--[[ Spectator Mode Addon for SRB2 Introduces a feature allowing players to view the game as a free-floating camera or by tracking specific objects. 'Freecamera' mode: Viewpoint is detached, allowing free navigation of the level. 'Tracking object' mode: Follows a chosen entity automatically. Toggles: Camera can switch between players and objects for focused perspective. --]] -- Prevent multiple initializations of the script if sirexer_spectator_mode then print("\x85".."ERROR".."\x80"..": Spectator Mode is already added in the game!") return else rawset(_G, "sirexer_spectator_mode", true) end -- Addon metadata registration local sc_addon = { name = "Spectator Camera", version = 2, subversion = 3, author = "Sirexer", } if not addoninfo then rawset(_G, "addoninfo", {}) end table.insert(addoninfo, sc_addon) -- Allocate slots for the camera object freeslot("MT_SPECCAMERA") -- Definition of the Camera Object properties mobjinfo[MT_SPECCAMERA] = { doomednum = -1, spawnstate = S_INVISIBLE, spawnhealth = 1000, reactiontime = 5, radius = 16 * FU, height = 32 * FU, flags = MF_NOTHINK } -- Forward declarations of core variables and functions local speccam local cmd_main local sc_chase local saveControls local loadControls local saveConfig local loadConfig -- Localize math functions for performance optimization local FixedMul = FixedMul local FixedDiv = FixedDiv local FixedHypot = FixedHypot local sin, cos = sin, cos local min, max = min, max local abs = abs local PossibleValues = { ["sc_gamepad"] = {generic = 0, xbox = 1, playstation = 2, nintendo = 3}, ["sc_remove_on_mapchange"] = {No = 0, Yes = 1}, ["sc_camerahud"] = {Off = 0, On = 1}, ["sc_cinema_damp"] = {MIN = 0, MAX = 32}, ["sc_glide_damp"] = {MIN = 0, MAX = 32}, ["sc_distance_reset"] = {MIN = 0, MAX = 8192}, ["sc_time_distance_reset"] = {MIN = 0, MAX = 60}, ["sc_time_sight_reset"] = {MIN = 0, MAX = 60} } -- Configuration console variables (CVARs) local sc_gamepad = CV_RegisterVar({ name = "sc_gamepad", defaultvalue = 1, flags = CV_CALL, PossibleValue = PossibleValues["sc_gamepad"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_remove_on_mapchange = CV_RegisterVar({ name = "sc_remove_on_mapchange", defaultvalue = 1, flags = CV_CALL, PossibleValue = PossibleValues["sc_remove_on_mapchange"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_camerahud = CV_RegisterVar({ name = "sc_camerahud", defaultvalue = 1, flags = CV_CALL, PossibleValue = PossibleValues["sc_camerahud"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_cinema_damp = CV_RegisterVar({ name = "sc_cinema_damp", defaultvalue = 30, flags = CV_CALL, PossibleValue = PossibleValues["sc_cinema_damp"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_glide_damp = CV_RegisterVar({ name = "sc_glide_damp", defaultvalue = 31, flags = CV_CALL, PossibleValue = PossibleValues["sc_glide_damp"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_distance_reset = CV_RegisterVar({ name = "sc_distance_reset", defaultvalue = 2048, flags = CV_CALL, PossibleValue = PossibleValues["sc_distance_reset"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_time_distance_reset = CV_RegisterVar({ name = "sc_time_distance_reset", defaultvalue = 3, flags = CV_CALL, PossibleValue = PossibleValues["sc_time_distance_reset"], func = function(cvar) if not saveConfig then return end saveConfig() end }) local sc_time_sight_reset = CV_RegisterVar({ name = "sc_time_sight_reset", defaultvalue = 0, flags = CV_CALL, PossibleValue = PossibleValues["sc_time_sight_reset"], func = function(cvar) if not saveConfig then return end saveConfig() end }) -- Controller button mapping definitions local gamepad_buttons = { ["xbox"] = { ["joy1"] = "\x83".."A".."\x80", ["joy2"] = "\x85".."B".."\x80", ["joy3"] = "\x84".."X".."\x80", ["joy4"] = "\x82".."Y".."\x80", ["joy5"] = "LB", ["joy6"] = "RB", ["joy7"] = "Back", ["joy8"] = "Start", ["joy9"] = "LS", ["joy10"] = "RS", ["joy11"] = "LT", ["joy12"] = "RT", ["joy13"] = "Guide", ["hatup"] = "D-Pad Up", ["hatdown"] = "D-Pad Down", ["hatleft"] = "D-Pad Left", ["hatright"] = "D-Pad Right" }, ["playstation"] = { ["joy1"] = "Cross", ["joy2"] = "Circle", ["joy3"] = "Square", ["joy4"] = "Triangle", ["joy5"] = "Back", ["joy6"] = "PS Button", ["joy7"] = "Start", ["joy8"] = "L3", ["joy9"] = "R3", ["joy10"] = "L2", ["joy11"] = "R2", ["joy12"] = "D-Pad Up", ["joy13"] = "D-Pad Down", ["joy14"] = "D-Pad Left", ["joy15"] = "D-Pad Right" }, ["nintendo"] = { ["joy1"] = "\x85".."B".."\x80", ["joy2"] = "\x83".."A".."\x80", ["joy3"] = "\x82".."Y".."\x80", ["joy4"] = "\x84".."X".."\x80", ["joy5"] = "Back", ["joy6"] = "Guide", ["joy7"] = "Start", ["joy8"] = "LS", ["joy9"] = "RS", ["joy10"] = "LB", ["joy11"] = "RB", ["joy12"] = "D-Pad Up", ["joy13"] = "D-Pad Down", ["joy14"] = "D-Pad Left", ["joy15"] = "D-Pad Right", ["joy16"] = "Screenshot" }, } -- Default key and button bindings local default_controls = { -- Movement ["forward"] = {"w"}, ["backward"] = {"s"}, ["strafeleft"] = {"a"}, ["straferight"] = {"d"}, ["moveup"] = {"space"}, ["movedown"] = {"lctrl"}, -- Looking ["rollleft"] = {"q", "joy5"}, ["rollright"] = {"e", "joy6"}, ["turnleft"] = {"left arrow"}, ["turnright"] = {"right arrow"}, ["lookup"] = {"up arrow"}, ["lookdown"] = {"down arrow"}, -- Freecam Controls ["speedup"] = {"lshift", "joy9"}, ["centerview"] = {"x", "joy9+joy10"}, ["increasespeed"] = {"wheel 1 up", "hatup"}, ["reducespeed"] = {"wheel 1 down", "hatdown"}, ["trackobject"] = {"mouse1", "joy10"}, ["togglecinema"] = {"1", "joy1"}, ["toggleglide"] = {"2", "joy2"}, ["togglesixdof"] = {"3", "joy3"}, ["togglenoclip"] = {"4", "joy4"}, ["togglegravity"] = {"5", "joy3+joy4"}, -- Object Tracking Controls ["movecloser"] = {"wheel 1 up", "hatup"}, ["moveaway"] = {"wheel 1 down", "hatdown"}, ["resetposition"] = {"mouse3", "joy9"}, ["togglefollow"] = {"z", "joy1"}, ["toggleangle"] = {"x", "joy3"}, ["togglebtplayer"] = {"c", "joy9+joy2"}, ["togglebtcamera"] = {"o", "joy10+joy2"}, ["chasecam"] = {"v", "joy4"}, -- Miscellaneous ["togglecamera"] = {"f5", "joy1+joy4"}, -- A+Y ["freecamera"] = {"f6", "joy3+joy4"}, -- X+Y ["trackself"] = {"f7", "joy10+joy1"}, -- LS + A ["togglehud"] = {"r", "joy9+joy4"}, -- RS + Y ["settings"] = {"i"}, ["zoomin"] = {"=", "hatright"}, ["zoomout"] = {"-", "hatleft"}, ["prevplayer"] = {"["}, ["nextplayer"] = {"]"} } -- Actions that trigger continuously while held local repeated_controls = { ["forward"] = true, ["backward"] = true, ["strafeleft"] = true, ["straferight"] = true, ["moveup"] = true, ["movedown"] = true, ["rollleft"] = true, ["rollright"] = true, ["turnleft"] = true, ["turnright"] = true, ["lookup"] = true, ["lookdown"] = true, ["speedup"] = true, ["centerview"] = true, ["movecloser"] = true, ["moveaway"] = true, ["zoomin"] = true, ["zoomout"] = true } -- Keys that cannot be bound local blacklist_keys = { ["f3"] = true, ["f8"] = true, ["f9"] = true, ["f10"] = true, ["f11"] = true, ["esc"] = true, ["tilde"] = true, ["leftwin"] = true, ["rightwin"] = true, ["pause/break"] = true } -- Keys whose default game actions are blocked while spectator mode is active local block_keys = { ["f1"] = true, ["f2"] = true, ["f4"] = true, ["f5"] = true, ["f6"] = true, ["f7"] = true } -- Specific SRB2 controls to block local block_srb_control = { [GC_CENTERVIEW] = true, [GC_CAMTOGGLE] = true, [GC_CAMRESET] = true, [GC_SCORES] = true } local controls = {} for k, v in pairs(default_controls) do controls[k] = v end -- State tracking for inputs local pressed_keys = {} local impulse_keys = {} local active_binds = {} local prev_state = {} local hold_time = {} local combo_wait = {} local combo_lock = {} local HOLD_DELAY = 35 local HOLD_REPEAT = 2 local COMBO_GRACE = 1 local compiled_controls = {} -- HUD state configuration local sc_hud = { enabled = false, index = 2, fade = 0, hud_scroll = 0, col = 1, binding = false, cooldown = 0, last_tic = 0, current_combo = {} -- Stores the currently held combo during binding mode } local IsGamepad = false -- Compile controls into manageable structures for multi-key evaluation for control, binds in pairs(controls) do compiled_controls[control] = {} for _, bind in ipairs(binds) do local parts = {} for part in string.gmatch(bind, "[^+]+") do parts[#parts+1] = part end compiled_controls[control][#compiled_controls[control]+1] = { keys = parts, size = #parts } end end -- Check if a bind represents a mouse wheel action local function isImpulseBind(bind) return string.find(bind, "^wheel") end -- Evaluates if a specific multi-key or single-key bind is currently fully held down local function isCompiledBindActive(bind) for i = 1, bind.size do local key = bind.keys[i] if isImpulseBind(key) then if not impulse_keys[key] then return false end else if not pressed_keys[key] then return false end end end return true end -- Translate internal joypad binds to user-friendly display strings local function translateGamepadBind(bind) local map = gamepad_buttons[sc_gamepad.string] if not map then return bind end local parts = {} for part in string.gmatch(bind, "[^+]+") do parts[#parts+1] = map[part] or part end return table.concat(parts, " + ") end -- Check if the input name is a gamepad button or hat local function isGamepadBind(bind) return string.find(bind, "^joy") or string.find(bind, "^hat") end -- Main function to check if a logical control action is triggered local function controlPressed(control) if sc_hud.enabled then return false end local bind = active_binds[control] if not bind then prev_state[control] = false hold_time[control] = 0 return false end -- If this is a single-key control and the key is locked by a combo sequence if bind.size == 1 then local key = bind.keys[1] if combo_lock[key] then return false end end -- Suppress shorter combinations if a longer combination sharing the same keys is active for otherControl, otherBind in pairs(active_binds) do if otherControl ~= control and otherBind and otherBind.size > bind.size then local match = true for i = 1, bind.size do local key = bind.keys[i] local found = false for j = 1, otherBind.size do if otherBind.keys[j] == key then found = true break end end if not found then match = false break end end if match then prev_state[control] = false hold_time[control] = 0 return false end end end -- Continuous controls fire continuously when active if repeated_controls[control] then prev_state[control] = true return true end -- Wait briefly to see if the user is trying to press a combo if combo_wait[control] and combo_wait[control] > 0 then combo_wait[control] = combo_wait[control] - 1 if combo_wait[control] == 0 then return true end return false end hold_time[control] = (hold_time[control] or 0) + 1 -- First tick of the key press if not prev_state[control] then -- Lock individual keys from triggering single-binds if part of a combo if bind.size > 1 then for i = 1, bind.size do combo_lock[bind.keys[i]] = true end end -- Check for the existence of a longer bind that shares these keys for otherControl, bindList in pairs(compiled_controls) do for _, otherBind in ipairs(bindList) do if otherBind.size > bind.size then local match = true for i = 1, bind.size do local key = bind.keys[i] local found = false for j = 1, otherBind.size do if otherBind.keys[j] == key then found = true break end end if not found then match = false break end end if match then combo_wait[control] = COMBO_GRACE prev_state[control] = true hold_time[control] = 0 return false end end end end prev_state[control] = true hold_time[control] = 0 return true end -- Enforce hold delay before repeating the action if hold_time[control] < HOLD_DELAY then return false end -- Accelerated repeat rate after the initial hold delay if (hold_time[control] - HOLD_DELAY) % HOLD_REPEAT == 0 then return true end return false end -- Helper: Deep copy a table local function cloneTable(t) local new = {} for k, v in pairs(t) do if type(v) == "table" then new[k] = cloneTable(v) else new[k] = v end end return new end -- Splits a composite bind string (e.g., "mouse1+lshift") into array components local function splitBind(bind) if not string.find(bind, "%S%+%S") then return { bind } end local parts = {} for part in string.gmatch(bind, "([^+]+)") do parts[#parts+1] = part end return parts end -- Re-evaluates strings into compiled control definitions when updated local function recompileControl(control) if not controls[control] then return end compiled_controls[control] = {} for _, bind in ipairs(controls[control]) do local parts = splitBind(bind) if #parts > 0 then compiled_controls[control][#compiled_controls[control]+1] = { keys = parts, size = #parts } end end end -- Checks if a specific bind exists within a control local function hasBind(control, bind) for _, b in ipairs(controls[control]) do if b == bind then return true end end return false end local function escape(str) return string.gsub(str, '"', '\\"') end -- File operations for Controls and Configurations function saveControls() local file = io.openlocal("client/sc_controls.cfg", "w") if not file then return end file:write("// Spectator Camera Controls CFG\n\n") for control, bindlist in pairs(controls) do if #bindlist > 0 then file:write(control) for _, bind in ipairs(bindlist) do file:write(" \""..escape(bind).."\"") end file:write("\n") end end file:close() end function loadControls() local file = io.openlocal("client/sc_controls.cfg", "r") if not file then return end for line in file:lines() do -- Trim whitespaces line = string.match(line, "^%s*(.-)%s*$") -- Skip empty lines and comments if line ~= "" and not string.find(line, "^//") then -- Control name parsing local control = string.match(line, "^[^%s\"]+") if control and controls[control] then local newbinds = {} for bind in string.gmatch(line, '"(.-)"') do newbinds[#newbinds+1] = bind end controls[control] = newbinds recompileControl(control) end end end file:close() end function saveConfig() local f = io.openlocal("client/sc_config.cfg", "w") if not f then return end f:write("sc_gamepad ", sc_gamepad.string, "\n") f:write("sc_camerahud ", sc_camerahud.string, "\n") f:write("sc_remove_on_mapchange ", sc_remove_on_mapchange.string, "\n") f:write("sc_cinema_damp ", sc_cinema_damp.string, "\n") f:write("sc_glide_damp ", sc_glide_damp.string, "\n") f:write("sc_distance_reset ", sc_distance_reset.string, "\n") f:write("sc_time_distance_reset ", sc_time_distance_reset.string, "\n") f:write("sc_time_sight_reset ", sc_time_sight_reset.string, "\n") f:close() end function loadConfig() local f = io.openlocal("client/sc_config.cfg", "r") if not f then return end for line in f:lines() do local name, value = line:match("^(%S+)%s+(.+)$") if name and value then local cv = CV_FindVar(name) if cv then CV_Set(cv, value) end end end f:close() end -- Detect if the user is currently utilizing a gamepad based on axis threshold inputs local function usingGamepad() -- Main movement axes if abs(input.joyAxis(JA_MOVE)) >= 10 then IsGamepad = true end if abs(input.joyAxis(JA_STRAFE)) >= 10 then IsGamepad = true end if abs(input.joyAxis(JA_TURN)) >= 10 then IsGamepad = true end if abs(input.joyAxis(JA_LOOK)) >= 10 then IsGamepad = true end -- Triggers (JUMP / SPIN) local joy_jump = (input.joyAxis(JA_JUMP) + 1024) / 2 local joy_spin = (input.joyAxis(JA_SPIN) + 1024) / 2 -- If triggers were not pressed since the game started, they default to 512 if joy_jump == 512 then joy_jump = 0 end if joy_spin == 512 then joy_spin = 0 end if abs(joy_jump) >= 10 then IsGamepad = true end if abs(joy_spin) >= 10 then IsGamepad = true end return IsGamepad end -- Constructs a readable display string representing the current bind for a control local function getBindText(control) if not controls[control] or #controls[control] == 0 then return "\"UNBOUND\"" end local wantGamepad = usingGamepad() local primary = {} local fallback = {} for _, bind in ipairs(controls[control]) do if isGamepadBind(bind) then if wantGamepad then primary[#primary+1] = bind else fallback[#fallback+1] = bind end else if not wantGamepad then primary[#primary+1] = bind else fallback[#fallback+1] = bind end end end local chosen = (#primary > 0) and primary or fallback local out = {} for _, bind in ipairs(chosen) do local display = bind if isGamepadBind(bind) then display = translateGamepadBind(bind) end out[#out+1] = "\"" .. display .. "\"" end return table.concat(out, " | ") end loadControls() loadConfig() -- Console commands for interacting with the control scheme manually COM_AddCommand("sc_control", function(player, ...) if player ~= consoleplayer then return end local args = {...} if #args < 2 then CONS_Printf(player, "Usage:\n".. "sc_control set [bind2]\n".. "sc_control add \n".. "sc_control remove \n".. "sc_control default") return end local control = string.lower(args[1]) if not controls[control] then CONS_Printf(player, "Unknown control: "..control) return end local mode = string.lower(args[2]) if mode == "default" then controls[control] = cloneTable(default_controls[control]) recompileControl(control) CONS_Printf(player, "Control '"..control.."' reset to default.") return saveControls() end if mode == "add" then if not args[3] or args[3] == "" then CONS_Printf(player, "Invalid bind."); return end local bind = string.lower(args[3]) if hasBind(control, bind) then CONS_Printf(player, "Bind already exists."); return end if blacklist_keys[bind] then CONS_Printf(player, "This key cannot be assigned."); return end controls[control][#controls[control]+1] = bind recompileControl(control) CONS_Printf(player, "Added bind '"..bind.."' to "..control) return saveControls() end if mode == "remove" then if not args[3] or args[3] == "" then CONS_Printf(player, "Invalid bind."); return end local bind = string.lower(args[3]) local removed = false for i = #controls[control], 1, -1 do if controls[control][i] == bind then table.remove(controls[control], i) removed = true end end if not removed then CONS_Printf(player, "Bind not found."); return end recompileControl(control) CONS_Printf(player, "Removed bind '"..bind.."' from "..control) return saveControls() end if mode == "set" then if not args[3] then CONS_Printf(player, "No binds specified."); return end controls[control] = {} for i = 3, #args do local bind = string.lower(args[i]) if bind ~= "" then controls[control][#controls[control]+1] = bind end end if #controls[control] == 0 then CONS_Printf(player, "No valid binds."); return end if blacklist_keys[bind] then CONS_Printf(player, "This key cannot be assigned."); return end recompileControl(control) CONS_Printf(player, "Control '"..control.."' updated.") return saveControls() end CONS_Printf(player, "Unknown mode: "..mode) end, COM_LOCAL) COM_AddCommand("sc_listcontrols", function(player, ...) if player ~= consoleplayer then return end local args = {...} if args[1] then local control = string.lower(args[1]) if not controls[control] then CONS_Printf(player, "Unknown control: "..control); return end CONS_Printf(player, "Control: "..control) if #controls[control] == 0 then CONS_Printf(player, " (no binds)"); return end for i, bind in ipairs(controls[control]) do CONS_Printf(player, " "..i..". "..bind) end return end CONS_Printf(player, "==== Controls List ====") for control, bindlist in pairs(controls) do local line = control..": " if #bindlist == 0 then line = line.."(no binds)" else line = line..table.concat(bindlist, ", ") end CONS_Printf(player, line) end end, COM_LOCAL) -- Low-level key state capturing addHook("KeyDown", function(key) if chatactive then return end if key.repeated then return end local k_name = key.name:lower() if blacklist_keys[k_name] then return end IsGamepad = isGamepadBind(k_name) if isImpulseBind(k_name) then impulse_keys[k_name] = true else pressed_keys[k_name] = true end if speccam and block_srb_control then for k, v in pairs(block_srb_control) do local knum1, knum2 = input.gameControlToKeyNum(k) if knum1 == key.num or knum2 == key.num then return true end end end if sc_hud.enabled then return true end return block_keys and block_keys[k_name] end) addHook("KeyUp", function(key) local k_name = key.name:lower() pressed_keys[k_name] = nil end) addHook("ThinkFrame", function() -- Disable gamepad flag if raw mouse movements are detected if mouse then if mouse.dx ~= 0 or mouse.dy ~= 0 then IsGamepad = false end end active_binds = {} -- Determine which valid action currently resolves favorably to being pressed for control, binds in pairs(compiled_controls) do local bestBind = nil for i = 1, #binds do if isCompiledBindActive(binds[i]) then if not bestBind or binds[i].size > bestBind.size then bestBind = binds[i] end end end active_binds[control] = bestBind end -- Release the combo lock if the locking key is physically let go for key, _ in pairs(combo_lock) do if not pressed_keys[key] then combo_lock[key] = nil end end impulse_keys = {} end) -- Dictionary for formatted display names used in the Settings UI local display_names = { -- CVARs sc_gamepad = "Gamepad layout", sc_remove_on_mapchange = "Remove on map change", sc_camerahud = "Camera HUD", sc_cinema_damp = "Cinema damping", sc_glide_damp = "Glide damping", sc_distance_reset = "Distance reset", sc_time_distance_reset = "Time Distance reset", sc_time_sight_reset = "Time sight reset", -- Primary Movement forward = "Move Forward", backward = "Move Backward", strafeleft = "Strafe Left", straferight = "Strafe Right", moveup = "Move Up", movedown = "Move Down", -- Viewing / Rotation rollleft = "Roll Left", rollright = "Roll Right", turnleft = "Turn Left", turnright = "Turn Right", lookup = "Look Up", lookdown = "Look Down", centerview = "Center View", -- Speed Variables speedup = "Boost Speed (Hold)", increasespeed = "Increase Base Speed", reducespeed = "Decrease Base Speed", -- Camera Core Modes trackobject = "Track Object", togglecinema = "Cinema Mode", toggleglide = "Glide Mode", togglesixdof = "6-DOF Mode", togglenoclip = "Noclip Mode", togglegravity = "Toggle Gravity", -- Camera Framing Adjustments movecloser = "Camera Closer", moveaway = "Camera Away", resetposition = "Reset Position", togglefollow = "Toggle Follow Track", toggleangle = "Toggle Angle Track", -- Target selection / Control block togglebtplayer = "Block Player Movement", togglebtcamera = "Block Camera Movement", chasecam = "Chase Camera", togglecamera = "Toggle Camera", freecamera = "Free Camera Mode", trackself = "Track Myself", -- Interface Utilities prevplayer = "Previous player", nextplayer = "Next player", togglehud = "Show/Hide HUD", settings = "Open Settings", zoomin = "Zoom In", zoomout = "Zoom Out" } -- Menu navigation controls dictionary local menu_navigation = { ["up"] = {"up arrow", "hatup", "joy12"}, ["down"] = {"down arrow", "hatdown", "joy13"}, ["left"] = {"left arrow", "hatleft", "joy14"}, ["right"] = {"right arrow", "hatright", "joy15"}, ["accept"] = {"enter", "joy1"}, ["back"] = {"escape", "joy7", "joy5"}, ["remove"] = {"backspace", "joy2"} } -- Helper function to check if navigation keys are pressed local function isNavPressed(action) for _, key in ipairs(menu_navigation[action] or {}) do if pressed_keys[key] then return true end end return false end -- Updated list of items with logical headers for menu structuring local menu_items = { {type = "main_header", text = "Console Variables"}, {type = "cvar", name = "sc_gamepad", cvar = sc_gamepad, PossibleValue = PossibleValues["sc_gamepad"]}, {type = "cvar", name = "sc_remove_on_mapchange", cvar = sc_remove_on_mapchange, PossibleValue = PossibleValues["sc_remove_on_mapchange"]}, {type = "cvar", name = "sc_camerahud", cvar = sc_camerahud, PossibleValue = PossibleValues["sc_camerahud"]}, {type = "cvar", name = "sc_cinema_damp", cvar = sc_cinema_damp, PossibleValue = PossibleValues["sc_cinema_damp"]}, {type = "cvar", name = "sc_glide_damp", cvar = sc_glide_damp, PossibleValue = PossibleValues["sc_glide_damp"]}, {type = "sub_header", text = "Object Camera Reset"}, {type = "cvar", name = "sc_distance_reset", cvar = sc_distance_reset, PossibleValue = PossibleValues["sc_distance_reset"]}, {type = "cvar", name = "sc_time_distance_reset", cvar = sc_time_distance_reset, PossibleValue = PossibleValues["sc_time_distance_reset"]}, {type = "cvar", name = "sc_time_sight_reset", cvar = sc_time_sight_reset, PossibleValue = PossibleValues["sc_time_sight_reset"]}, {type = "main_header", text = "Controls"}, {type = "sub_header", text = "Movement"}, {type = "control", name = "forward"}, {type = "control", name = "backward"}, {type = "control", name = "strafeleft"}, {type = "control", name = "straferight"}, {type = "control", name = "moveup"}, {type = "control", name = "movedown"}, {type = "sub_header", text = "Camera Rotation"}, {type = "control", name = "rollleft"}, {type = "control", name = "rollright"}, {type = "control", name = "turnleft"}, {type = "control", name = "turnright"}, {type = "control", name = "lookup"}, {type = "control", name = "lookdown"}, {type = "control", name = "centerview"}, {type = "sub_header", text = "Free Camera"}, {type = "control", name = "trackobject"}, {type = "control", name = "speedup"}, {type = "control", name = "increasespeed"}, {type = "control", name = "reducespeed"}, {type = "control", name = "togglecinema"}, {type = "control", name = "toggleglide"}, {type = "control", name = "togglesixdof"}, {type = "control", name = "togglenoclip"}, {type = "control", name = "togglegravity"}, {type = "sub_header", text = "Object Camera"}, {type = "control", name = "movecloser"}, {type = "control", name = "moveaway"}, {type = "control", name = "resetposition"}, {type = "control", name = "togglefollow"}, {type = "control", name = "toggleangle"}, {type = "control", name = "togglebtplayer"}, {type = "control", name = "togglebtcamera"}, {type = "control", name = "chasecam"}, {type = "control", name = "prevplayer"}, {type = "control", name = "nextplayer"}, {type = "sub_header", text = "Camera Modes"}, {type = "control", name = "togglecamera"}, {type = "control", name = "freecamera"}, {type = "control", name = "trackself"}, {type = "sub_header", text = "Interface"}, {type = "control", name = "settings"}, {type = "control", name = "togglehud"}, {type = "control", name = "zoomin"}, {type = "control", name = "zoomout"}, } local keyPriority = { lctrl = 1, lalt = 2, lshift = 3, rctrl = 4, ralt = 5, rshift = 6, } local function sortCombo(combo) table.sort(combo, function(a, b) local pa = keyPriority[a] local pb = keyPriority[b] if pa and pb then return pa < pb end if pa then return true end if pb then return false end return a < b end) end local CV_HUD = CV_FindVar("showhud") local function settings_control(v, player) if not sc_hud.enabled then return end if CV_HUD.value == 0 then CV_Set(CV_HUD, 1) end if sc_hud.last_tic == leveltime then return end sc_hud.last_tic = leveltime local header_h = 16 local page_h = 200 - (header_h * 2) local page_x = 4 local page_w = 300 local item_spacing = 14 -- Accurate calculation of maximum scroll local total_h = 0 for i, item in ipairs(menu_items) do if item.type == "main_header" and i ~= 1 then total_h = total_h + 6 end total_h = total_h + item_spacing if item.type == "main_header" then total_h = total_h + 4 end end local max_scroll = max(0, total_h - page_h + 10) sc_hud.target_scroll = sc_hud.target_scroll or 0 -- Login of coordinates and mouse clicks local mouse1_pressed = pressed_keys["mouse1"] local mouse1_just_pressed = mouse1_pressed and not sc_hud.last_mouse1 sc_hud.last_mouse1 = mouse1_pressed local mouse2_pressed = pressed_keys["mouse2"] local mouse2_just_pressed = mouse2_pressed and not sc_hud.last_mouse2 sc_hud.last_mouse2 = mouse2_pressed if mouse then if sc_hud.cursor_x == nil then sc_hud.cursor_x = 160 end if sc_hud.cursor_y == nil then sc_hud.cursor_y = 100 end sc_hud.cursor_x = max(1, min((sc_hud.cursor_x) + mouse.dx / 8, 320)) sc_hud.cursor_y = max(1, min((sc_hud.cursor_y) + mouse.dy / 8, 200)) -- Testing the cursor movement and clicking the close button (“X”) sc_hud.hovered_close = false if sc_hud.cursor_x >= 304 and sc_hud.cursor_x <= 320 and sc_hud.cursor_y >= 0 and sc_hud.cursor_y <= 16 then sc_hud.hovered_close = true if mouse1_just_pressed then S_StartSound(nil, sfx_menu1, consoleplayer) sc_hud.enabled = false sc_hud.binding = false sc_hud.cooldown = 10 sc_hud.dragging_cvar = nil -- Reset sc_hud.dragging_pv = nil return end end -- The mouse wheel scrolls the page ONLY if we do not assign a button. if not sc_hud.binding then if impulse_keys["wheel 1 up"] then sc_hud.target_scroll = max(0, sc_hud.target_scroll - 30) elseif impulse_keys["wheel 1 down"] then sc_hud.target_scroll = min(max_scroll, sc_hud.target_scroll + 30) end end -- Dragging the slider local scrollBarX = page_x + page_w + 4 local scrollBarY = header_h + 12 local scrollBarWidth = 8 local scrollBarHeight = page_h - 24 if mouse1_just_pressed and sc_hud.cursor_x >= scrollBarX and sc_hud.cursor_x <= scrollBarX + scrollBarWidth and sc_hud.cursor_y >= scrollBarY and sc_hud.cursor_y <= scrollBarY + scrollBarHeight then sc_hud.dragging_scroll = true end if not mouse1_pressed then sc_hud.dragging_scroll = false sc_hud.dragging_cvar = nil -- Reset CVAR slider drag sc_hud.dragging_pv = nil end if sc_hud.dragging_scroll then local relative_y = sc_hud.cursor_y - scrollBarY local scroll_pos = (relative_y * max_scroll) / scrollBarHeight sc_hud.target_scroll = max(0, min(max_scroll, scroll_pos)) end -- Logic of dragging the CVAR slider if sc_hud.dragging_cvar then local track_x = page_x + page_w - 130 local track_w = 80 local min_val = sc_hud.dragging_pv.MIN local max_val = sc_hud.dragging_pv.MAX local relative_x = sc_hud.cursor_x - track_x local pct = (relative_x * FRACUNIT) / track_w pct = max(0, min(FRACUNIT, pct)) local range = max_val - min_val local new_val = min_val + (pct * range) / FRACUNIT if sc_hud.dragging_cvar.value ~= new_val then CV_Set(sc_hud.dragging_cvar, new_val) end end else sc_hud.cursor_x = sc_hud.cursor_x or 160 sc_hud.cursor_y = sc_hud.cursor_y or 100 sc_hud.dragging_scroll = false sc_hud.hovered_close = false sc_hud.dragging_cvar = nil sc_hud.dragging_pv = nil end if sc_hud.cooldown > 0 then sc_hud.cooldown = sc_hud.cooldown - 1 return end local current_item = menu_items[sc_hud.index] sc_hud.ignore_keys = sc_hud.ignore_keys or {} -- Bindind mode if sc_hud.binding then local any_pressed = false local all_keys = {} for k, v in pairs(pressed_keys) do all_keys[k] = v end for k, v in pairs(impulse_keys) do all_keys[k] = v end for k, _ in pairs(all_keys) do if not sc_hud.ignore_keys[k] then any_pressed = true local found = false for _, ck in ipairs(sc_hud.current_combo) do if ck == k then found = true; break end end if not found then table.insert(sc_hud.current_combo, k) end sortCombo(sc_hud.current_combo) end end for k, _ in pairs(sc_hud.ignore_keys) do if not all_keys[k] then sc_hud.ignore_keys[k] = nil end end if not any_pressed and #sc_hud.current_combo > 0 then if current_item.type == "control" then S_StartSound(nil, sfx_strpst, consoleplayer) controls[current_item.name][sc_hud.col] = table.concat(sc_hud.current_combo, "+") recompileControl(current_item.name) saveControls() end sc_hud.binding = false sc_hud.current_combo = {} sc_hud.cooldown = 10 end if isNavPressed("back") then sc_hud.binding = false; sc_hud.current_combo = {}; sc_hud.cooldown = 10 end return end -- Mouse pointing at element local draw_y = header_h + 8 - (sc_hud.hud_scroll or 0) local bind_w = 80 local bind2_x = page_x + page_w - 8 - bind_w local bind1_x = bind2_x - bind_w - 4 local hovered_idx = nil local hovered_col = nil local hovered_cvar_arrow = nil local hovered_cvar_slider = nil -- Slider highlighting local mouse_moved = mouse and (mouse.dx ~= 0 or mouse.dy ~= 0) for i, item in ipairs(menu_items) do if draw_y >= 4 and (draw_y + 10) <= (200 - 4) then if item.type == "main_header" and i ~= 1 then draw_y = draw_y + 6 end if sc_hud.cursor_y >= draw_y - 3 and sc_hud.cursor_y < draw_y - 3 + item_spacing then if item.type == "cvar" or item.type == "control" then hovered_idx = i if item.type == "control" then if sc_hud.cursor_x >= bind1_x and sc_hud.cursor_x <= bind1_x + bind_w then hovered_col = 1 elseif sc_hud.cursor_x >= bind2_x and sc_hud.cursor_x <= bind2_x + bind_w then hovered_col = 2 end elseif item.type == "cvar" then -- Guidance logic for different types of CVAR local is_slider = item.PossibleValue and item.PossibleValue.MIN ~= nil and item.PossibleValue.MAX ~= nil if is_slider then local track_x = page_x + page_w - 130 local track_w = 80 if sc_hud.cursor_x >= track_x - 4 and sc_hud.cursor_x <= track_x + track_w + 4 then hovered_cvar_slider = item.cvar end else local box_w = 80 local box_x = page_x + page_w - 24 - box_w local left_arrow_x = box_x - 12 local right_arrow_x = box_x + box_w + 4 if sc_hud.cursor_x >= left_arrow_x - 6 and sc_hud.cursor_x <= left_arrow_x + 10 then hovered_cvar_arrow = -1 elseif sc_hud.cursor_x >= right_arrow_x - 6 and sc_hud.cursor_x <= right_arrow_x + 10 then hovered_cvar_arrow = 1 end end end end end if item.type == "main_header" then draw_y = draw_y + 4 end end draw_y = draw_y + item_spacing end -- Ignore hover and clicks if sliders are currently being dragged if hovered_idx and (mouse_moved or mouse1_just_pressed or mouse2_just_pressed) and not sc_hud.dragging_scroll and not sc_hud.dragging_cvar and sc_hud.cursor_x < page_w then sc_hud.index = hovered_idx if hovered_col then sc_hud.col = hovered_col end current_item = menu_items[sc_hud.index] end -- Interaction with the mouse if hovered_idx and not sc_hud.dragging_scroll and not sc_hud.dragging_cvar then if mouse1_just_pressed then if current_item.type == "control" and hovered_col then sc_hud.binding = true sc_hud.current_combo = {} sc_hud.cooldown = 5 for k, _ in pairs(pressed_keys) do sc_hud.ignore_keys[k] = true end elseif current_item.type == "cvar" then -- We check what exactly was clicked on local is_slider = current_item.PossibleValue and current_item.PossibleValue.MIN ~= nil and current_item.PossibleValue.MAX ~= nil if is_slider and hovered_cvar_slider then sc_hud.dragging_cvar = current_item.cvar sc_hud.dragging_pv = current_item.PossibleValue elseif not is_slider then if hovered_cvar_arrow == -1 then S_StartSound(nil, sfx_menu1, consoleplayer) CV_AddValue(current_item.cvar, -1) sc_hud.cooldown = 5 elseif hovered_cvar_arrow == 1 then S_StartSound(nil, sfx_menu1, consoleplayer) CV_AddValue(current_item.cvar, 1) sc_hud.cooldown = 5 end end end elseif mouse2_just_pressed then if current_item.type == "control" and hovered_col then S_StartSound(nil, sfx_altdi1, consoleplayer) controls[current_item.name][sc_hud.col] = nil if sc_hud.col == 1 and controls[current_item.name][2] then controls[current_item.name][1] = controls[current_item.name][2] controls[current_item.name][2] = nil end recompileControl(current_item.name) saveControls() sc_hud.cooldown = 10 end end end -- Standard control (Keyboard/gamepad) if isNavPressed("remove") then if current_item.type == "control" then S_StartSound(nil, sfx_altdi1, consoleplayer) controls[current_item.name][sc_hud.col] = nil if sc_hud.col == 1 and controls[current_item.name][2] then controls[current_item.name][1] = controls[current_item.name][2] controls[current_item.name][2] = nil end recompileControl(current_item.name) saveControls() sc_hud.cooldown = 10 return elseif current_item.type == "cvar" then S_StartSound(nil, sfx_altdi1, consoleplayer) CV_Set(current_item.cvar, current_item.cvar.defaultvalue) sc_hud.cooldown = 10 end end local function moveIndex(dir) local total = #menu_items local new_idx = sc_hud.index repeat new_idx = (new_idx + dir - 1 + total) % total + 1 until menu_items[new_idx].type ~= "main_header" and menu_items[new_idx].type ~= "sub_header" sc_hud.index = new_idx sc_hud.snap_to_index = true end if isNavPressed("up") then S_StartSound(nil, sfx_menu1, consoleplayer) moveIndex(-1); sc_hud.cooldown = 4 elseif isNavPressed("down") then S_StartSound(nil, sfx_menu1, consoleplayer) moveIndex(1); sc_hud.cooldown = 4 end if isNavPressed("left") then S_StartSound(nil, sfx_menu1, consoleplayer) if current_item.type == "control" then sc_hud.col = 1 elseif current_item.type == "cvar" then CV_AddValue(current_item.cvar, -1) end sc_hud.cooldown = 5 elseif isNavPressed("right") then S_StartSound(nil, sfx_menu1, consoleplayer) if current_item.type == "control" then sc_hud.col = 2 elseif current_item.type == "cvar" then CV_AddValue(current_item.cvar, 1) end sc_hud.cooldown = 5 end if isNavPressed("accept") and current_item.type == "control" then sc_hud.binding = true sc_hud.current_combo = {} sc_hud.cooldown = 5 for k, _ in pairs(pressed_keys) do sc_hud.ignore_keys[k] = true end for k, _ in pairs(impulse_keys) do sc_hud.ignore_keys[k] = true end end end local function drawCursor(v, x, y) local white = 3 local shadow = 10 v.drawFill(x, y, 1, 8, shadow) for i = 0, 4 do v.drawFill(x+i, y+i, 1, 1, shadow) end v.drawFill(x+2, y+6, 3, 1, shadow) v.drawFill(x+4, y+4, 1, 3, shadow) v.drawFill(x+1, y+1, 1, 6, white) v.drawFill(x+2, y+2, 1, 4, white) v.drawFill(x+3, y+3, 1, 2, white) v.drawFill(x+2, y+5, 2, 1, white) end local function settings_rendering(v, player) local header_h = 16 local page_h = 200 - (header_h * 2) local page_x = 4 local page_w = 300 local item_spacing = 14 v.drawFill(page_x, header_h, page_w, page_h, 158) local total_h = 0 for i, item in ipairs(menu_items) do if item.type == "main_header" and i ~= 1 then total_h = total_h + 6 end total_h = total_h + item_spacing if item.type == "main_header" then total_h = total_h + 4 end end local max_scroll = max(0, total_h - page_h + 10) -- If moving with the keyboard arrows, center the screen on the element if sc_hud.snap_to_index then local current_y = 0 for i=1, sc_hud.index do if menu_items[i].type == "main_header" and i ~= 1 then current_y = current_y + 6 end if i < sc_hud.index then current_y = current_y + item_spacing end if menu_items[i].type == "main_header" then current_y = current_y + 4 end end sc_hud.target_scroll = current_y - (page_h / 2) sc_hud.snap_to_index = false end sc_hud.target_scroll = sc_hud.target_scroll or 0 sc_hud.target_scroll = max(0, min(sc_hud.target_scroll, max_scroll)) sc_hud.hud_scroll = sc_hud.hud_scroll or 0 sc_hud.hud_scroll = sc_hud.hud_scroll + (sc_hud.target_scroll - sc_hud.hud_scroll) / 4 -- Scroll Bar if max_scroll > 0 then local scrollBarX = page_x + page_w + 4 local scrollBarY = header_h + 12 local scrollBarWidth = 8 local scrollBarHeight = page_h - 24 local scrollTrackX = scrollBarX + 1 local scrollTrackY = scrollBarY + 1 local scrollTrackWidth = 6 local scrollTrackHeight = scrollBarHeight - 2 v.drawFill(scrollBarX, scrollBarY, scrollBarWidth, scrollBarHeight, 158) v.drawFill(scrollTrackX, scrollTrackY, scrollTrackWidth, scrollTrackHeight, 31) local currentScroll = max(0, min(sc_hud.hud_scroll, max_scroll)) local thumbHeight = FixedMul( FixedDiv(page_h*FRACUNIT, total_h*FRACUNIT), scrollTrackHeight*FRACUNIT ) / FRACUNIT thumbHeight = max(8, thumbHeight) local thumbPosition = (max_scroll == 0) and 0 or (currentScroll * (scrollTrackHeight - thumbHeight)) / max_scroll thumbPosition = max(0, min(thumbPosition, scrollTrackHeight - thumbHeight)) local thumbY = scrollTrackY + thumbPosition -- Slider backlight when held down with the mouse local thumbColor = sc_hud.dragging_scroll and 74 or 73 v.drawFill(scrollTrackX, thumbY, scrollTrackWidth, thumbHeight+1, thumbColor) local shadowColor = sc_hud.dragging_scroll and 79 or 78 -- right shadow v.drawFill(scrollTrackX - 1 + scrollTrackWidth, thumbY, 1, thumbHeight, shadowColor) -- left shadow v.drawFill(scrollTrackX, thumbY + thumbHeight, scrollTrackWidth, 1, shadowColor) local arrow_y = ((leveltime % 10) > 5) and 2 or 0 if sc_hud.hud_scroll > 5 then v.drawString(scrollTrackX + 3, scrollBarY - 8 + arrow_y, string.char(26), V_YELLOWMAP, "center") end if sc_hud.hud_scroll < max_scroll - 5 then v.drawString(scrollTrackX + 3, scrollBarY + scrollBarHeight + arrow_y, string.char(27), V_YELLOWMAP, "center") end end local draw_y = header_h + 8 - sc_hud.hud_scroll local bind_w = 80 local bind2_x = page_x + page_w - 8 - bind_w local bind1_x = bind2_x - bind_w - 4 for i, item in ipairs(menu_items) do if draw_y >= 4 and (draw_y + 10) <= (200 - 4) then local is_selected = (i == sc_hud.index) local text_clr = is_selected and V_YELLOWMAP or 0 local readable_name = display_names[item.name] or item.name if item.type == "main_header" then if i ~= 1 then draw_y = draw_y + 6 end v.drawFill(page_x + 4, draw_y - 2, page_w - 8, 12, 154) v.drawString(page_x + 8, draw_y, item.text, V_YELLOWMAP, "left") draw_y = draw_y + 2 v.drawFill(page_x + 4, draw_y + 8, page_w - 8, 1, 73) draw_y = draw_y + 2 elseif item.type == "sub_header" then v.drawFill(page_x + 4, draw_y - 2, page_w - 8, 12, 155) v.drawString(page_x + 8, draw_y, item.text, V_GRAYMAP, "left") v.drawFill(page_x + 4, draw_y + 9, page_w - 8, 1, 20) elseif item.type == "cvar" or item.type == "control" then if is_selected then v.drawFill(page_x + 4, draw_y - 3, page_w - 8, item_spacing, 153) else local bg = (i % 2) > 0 and 156 or 157 v.drawFill(page_x + 4, draw_y - 3, page_w - 8, item_spacing, bg) end v.drawString(page_x + 8, draw_y, readable_name, text_clr, "thin") if item.type == "cvar" then -- Drawing CVAR with and without sliders local is_slider = item.PossibleValue and item.PossibleValue.MIN ~= nil and item.PossibleValue.MAX ~= nil if is_slider then local val = item.cvar.value local min_val = item.PossibleValue.MIN local max_val = item.PossibleValue.MAX local track_x = page_x + page_w - 130 local track_w = 80 local track_y = draw_y + 4 -- Draw a line v.drawFill(track_x, track_y, track_w, 2, 31) -- Calculating and Drawing a Slider local pct = FixedDiv((val - min_val) * FRACUNIT, (max_val - min_val) * FRACUNIT) local handle_x = track_x + FixedMul(track_w * FRACUNIT, pct) / FRACUNIT local handle_color = (sc_hud.dragging_cvar == item.cvar) and 72 or 73 local handle_shadow = (sc_hud.dragging_cvar == item.cvar) and 78 or 79 v.drawFill(handle_x - 2, track_y - 3, 4, 8, handle_color) v.drawFill(handle_x + 1, track_y - 3, 1, 8, handle_shadow) v.drawFill(handle_x - 2, track_y + 4, 4, 1, handle_shadow) -- Let's draw a small window containing the value itself local val_box_x = track_x + track_w + 8 local val_box_w = 24 local bg = is_selected and 134 or 253 local bg_shadow = is_selected and 136 or 254 v.drawFill(val_box_x, draw_y - 1, val_box_w, 10, bg) v.drawFill(val_box_x, draw_y + 8, val_box_w, 1, bg_shadow) v.drawFill(val_box_x + val_box_w - 1, draw_y - 1, 1, 10, bg_shadow) v.drawString(val_box_x + val_box_w/2, draw_y, tostring(val), text_clr, "thin-center") else -- A standard CVAR with an offset frame and arrows on the outside local val = item.cvar.string local box_w = 80 local box_x = page_x + page_w - 24 - box_w local bg = is_selected and 134 or 253 local bg_shadow = is_selected and 136 or 254 v.drawFill(box_x, draw_y - 1, box_w, 10, bg) v.drawFill(box_x, draw_y + 8, box_w, 1, bg_shadow) v.drawFill(box_x + box_w - 1, draw_y - 1, 1, 10, bg_shadow) v.drawString(box_x + box_w/2, draw_y, val, text_clr, "thin-center") local arrow_x_offset = ((leveltime % 10) > 5) and 2 or 0 if is_selected then v.drawString(box_x - 8 - arrow_x_offset, draw_y, string.char(28), text_clr, "thin-center") v.drawString(box_x + box_w + 8 + arrow_x_offset, draw_y, string.char(29), text_clr, "thin-center") end end else for col=1, 2 do local b_x = (col == 1) and bind1_x or bind2_x local b_val = controls[item.name][col] or "---" if is_selected and sc_hud.col == col and sc_hud.binding then b_val = #sc_hud.current_combo > 0 and translateGamepadBind(table.concat(sc_hud.current_combo, "+")) or "???" else b_val = translateGamepadBind(b_val) end local bg = (is_selected and sc_hud.col == col) and 134 or 253 local bg_shadow = (is_selected and sc_hud.col == col) and 136 or 254 v.drawFill(b_x, draw_y - 1, bind_w, 10, bg) v.drawFill(b_x, draw_y + 8, bind_w, 1, bg_shadow) v.drawFill(b_x + bind_w - 1, draw_y - 1, 1, 10, bg_shadow) local align = "thin-center" local bind_dy = draw_y if v.stringWidth(b_val, 0, "thin") > bind_w * 8 / 9 then align = "small-center" bind_dy = bind_dy + 1 end v.drawString(b_x + bind_w/2, bind_dy, b_val, 0, align) end end end end draw_y = draw_y + item_spacing end v.drawFill(0, 0, 320, header_h, 154) v.drawFill(0, 0, 320, 1, 72) v.drawFill(0, header_h, 320, 1, 31) v.drawString(160, 4, "Spectator Camera Settings", V_YELLOWMAP, "center") -- An illustration of the “Close” button in the upper-right corner local close_x = 304 local close_y = 0 if sc_hud.hovered_close then v.drawFill(close_x, close_y, 16, 16, 35) end v.drawScaled(close_x * FRACUNIT, close_y * FRACUNIT, FRACUNIT / 2, v.cachePatch("M_FNOPE")) v.drawFill(0, 200 - header_h, 320, header_h, 154) v.drawFill(0, 200-header_h, 320, 1, 72) v.drawFill(0, 199, 320, 1, 31) if not IsGamepad then drawCursor(v, sc_hud.cursor_x or 160, sc_hud.cursor_y or 100) end end -- CVAR to allow or disallow noclip for the camera (Server setting) local sc_allownoclip = CV_RegisterVar({ name = "sc_allownoclip", defaultvalue = 1, flags = CV_NETVAR | CV_SAVE, PossibleValue = {No = 0, Yes = 1} }) -- Helper: Check if objects exist and are valid local IsValid = function(args, ...) if args ~= #{...} then return false end for k, v in ipairs({...}) do if not v or v.valid == false then return false end end return true end -- Helper: Find a player by node ID or name local findplayer = function(p) if tonumber(p) == 0 or p == "00" then return server end local node = tonumber(p) if node ~= nil and node >= 0 and node < 32 then for player in players.iterate do if #player == node then return player end end end for player in players.iterate do if string.lower(p) == string.lower(player.name) then return player end if string.find(string.lower(player.name), string.lower(p)) then return player end end return nil end local DAMP = FU * 30 / 32 local STOP_EPS = FU / 256 -- Helper: Apply friction/damping to a value local function Damp(value, damp, stop_eps) damp = $ or DAMP stop_eps = $ or STOP_EPS if tonumber(value) == nil then return end value = FixedMul(value, damp) if abs(value) < stop_eps then return 0 end return value end -- Initialize the Camera for a player local SpawnCamera = function(player, object, x, y, z) local pmo = player.realmo if not object then object = pmo end x, y, z = x or object.x, y or object.y, z or object.z -- Initialize the camera data structure speccam = { mo = P_SpawnMobj(x, y, z, MT_SPECCAMERA), type = "freecamera", movexy = TICRATE, -- Timers for movement smoothing/stopping movez = TICRATE, fov = 0, fov_a = 0, speed = 100, roll = 0, noclip = false, gravity = false, cinema = false, -- Smooth camera movement invert = false, follow = true, -- For object mode: actually follow position? blockplayer = true, -- Stop player input from moving the real character t_angle = true, -- Track angle of target chase = true, -- Chase cam vs fixed view --hud = true, c_mx = 0, c_my = 0, c_mz = 0 } player.awayviewaiming = 0 if not player.mo or not player.mo.valid then speccam.movexy = 0 speccam.movez = 0 end if player == consoleplayer then camera.chase = true sc_chase = camera.chase end return speccam end -- Clean up camera and restore normal view local RemoveCamera = function(player) player.viewrollangle = 0 if IsValid(2, player, speccam.mo) then P_RemoveMobj(speccam.mo) end if player == consoleplayer then if hud.enabled("textspectator") == false then hud.enable("textspectator") end camera.chase = sc_chase end speccam = nil end local function getSelfPlayer(player) if player and player.valid then return player end if consoleplayer and consoleplayer.valid then return consoleplayer end if displayplayer and displayplayer.valid then return displayplayer end end --========================================================-- -- Movement Input --========================================================-- local function getMovement() local forward = 0 local strafe = 0 local up = 0 if sc_hud.enabled then return forward, strafe, up end -- Keyboard forward/backward if controlPressed("forward") then forward = $ + 50 end if controlPressed("backward") then forward = $ - 50 end -- Keyboard upward/downward if controlPressed("moveup") then up = $ + 50 end if controlPressed("movedown") then up = $ - 50 end -- Keyboard strafe if controlPressed("strafeleft") then strafe = $ - 50 end if controlPressed("straferight") then strafe = $ + 50 end -- Read joystick axes local joy_move = input.joyAxis(JA_MOVE) local joy_strafe = input.joyAxis(JA_STRAFE) local joy_jump = (input.joyAxis(JA_JUMP) + 1024) / 2 local joy_spin = (input.joyAxis(JA_SPIN) + 1024) / 2 -- If triggers were not pressed since the game started, they default to 512 if joy_jump == 512 then joy_jump = 0 end if joy_spin == 512 then joy_spin = 0 end local joy_up = joy_jump - joy_spin local scale_move = -joy_move * 50 / 1024 local scale_strafe = joy_strafe * 50 / 1024 local scale_up = joy_up * 50 / 1024 forward = $ + scale_move strafe = $ + scale_strafe up = $ + scale_up forward = min($, 50) strafe = min($, 50) up = min($, 50) return forward, strafe, up end --========================================================-- -- Rotation Input --========================================================-- local function getRotate() local player = getSelfPlayer(consoleplayer) local pmo = player.realmo local mousex = 0 local mousey = 0 local mousez = 0 if sc_hud.enabled then return mousex, mousey, mousez end -- Raw mouse movement if mouse then mousex = $ - mouse.dx * 8 mousey = $ - mouse.dy * 8 end -- Add joystick camera axes mousex = $ - input.joyAxis(JA_TURN) mousey = $ + input.joyAxis(JA_LOOK) -- Digital turning if controlPressed("turnleft") then mousex = $ + 960 end if controlPressed("turnright") then mousex = $ - 960 end -- Digital looking if controlPressed("lookup") then mousey = $ + 512 end if controlPressed("lookdown") then mousey = $ - 512 end -- Digital rolling if controlPressed("rollright") then mousez = $ + 50 end if controlPressed("rollleft") then mousez = $ - 50 end if P_MobjFlip(pmo) ~= 1 and (player.pflags & PF_FLIPCAM) then mousey = -$ end return mousex, mousey, mousez end --========================================================-- -- Controls Builder --========================================================-- local function getControls() return { forwardmove = select(1, getMovement()), sidemove = select(2, getMovement()), upmove = select(3, getMovement()), angleturn = select(1, getRotate()), aiming = select(2, getRotate()), roll = select(3, getRotate()) } end --========================================================-- -- Freecamera movement thinker --========================================================-- local function moveCamera(player) local cam = speccam local cmd = getControls() local pmo = player.realmo local cmo = cam.mo local yaw = cmo.angle local pitch = player.awayviewaiming local roll = player.viewrollangle local basespeed = cam.speed local speed_div = 4 -- Boost speed if controlPressed("speedup") then speed_div = 1 end -- Slow down significantly in glide mode for precision if cam.glide then speed_div = 64 end local fixedspeed = FixedMul(FRACUNIT, FixedDiv(basespeed * FRACUNIT, 100 * FRACUNIT)) local moveforce = fixedspeed * 100 / speed_div local sinyaw, cosyaw = sin(yaw), cos(yaw) local sinpitch, cospitch = sin(pitch), cos(pitch) local sinroll, cosroll = sin(roll), cos(roll) -- Handle Forward/Backward Movement if cmd.forwardmove ~= 0 then local input = cmd.forwardmove * FRACUNIT local scale = FixedMul(FRACUNIT, FixedDiv(input, 50 * FRACUNIT)) local finalforce = FixedMul(moveforce, scale) local z = sinpitch local xy = cospitch -- Calculate momentum vectors based on camera direction cmo.momx = $ + FixedMul(FixedMul(xy, cosyaw), finalforce) cmo.momy = $ + FixedMul(FixedMul(xy, sinyaw), finalforce) cmo.momz = $ + FixedMul(z, finalforce) end -- Handle Strafe Movement if cmd.sidemove ~= 0 then local input = cmd.sidemove * FRACUNIT local scale = FixedMul(FRACUNIT, FixedDiv(input, 50 * FRACUNIT)) local finalforce = FixedMul(moveforce, scale) -- Math to move perpendicular to view direction (considering roll) local x = FixedMul(sinyaw, cosroll) + FixedMul(cosyaw, FixedMul(sinpitch, sinroll)) local y = -FixedMul(cosyaw, cosroll) + FixedMul(sinyaw, FixedMul(sinpitch, sinroll)) local z = FixedMul(cospitch, sinroll) cmo.momx = $ + FixedMul(x, finalforce) cmo.momy = $ + FixedMul(y, finalforce) cmo.momz = $ - FixedMul(z, finalforce) end -- Vertical Movement (Up/Down relative to camera up-vector) if cmd.upmove ~= 0 then if P_MobjFlip(pmo) ~= 1 and (player.pflags & PF_FLIPCAM) then moveforce = -$ end local input = cmd.upmove * FRACUNIT local scale = FixedMul(FRACUNIT, FixedDiv(input, 50 * FRACUNIT)) local finalforce = FixedMul(moveforce, scale) local z = FixedMul(cosroll, cospitch) local x = -FixedMul(cosyaw, FixedMul(sinpitch, cosroll)) + FixedMul(sinyaw, sinroll) local y = -FixedMul(sinyaw, FixedMul(sinpitch, cosroll)) - FixedMul(cosyaw, sinroll) cmo.momx = $ + FixedMul(x, finalforce) cmo.momy = $ + FixedMul(y, finalforce) cmo.momz = $ + FixedMul(z, finalforce) end -- Gravity logic: Prevent flying up if gravity is on, unless gliding if cam.gravity and not cam.glide then if (P_MobjFlip(cmo) == 1 and cmo.momz > 0) or (P_MobjFlip(cmo) == -1 and cmo.momz < 0) then cmo.momz = 0 end end end -- Calculates camera roll based on input or cinema smoothing local function handleRoll(player, currentroll) local cmd = getControls() local roll = currentroll or 0 local add_roll = cmd.roll if speccam.cinema then local c_damp = sc_cinema_damp.value -- Smooth roll using momentum (c_mz) speccam.c_mz = speccam.c_mz or 0 speccam.c_mz = $ + (add_roll * FU) / 2 local maxspeed = 512 * FU if abs(speccam.c_mz) > maxspeed then speccam.c_mz = (speccam.c_mz > 0 and maxspeed or -maxspeed) elseif abs(add_roll) <= 5 then speccam.c_mz = Damp(speccam.c_mz, FU * c_damp / 32, 32) end roll = $ + speccam.c_mz else -- Direct roll control when holding FireNormal if abs(add_roll) >= 2 then if speccam.roll <= 16 then speccam.roll = $ + 2 end roll = $ + add_roll * speccam.roll * FU else speccam.roll = 0 end end return roll end -- Smoothes mouse input for cinematic feel local function cinemaSmoothing(mousex, mousey) if not speccam.cinema then speccam.c_mx, speccam.c_my = 0, 0 return mousex, mousey end local c_damp = sc_cinema_damp.value speccam.c_mx, speccam.c_my = speccam.c_mx or 0, speccam.c_my or 0 if abs(mousex) >= 16 then speccam.c_mx = speccam.c_mx + mousex / 16 else speccam.c_mx = Damp(speccam.c_mx, FU * c_damp / 32, 16) end if abs(mousey) >= 16 then speccam.c_my = speccam.c_my + mousey / 16 else speccam.c_my = Damp(speccam.c_my, FU * c_damp / 32, 16) end return speccam.c_mx, speccam.c_my end -- Converts current camera Euler angles to a 3-vector axis system (Forward, Right, Up) local function anglesToAxis(cmo, player) local axis = {} local fyaw, fpitch = cmo.angle, -player.awayviewaiming local cy, sy = cos(fyaw), sin(fyaw) local cp, sp = cos(fpitch), sin(fpitch) -- Forward vector axis.fx, axis.fy, axis.fz = FixedMul(cp, cy), FixedMul(cp, sy), sp -- Calculate Right and Up vectors local wx, wy, wz = 0, 0, FU if AngleFixed(fpitch) / FU > 90 and AngleFixed(fpitch) / FU < 270 then wx, wy, wz = 0, 0, -FU end axis.rx = FixedMul(axis.fy, wz) - FixedMul(axis.fz, wy) axis.ry = FixedMul(axis.fz, wx) - FixedMul(axis.fx, wz) axis.rz = FixedMul(axis.fx, wy) - FixedMul(axis.fy, wx) local len = FixedHypot(axis.rx, FixedHypot(axis.ry, axis.rz)) axis.rx, axis.ry, axis.rz = FixedDiv(axis.rx, len), FixedDiv(axis.ry, len), FixedDiv(axis.rz, len) axis.ux = FixedMul(axis.ry, axis.fz) - FixedMul(axis.rz, axis.fy) axis.uy = FixedMul(axis.rz, axis.fx) - FixedMul(axis.rx, axis.fz) axis.uz = FixedMul(axis.rx, axis.fy) - FixedMul(axis.ry, axis.fx) -- Apply Roll local roll = player.viewrollangle local s, c = sin(roll), cos(roll) local rx, ry, rz = axis.rx, axis.ry, axis.rz local ux, uy, uz = axis.ux, axis.uy, axis.uz axis.rx = FixedMul(rx, c) + FixedMul(ux, s) axis.ry = FixedMul(ry, c) + FixedMul(uy, s) axis.rz = FixedMul(rz, c) + FixedMul(uz, s) axis.ux = FixedMul(ux, c) - FixedMul(rx, s) axis.uy = FixedMul(uy, c) - FixedMul(ry, s) axis.uz = FixedMul(uz, c) - FixedMul(rz, s) return axis end -- Re-calculates roll angle from the axis system local function computeRoll(axis) local dot = FixedMul(FU, axis.fz) local px = -FixedMul(axis.fx, dot) local py = -FixedMul(axis.fy, dot) local pz = FU - FixedMul(axis.fz, dot) local len = FixedHypot(px, FixedHypot(py, pz)) if not len then return 0 end px, py, pz = FixedDiv(px, len), FixedDiv(py, len), FixedDiv(pz, len) local sinv = FixedMul(axis.rx, px) + FixedMul(axis.ry, py) + FixedMul(axis.rz, pz) local cosv = FixedMul(axis.ux, px) + FixedMul(axis.uy, py) + FixedMul(axis.uz, pz) return R_PointToAngle2(0, 0, cosv, sinv) end -- 6DOF (Six Degrees of Freedom) Rotation Logic -- Allows looking in any direction without "Gimbal Lock" local function rotateCamera6DOF(player) local cam = speccam local axis = cam.axis local cmd = getControls() local pmo = player.realmo local cmo = cam.mo local mousex = cmd.angleturn local mousey = cmd.aiming if abs(mousex) <= 8 then mousex = 0 end if abs(mousey) <= 8 then mousey = 0 end mousex, mousey = cinemaSmoothing(mousex, mousey) local rolldelta = handleRoll(player) if mousex == 0 and mousey == 0 and rolldelta == 0 then return end -- Apply Pitch (Y-axis rotation) if mousey ~= 0 then local angle = -mousey * FU local s, c = sin(angle), cos(angle) local fx, fy, fz = axis.fx, axis.fy, axis.fz local ux, uy, uz = axis.ux, axis.uy, axis.uz axis.fx = FixedMul(fx, c) + FixedMul(ux, s) axis.fy = FixedMul(fy, c) + FixedMul(uy, s) axis.fz = FixedMul(fz, c) + FixedMul(uz, s) axis.ux = FixedMul(ux, c) - FixedMul(fx, s) axis.uy = FixedMul(uy, c) - FixedMul(fy, s) axis.uz = FixedMul(uz, c) - FixedMul(fz, s) end -- Apply Yaw (X-axis rotation) if mousex ~= 0 then local angle = -mousex * FU local s, c = sin(angle), cos(angle) local fx, fy, fz = axis.fx, axis.fy, axis.fz local rx, ry, rz = axis.rx, axis.ry, axis.rz axis.fx = FixedMul(fx, c) + FixedMul(rx, s) axis.fy = FixedMul(fy, c) + FixedMul(ry, s) axis.fz = FixedMul(fz, c) + FixedMul(rz, s) axis.rx = FixedMul(rx, c) - FixedMul(fx, s) axis.ry = FixedMul(ry, c) - FixedMul(fy, s) axis.rz = FixedMul(rz, c) - FixedMul(fz, s) end -- Apply Roll (Z-axis rotation) if rolldelta ~= 0 then local s, c = sin(rolldelta), cos(rolldelta) local rx, ry, rz = axis.rx, axis.ry, axis.rz local ux, uy, uz = axis.ux, axis.uy, axis.uz axis.rx = FixedMul(rx, c) + FixedMul(ux, s) axis.ry = FixedMul(ry, c) + FixedMul(uy, s) axis.rz = FixedMul(rz, c) + FixedMul(uz, s) axis.ux = FixedMul(ux, c) - FixedMul(rx, s) axis.uy = FixedMul(uy, c) - FixedMul(ry, s) axis.uz = FixedMul(uz, c) - FixedMul(rz, s) end -- Normalize Forward vector local len = FixedHypot(axis.fx, FixedHypot(axis.fy, axis.fz)) if len then axis.fx = FixedDiv(axis.fx, len) axis.fy = FixedDiv(axis.fy, len) axis.fz = FixedDiv(axis.fz, len) end -- Reconstruct Right/Up vectors via Cross Product to maintain orthogonality axis.rx = FixedMul(axis.fy, axis.uz) - FixedMul(axis.fz, axis.uy) axis.ry = FixedMul(axis.fz, axis.ux) - FixedMul(axis.fx, axis.uz) axis.rz = FixedMul(axis.fx, axis.uy) - FixedMul(axis.fy, axis.ux) axis.ux = FixedMul(axis.ry, axis.fz) - FixedMul(axis.rz, axis.fy) axis.uy = FixedMul(axis.rz, axis.fx) - FixedMul(axis.rx, axis.fz) axis.uz = FixedMul(axis.rx, axis.fy) - FixedMul(axis.ry, axis.fx) cmo.angle = R_PointToAngle2(0, 0, axis.fx, axis.fy) player.awayviewaiming = -R_PointToAngle2(0, 0, FixedHypot(axis.fx, axis.fy), axis.fz) player.viewrollangle = computeRoll(axis) if player == consoleplayer then P_ResetCamera(consoleplayer, camera) end end -- Standard Camera Rotation (First Person Style) local function rotateCamera(player) local cmd = getControls() local pmo = player.realmo local cmo = speccam.mo local yaw = cmo.angle local pitch = player.awayviewaiming local roll = player.viewrollangle local sensitivity = FU local mousex, mousey = cmd.angleturn, cmd.aiming local sinroll, cosroll = sin(roll), cos(roll) -- Rotate input based on roll so "Up" on mouse still feels like "Up" on screen local local_x = FixedMul(cosroll, mousex) - FixedMul(sinroll, mousey) local local_y = FixedMul(sinroll, mousex) + FixedMul(cosroll, mousey) local pitchDeg = AngleFixed(pitch) / FU if pitchDeg >= 90 and pitchDeg <= 270 then local_x = -local_x end if abs(local_x) <= 8 then local_x = 0 end if abs(local_y) <= 8 then local_y = 0 end local_x, local_y = cinemaSmoothing(local_x, local_y) pitch = $ + (local_y * sensitivity) yaw = $ + FixedMul(local_x * sensitivity, abs(cos(pitch))) roll = handleRoll(player, roll) cmo.angle = yaw player.awayviewaiming = pitch player.viewrollangle = roll end -- Helper: Calculate Distance and Angles (Horizontal/Vertical) to a target object local function DistAngleToObject(source, target, use_height) if not IsValid(1, source) or not IsValid(1, target) then return nil end local z_offset = target.z if use_height then z_offset = target.z + (target.height * P_MobjFlip(target)) end local angle = source.angle local horizontal_angle = R_PointToAngle2(source.x, source.y, target.x, target.y) local distance = R_PointToDist2(source.x, source.y, target.x, target.y) local vertical_angle = R_PointToAngle2(0, 0, distance, z_offset - source.z) return distance, horizontal_angle, vertical_angle end local change_chase = 5 addHook("ThinkFrame", function() local player = getSelfPlayer(consoleplayer) if controlPressed("togglecamera") then cmd_main(player) P_ResetCamera(player, camera) end if controlPressed("freecamera") then if not speccam then cmd_main(player) elseif speccam.type ~= "freecamera" then speccam.type = "freecamera" speccam.chase, speccam.blockplayer = true, true end P_ResetCamera(player, camera) end if controlPressed("trackself") then if speccam then RemoveCamera(player) end cmd_main(player, "player", #player) P_ResetCamera(player, camera) end -- Adjust FOV smoothly for console player if player and player.valid then if speccam then local fov = CV_FindVar("fov").value if change_chase > 0 then change_chase = $ - 1 end player.fovadd = speccam.fov_a + (90 * FU - fov) else change_chase = 5 end end -- Sync camera position for non-chase Object Mode if speccam and speccam.mo and speccam.mo.valid then if speccam.type == "object" and IsValid(1, speccam.object) and not speccam.chase then local obj = speccam.object local z_pos = obj.z + obj.height - (obj.scale * 29) if (obj.flags2 & MF2_OBJECTFLIP) then z_pos = obj.z - obj.height + (obj.scale * 29) end P_SetOrigin(speccam.mo, obj.x, obj.y, z_pos) end end end) -- Function for switching between players local function switchPlayer(player, camera, dir) if not player or not player.valid then return end local current = player.speccam_target or player local found_current = false local fallback = nil if dir == 1 then -- NEXT for p in players.iterate do if p.valid and p.mo and not p.spectator then if not fallback then fallback = p end if found_current then player.speccam_target = p cmd_main(player, "player", #p) P_ResetCamera(player, camera) return end if p == current then found_current = true end end end -- wrap to first if fallback then player.speccam_target = fallback cmd_main(player, "player", #fallback) P_ResetCamera(player, camera) end else -- PREV local last_valid = nil for p in players.iterate do if p.valid and p.mo and not p.spectator then if p == current then break end last_valid = p end end if last_valid then player.speccam_target = last_valid cmd_main(player, "player", #last_valid) P_ResetCamera(player, camera) return end -- wrap to last for p in players.iterate do if p.valid and p.mo and not p.spectator then last_valid = p end end if last_valid then player.speccam_target = last_valid cmd_main(player, "player", #last_valid) P_ResetCamera(player, camera) end end end -- Records the object's previous positions for cinematic cutting/panning local function RecordObjectPositions(omo) local flipped = (P_MobjFlip(omo) == -1) local MAX_DIST = 128 * FRACUNIT if not omo then return end speccam.cam_history = $ or {} local hist = speccam.cam_history if leveltime % 10 ~= 0 then return end local x, y, z = omo.x, omo.y, omo.z local dist_floor = omo.z - omo.floorz local dist_ceiling = omo.ceilingz - omo.z if flipped then if dist_ceiling <= MAX_DIST then z = omo.ceilingz end else if dist_floor <= MAX_DIST then z = omo.floorz end end table.insert(hist, { x = x, y = y, z = z, angle = omo.angle }) if #hist > 64 then table.remove(hist, 1) end end -- Fallback mechanism calculating optimal spawn point from object's history local function GetHistorySpawn(smo, omo, dist, updist) if dist == 0 then return end dist = $ or (256 * FRACUNIT) updist = $ or (128 * FRACUNIT) local flip = P_MobjFlip(omo) local hist = speccam.cam_history if not hist then return end for i = #hist, 1, -1 do local h = hist[i] local z if flip == 1 then z = h.z + (omo.height / 2) + updist else z = h.z + (omo.height / 2) - updist end local px, py, pz = smo.x, smo.y, smo.z P_SetOrigin(smo, h.x, h.y, z) -- Update floor/ceiling P_TryMove(smo, smo.x, smo.y, true) local cam_margin = 16 * FU local valid_floor = (smo.z >= smo.floorz + cam_margin) local valid_ceiling = (smo.z + smo.height <= smo.ceilingz - cam_margin) local o_dist = R_PointToDist2(smo.x, smo.y, omo.x, omo.y) if valid_floor and valid_ceiling and P_CheckSight(omo, smo) and (o_dist >= dist or i == 1) then return h.x, h.y, smo.z end P_SetOrigin(smo, px, py, pz) end return GetHistorySpawn(smo, omo, dist / 2, updist) end local function AngleDelta(a, b) local diff = (a - b) & 0xFFFFFFFF if diff > ANGLE_180 then diff = $ - ANGLE_MAX end return diff end -- Main logic for "Object" tracking mode (Camera follows a specific mobj) local function UpdateObjectCamera(player, speccam, pmo, smo, cmd_p) local omo = speccam.object local cmd = player.cmd -- If object is gone, revert to free camera if not IsValid(1, omo) then speccam.type = "freecamera" speccam.blockplayer = true return end if speccam.object ~= player.realmo or camera.chase then if controlPressed("togglebtplayer") then speccam.blockplayer = not speccam.blockplayer end end if not speccam.blockcontrolbuttons then if controlPressed("prevplayer") then switchPlayer(player, camera, -1) return elseif controlPressed("nextplayer") then switchPlayer(player, camera, 1) return end end -- First Person / Fixed View if not speccam.chase then smo.angle = omo.angle if IsValid(1, omo.player) then player.awayviewaiming = omo.player.aiming --if player == consoleplayer then displayplayer = omo.player end elseif (omo.flags & MF_ENEMY) or (omo.flags & MF_BOSS) then -- Enemies look into the eyes of their offender if omo.target and omo.target.valid then local DiffAngle = omo.angle - R_PointToAngle2(omo.x, omo.y, omo.target.x, omo.target.y) if abs(DiffAngle) > ANG1 * 5 then player.awayviewaiming = $ - ($ / 8) elseif P_CheckSight(omo, omo.target) then local distance = R_PointToDist2(omo.x, omo.y, omo.target.x, omo.target.y) local z_offset = omo.target.z + (omo.target.height * P_MobjFlip(omo.target)) * 2 / 3 local v_angle = R_PointToAngle2(0, 0, distance, z_offset - omo.z) local diff_v_angle = player.awayviewaiming - v_angle player.awayviewaiming = $ - (diff_v_angle / 8) end else player.awayviewaiming = 0 end else player.awayviewaiming = $ + (cmd_p.aiming * FU) local max_pitch = ANGLE_90 - ANG1 if player.awayviewaiming > max_pitch then player.awayviewaiming = max_pitch end if player.awayviewaiming < -max_pitch then player.awayviewaiming = -max_pitch end end if player == consoleplayer then omo.dontdrawforviewmobj = smo -- Force camera chase cvar reset if needed if controlPressed("chasecam") and change_chase == 0 then change_chase = 5 cmd_main(consoleplayer, "chase") end end else -- Third Person / Chasing View if player == consoleplayer then omo.dontdrawforviewmobj = nil if controlPressed("chasecam") and change_chase == 0 then change_chase = 5 cmd_main(consoleplayer, "chase") end end -- Toggles via Custom Buttons if not speccam.blockcontrolbuttons then if controlPressed("togglefollow") then speccam.follow = not speccam.follow speccam.ps2 = nil speccam.prevps2 = nil speccam.cam_history = nil end if controlPressed("toggleangle") then speccam.t_angle = not speccam.t_angle end end if speccam.follow then -- Initialize orbit state once if not speccam.orbit then local dx = smo.x - omo.x local dy = smo.y - omo.y local dz = smo.z - omo.z local dist_xy = R_PointToDist2(0, 0, dx, dy) local dist = FixedHypot(dist_xy, dz) speccam.orbit = { angle = R_PointToAngle2(omo.x, omo.y, smo.x, smo.y), pitch = R_PointToAngle2(0, 0, dist_xy, dz), dist = dist, target_dist = dist, ang_vel = 0 } end local orbit = speccam.orbit if speccam.t_angle then local ang_to_cam = R_PointToAngle2(omo.x, omo.y, smo.x, smo.y) local block_angle = ANG1 * 20 local look_delta = abs(AngleDelta(omo.angle, ang_to_cam)) local moving_to_cam = false if omo.momx ~= 0 or omo.momy ~= 0 then local move_ang = R_PointToAngle2(0, 0, omo.momx, omo.momy) local move_delta = abs(AngleDelta(move_ang, ang_to_cam)) if move_delta < block_angle then moving_to_cam = true end end local looking_at_cam = (look_delta < block_angle) local block_turn = looking_at_cam or moving_to_cam if not block_turn then orbit.ang_vel = orbit.ang_vel or 0 local target = omo.angle local delta = AngleDelta(target, orbit.angle) local accel = delta / 256 orbit.ang_vel = $ + accel orbit.ang_vel = FixedMul($, 900*FRACUNIT/1000) -- Maximum speed limit local max_speed = ANG1 * 8 if orbit.ang_vel > max_speed then orbit.ang_vel = max_speed elseif orbit.ang_vel < -max_speed then orbit.ang_vel = -max_speed end -- Apply speed orbit.angle = $ + orbit.ang_vel end end orbit.real_dist = orbit.real_dist or orbit.target_dist local ca = cos(orbit.angle) local sa = sin(orbit.angle) local cp = cos(orbit.pitch) local sp = sin(orbit.pitch) local used_dist = orbit.real_dist local horiz = FixedMul(used_dist, cp) local ideal_x = omo.x - FixedMul(ca, horiz) local ideal_y = omo.y - FixedMul(sa, horiz) local ideal_z = omo.z + FixedMul(used_dist, sp) local final_x = ideal_x local final_y = ideal_y local final_z = ideal_z smo.momx = final_x - smo.x smo.momy = final_y - smo.y local slide = false if P_TryMove(smo, final_x, final_y, true) then P_SetOrigin(smo, final_x, final_y, smo.z) else slide = true P_SlideMove(smo) end smo.momx = 0 smo.momy = 0 local oldx = smo.x local oldy = smo.y local oldz = smo.z local test_dist = orbit.real_dist local step = 1 * FU while test_dist > 0 do local horiz = FixedMul(test_dist, cp) local tx = omo.x - FixedMul(ca, horiz) local ty = omo.y - FixedMul(sa, horiz) local tz = omo.z + FixedMul(test_dist, sp) smo.z = tz if P_TryMove(smo, tx, ty, true) and P_CheckSight(omo, smo) then local floor_limit = smo.floorz - FU*16 if tz < floor_limit then tz = floor_limit end final_x = tx final_y = ty final_z = tz break end test_dist = $ - step end if test_dist < orbit.real_dist then orbit.real_dist = test_dist elseif orbit.real_dist > orbit.target_dist then orbit.real_dist = $ + (orbit.target_dist - orbit.real_dist) / 8 elseif orbit.real_dist < orbit.target_dist then local new_dist = orbit.real_dist + (orbit.target_dist - orbit.real_dist) / 8 local new_horiz = FixedMul(new_dist, cp) local new_tx = omo.x - FixedMul(ca, new_horiz) local new_ty = omo.y - FixedMul(sa, new_horiz) local new_tz = omo.z + FixedMul(new_dist, sp) local zclipped = false if smo.flags2 & MF2_OBJECTFLIP then -- inverted gravity if new_tz + smo.height > P_CeilingzAtPos(new_tx, new_ty, new_tz, 0) then zclipped = true end if new_tz < P_FloorzAtPos(new_tx, new_ty, new_tz, 0) then zclipped = true end else if new_tz < P_FloorzAtPos(new_tx, new_ty, new_tz, 0) then zclipped = true end if new_tz + smo.height > P_CeilingzAtPos(new_tx, new_ty, new_tz, 0) then zclipped = true end end if P_TryMove(smo, new_tx, new_ty, true) and P_CheckSight(omo, smo) and not zclipped then orbit.real_dist = $ + (orbit.target_dist - orbit.real_dist) / 8 end end P_SetOrigin(smo, final_x, final_y, final_z) local _, h_ang, v_ang = DistAngleToObject(smo, omo, true) do local horiz = FixedMul(orbit.real_dist, cp) local tx = omo.x - FixedMul(ca, horiz) local ty = omo.y - FixedMul(sa, horiz) local tz = omo.z + FixedMul(orbit.real_dist, sp) local z_offset = omo.z + (omo.height * P_MobjFlip(omo)) local distance = R_PointToDist2(tx, ty, omo.x, omo.y) v_ang = R_PointToAngle2(0, 0, distance, z_offset - tz) end smo.angle = h_ang player.awayviewaiming = v_ang if not speccam.blockcontrolbuttons then local zoom_step = 64 * FU local min_dist = 128 * FU local max_dist = 2048 * FU if controlPressed("moveaway") then orbit.target_dist = $ + zoom_step end if controlPressed("movecloser") then orbit.target_dist = $ - zoom_step end -- Clamp distance targets if orbit.target_dist < min_dist then orbit.target_dist = min_dist end if orbit.target_dist > max_dist then orbit.target_dist = max_dist end orbit.angle = $ + (cmd_p.angleturn * FU) orbit.pitch = $ - (cmd_p.aiming * FU) local max_pitch = ANGLE_90 - ANG1 if orbit.pitch > max_pitch then orbit.pitch = max_pitch end if orbit.pitch < -max_pitch then orbit.pitch = -max_pitch end end else -- Logic for Fully Static Camera tracking local flipped = (P_MobjFlip(omo) == -1) RecordObjectPositions(omo) local add_angle = ANG1 * 5 if flipped then add_angle = -ANG1 * 5 end if not speccam.ps2 then local _, init_h_angle, init_v_angle = DistAngleToObject(smo, omo, true) local nx, ny, nz = GetHistorySpawn(smo, omo, 256 * FU, 128 * FU) speccam.ps2 = { x = smo.x, y = smo.y, z = smo.z, angle = init_h_angle, aiming = init_v_angle + add_angle, holdmove = true } end local ps2 = speccam.ps2 if cmd.forwardmove == 0 and cmd.sidemove == 0 then ps2.holdmove = false end P_SetOrigin(smo, ps2.x, ps2.y, ps2.z) smo.angle = ps2.angle player.awayviewaiming = ps2.aiming local distance, cur_h_angle, cur_v_angle = DistAngleToObject(smo, omo, true) local h_diff = abs(ps2.angle - cur_h_angle) local v_diff = abs(ps2.aiming - cur_v_angle) local should_cut = false if not speccam.t_angle then if h_diff > ANG1 * 45 then should_cut = true end if v_diff > ANG1 * 30 then should_cut = true end end local reset_distation = sc_distance_reset.value if controlPressed("resetposition") and not speccam.blockcontrolbuttons then should_cut = true end local lost_sight = not P_CheckSight(smo, omo) local too_far = distance > reset_distation * omo.scale local sight_time if lost_sight then sight_time = sc_time_sight_reset.value * TICRATE end local dist_time if too_far then dist_time = sc_time_distance_reset.value * TICRATE end if ps2.live == nil then local new_time if sight_time ~= nil and dist_time ~= nil then new_time = min(sight_time, dist_time) elseif sight_time ~= nil then new_time = sight_time elseif dist_time ~= nil then new_time = dist_time end if new_time ~= nil then if new_time <= 0 then should_cut = true else ps2.live = new_time end end else if not sight_time and not dist_time then ps2.live = nil end end if ps2.live ~= nil then if ps2.live <= 0 then should_cut = true else ps2.live = $ - 1 end end if should_cut then speccam.prevps2 = speccam.ps2 local _, init_h_angle, init_v_angle = DistAngleToObject(smo, omo, true) local updist_mom = omo.momz * 16 if flipped and updist_mom > 0 or updist_mom < 0 then updist_mom = 0 end local nx, ny, nz = GetHistorySpawn(smo, omo, 256 * FU, 128 * FU + updist_mom) speccam.ps2 = { x = nx, y = ny, z = nz, angle = init_h_angle, aiming = init_v_angle + add_angle, holdmove = true, live = TICRATE * 2 } if not speccam.ps2.x or not speccam.ps2.y or not speccam.ps2.z then speccam.ps2 = speccam.prevps2 end P_ResetCamera(consoleplayer, camera) end if speccam.t_angle then local _, horizontal_angle, vertical_angle = DistAngleToObject(smo, omo, true) smo.angle, player.awayviewaiming = horizontal_angle, vertical_angle end end if not speccam.follow and speccam.ps2 and speccam.ps2.moved and cmd_p.sidemove == 0 and cmd_p.forwardmove == 0 then speccam.ps2.moved = false end end end -- Main logic for "Free Camera" mode local function UpdateFreeCamera(player, speccam, pmo, smo, cmd_p) if player == consoleplayer and camera.chase == false then camera.chase = true end if not speccam.axis then speccam.axis = { fx=FU, fy=0, fz=0, rx=0, ry=FU, rz=0, ux=0, uy=0, uz=FU } end -- Handle Rotation if not speccam.sixDOF then rotateCamera(player) else rotateCamera6DOF(player) end if controlPressed("trackobject") then local best local bestdot = -FRACUNIT local yaw = smo.angle local pitch = player.awayviewaiming -- Vector of gaze local vx = FixedMul(cos(yaw), cos(pitch)) local vy = FixedMul(sin(yaw), cos(pitch)) local vz = sin(pitch) searchBlockmap("objects", function(ref, found) if not found or not found.valid then return true end if found == smo then return end if not (found.flags & MF_ENEMY) and not (found.flags & MF_BOSS) and not found.player and found.type ~= MT_METALSONIC_RACE then return end if not P_CheckSight(smo, found) then return end -- Vector to object local dx = found.x - smo.x local dy = found.y - smo.y local dz = (found.z + found.height/2) - (smo.z + smo.height/2) local dist = R_PointToDist2(0, 0, FixedHypot(dx, dy), dz) -- Normalize vector dx = FixedDiv(dx, dist) dy = FixedDiv(dy, dist) dz = FixedDiv(dz, dist) -- Dot product to find closest object alignment with gaze local dot = FixedMul(vx, dx) + FixedMul(vy, dy) + FixedMul(vz, dz) if dot > bestdot and dot > FRACUNIT*99/100 then best = found bestdot = dot end end, smo, smo.x - 8192*FRACUNIT, smo.x + 8192*FRACUNIT, smo.y - 8192*FRACUNIT, smo.y + 8192*FRACUNIT ) if best then if player and player.valid then RemoveCamera(player) end -- Switch the player to tracking this new object speccam = SpawnCamera(player) smo = speccam.mo speccam.type, speccam.blockplayer, speccam.object = "object", false, best P_MoveOrigin(smo, best.x - cos(speccam.object.angle) * 168, best.y - sin(speccam.object.angle) * 168, best.z) if (speccam.object.flags2 & MF2_OBJECTFLIP) then P_MoveOrigin(smo, smo.x, smo.y, best.z - best.height - 64 * speccam.object.scale) else player.viewrollangle = 0 P_MoveOrigin(smo, smo.x, smo.y, best.z + best.height + 64 * speccam.object.scale) end camera.chase = true change_chase = 5 return end end -- Toggles via Custom Config buttons if controlPressed("togglecinema") then speccam.cinema = not speccam.cinema end if controlPressed("toggleglide") then speccam.glide = not speccam.glide end if controlPressed("togglesixdof") then speccam.sixDOF = not speccam.sixDOF if speccam.sixDOF then speccam.axis = anglesToAxis(smo, player) end end if controlPressed("togglenoclip") then speccam.noclip = not speccam.noclip end if controlPressed("togglegravity") then speccam.gravity = not speccam.gravity end -- Speed Control (Holding C1 + Weapon Prev/Next) if controlPressed("increasespeed") and speccam.speed < 1000 then speccam.speed = $ + 10 elseif controlPressed("reducespeed") and speccam.speed > 0 then speccam.speed = $ - 10 end -- Reset view roll/pitch (smooth) local CENTER_SPEED = FU/16 if controlPressed("centerview") then -- Roll smoothing if player.viewrollangle ~= 0 then player.viewrollangle = $ - FixedMul($, CENTER_SPEED) if abs(player.viewrollangle) < ANG1 then player.viewrollangle = 0 end end -- Pitch smoothing if player.awayviewaiming ~= 0 then player.awayviewaiming = $ - FixedMul($, CENTER_SPEED) if abs(player.awayviewaiming) < ANG1 then player.awayviewaiming = 0 end end if speccam.sixDOF then speccam.axis = anglesToAxis(smo, player) end end -- Move camera if keys pressed if cmd_p.forwardmove ~= 0 or cmd_p.sidemove ~= 0 or cmd_p.upmove ~= 0 then moveCamera(player) speccam.movexy = 0 if not speccam.gravity then speccam.movez = 0 end end if smo.momx ~= 0 or smo.momy ~= 0 or smo.momz ~= 0 then local x = smo.x + smo.momx local y = smo.y + smo.momy local z = smo.z + smo.momz if speccam.noclip then P_MoveOrigin(smo, x, y, z) else -- Check XY bounds if P_TryMove(smo, x, y, true) then P_MoveOrigin(smo, x, y, smo.z) else P_SlideMove(smo) end P_MoveOrigin(smo, smo.x, smo.y, z) end end if not speccam.noclip then if smo.z < smo.floorz then smo.z = smo.floorz smo.momz = 0 elseif smo.z + smo.height > smo.ceilingz then smo.z = smo.ceilingz - smo.height smo.momz = 0 end end if speccam.gravity then smo.momz = $ - FU / 2 end -- Glide physics (Momentum retention) if speccam.glide then local g_damp = sc_glide_damp.value local maxspeed = (speccam.speed * FU) / 4 local hspeed = R_PointToDist2(0, 0, smo.momx, smo.momy) local vspeed = abs(smo.momz) local abs_speed = hspeed + vspeed local moving = cmd_p.forwardmove ~= 0 or cmd_p.sidemove ~= 0 or cmd_p.upmove ~= 0 if abs_speed > maxspeed then local scale = FixedDiv(maxspeed, abs_speed) smo.momx = FixedMul(smo.momx, scale) smo.momy = FixedMul(smo.momy, scale) smo.momz = FixedMul(smo.momz, scale) elseif not moving then local damp_val = (FU * g_damp / 32) local stop_val = (FU / g_damp * 8) smo.momx = Damp(smo.momx, damp_val, FU / 256) smo.momy = Damp(smo.momy, damp_val, FU / 256) if not speccam.gravity then smo.momz = Damp(smo.momz, damp_val, stop_val) end end end -- Gravity handling enforce flags if speccam.gravity then speccam.movez = 2 player.viewrollangle = 0 speccam.sixDOF = false speccam.noclip = false end end -- ========================================================================= -- MAIN LOOP: PreThinkFrame -- Handles input detection and state switching before physics runs -- ========================================================================= addHook("PreThinkFrame", function() local player = getSelfPlayer(consoleplayer) if not player or not player.valid or not player.realmo or not player.realmo.valid or not speccam then return end if not speccam.mo or not speccam.mo.valid then -- Load camera data into newly spawned instance speccam.mo = P_SpawnMobj(speccam.x, speccam.y, speccam.z, MT_SPECCAMERA) speccam.mo.momx = speccam.momx speccam.mo.momy = speccam.momy speccam.mo.momz = speccam.momz player.awayviewaiming = speccam.aiming player.viewrollangle = speccam.rollviewing speccam.mo.angle = speccam.angle player.awayviewmobj = speccam.mo player.awayviewtics = 2 end local cmd_p = getControls() local pmo = player.realmo local smo = speccam.mo -- Save Camera data speccam.x = smo.x speccam.y = smo.y speccam.z = smo.z speccam.momx = smo.momx speccam.momy = smo.momy speccam.momz = smo.momz speccam.rollviewing = player.viewrollangle speccam.aiming = player.awayviewaiming speccam.angle = smo.angle -- Drag/Damping timers if speccam.movexy > 0 then speccam.movexy = $ - 1 elseif not speccam.glide then smo.momx, smo.momy = 0, 0 end if speccam.movez > 0 then speccam.movez = $ - 1 elseif not speccam.glide then smo.momz = 0 end -- Override player view to camera object player.awayviewmobj = smo player.awayviewtics = 2 if controlPressed("togglebtcamera") and speccam.type == "object" then speccam.blockcontrolbuttons = not speccam.blockcontrolbuttons end -- FOV Adjustment execution if speccam.type == "freecamera" or not speccam.blockcontrolbuttons then if controlPressed("zoomout") and speccam.fov < 120 * FU then speccam.fov = $ + 4 * FU elseif controlPressed("zoomin") and speccam.fov > -34 * FU then speccam.fov = $ - 4 * FU end end speccam.fov_a = $ + (speccam.fov - speccam.fov_a) / 8 -- State Machine execution if speccam.type == "object" then UpdateObjectCamera(player, speccam, pmo, smo, cmd_p) elseif speccam.type == "freecamera" then UpdateFreeCamera(player, speccam, pmo, smo, cmd_p) end -- Toggle HUD visibility checks if speccam.type == "object" and not speccam.blockcontrolbuttons or speccam.type == "freecamera" then if controlPressed("togglehud") then CV_Set(sc_camerahud, 1 - sc_camerahud.value) end end -- Force noclip off if server disallows it if sc_allownoclip.value == 0 then speccam.noclip = false end if not smo or not smo.valid then return end end) -- Command: Initialize freecamera, optionally at specified coordinates local cmd_sc_freecamera = function(player, args) local pmo = player.realmo local x, y, z = pmo.x, pmo.y, pmo.z local dontmove = false local last_arg if speccam then x, y, z = speccam.mo.x, speccam.mo.y, speccam.mo.z end for _, v in ipairs(args) do local lower_v = string.lower(v) if string.match(lower_v, "^%-r?[xyz]$") then last_arg = lower_v else local n = tonumber(v) if n ~= nil and last_arg then local val = n * FU dontmove = true if last_arg == "-x" then x = val elseif last_arg == "-y" then y = val elseif last_arg == "-z" then z = val elseif last_arg == "-rx" then x = x + val elseif last_arg == "-ry" then y = y + val elseif last_arg == "-rz" then z = z + val end last_arg = nil end end end if not speccam then SpawnCamera(player, pmo, x, y, z) local smo = speccam.mo if not dontmove then P_InstaThrust(smo, pmo.angle, -8 * FU) end speccam.mo.angle = pmo.angle if (pmo.flags2 & MF2_OBJECTFLIP) then player.viewrollangle = ANGLE_180 if not dontmove then P_SetObjectMomZ(smo, -2 * FU) end else player.viewrollangle = 0 if not dontmove then P_SetObjectMomZ(smo, 2 * FU) end end elseif speccam and dontmove then P_MoveOrigin(speccam.mo, x, y, z) else RemoveCamera(player) end end -- Command: Initialize object tracking logic local cmd_sc_object = function(player, args) if speccam then RemoveCamera(player) end local object = player.mo if args[2] ~= nil then local target = findplayer(args[2]) if target then object = target.mo else CONS_Printf(player, "There is no player named \""..args[2].."\""); return end end if not IsValid(1, object) then if args[2] ~= nil then CONS_Printf(player, "The player is probably not entered in the game or does not exist.") else CONS_Printf(player, "That's not gonna work.") end return end local chase = true if args[3] ~= nil then local arg3 = string.lower(args[3]) if arg3 == "off" or arg3 == "false" or arg3 == "no" or arg3 == "0" then chase = false if player == consoleplayer then camera.chase = false end end end SpawnCamera(player, object) local smo = speccam.mo speccam.type, speccam.blockplayer, speccam.object, speccam.chase = "object", false, object, chase if player == consoleplayer then camera.chase = chase end if chase then local spawn_angle = object.angle + ANGLE_180 P_SetOrigin(smo, smo.x - cos(spawn_angle) * 168, smo.y - sin(spawn_angle) * 168, smo.z) local offset = (object.flags2 & MF2_OBJECTFLIP) and -64 * FU or 64 * FU if not (object.flags2 & MF2_OBJECTFLIP) then player.viewrollangle = 0 end P_SetOrigin(smo, smo.x, smo.y, smo.z + offset) else local offset = (object.flags2 & MF2_OBJECTFLIP) and -object.height or object.height P_SetOrigin(smo, smo.x, smo.y, smo.z + offset) end end -- Console Command: spectator_mode controller entry point function cmd_main(player, ...) if getSelfPlayer(consoleplayer) ~= player then return end local args = {...} if args[1] ~= nil and string.lower(args[1]) == "help" then local ht = "///Spectator Mode\\\\\\\nVersion: 2.3\nAuthor: Sirexer\n" ht = ht .. "The camera can be enabled by pressing the F6 button\nCommands:\n" ht = ht .. "spectator_mode freecamera - enables the camera\n" ht = ht .. "spectator_mode freecamera -x -y -z - enables the camera and teleports the camera directly to the specified coordinates\n" ht = ht .. "spectator_mode freecamera -rx -ry -rz - enables the camera and teleports the camera relative to camera or player current location\n" ht = ht .. "spectator_mode player - enables the camera in tracking object mode" CONS_Printf(player, ht) return end if gamestate ~= GS_LEVEL then return end if not G_CoopGametype() and not player.spectator then CONS_Printf(player, "Doesn't work when you're in game and in non friendly gametypes") return end if IsValid(2, player, player.realmo) then if args[1] ~= nil and string.lower(args[1]) == "player" then cmd_sc_object(player, args) elseif args[1] ~= nil and string.lower(args[1]) == "chase" then if IsValid(1, player) and speccam and speccam.object then local smo = speccam.mo local omo = speccam.object speccam.chase = not speccam.chase if player == consoleplayer then camera.chase = speccam.chase change_chase = 5 end if speccam.chase then -- reposition behind target local spawn_angle = omo.angle + ANGLE_180 P_SetOrigin(smo, omo.x - cos(spawn_angle) * 168, omo.y - sin(spawn_angle) * 168, omo.z ) local offset = (omo.flags2 & MF2_OBJECTFLIP) and -64*FU or 64*FU P_SetOrigin(smo, smo.x, smo.y, smo.z + offset) if not (omo.flags2 & MF2_OBJECTFLIP) then player.viewrollangle = 0 end end end else cmd_sc_freecamera(player, args) end end end COM_AddCommand("spectator_mode", cmd_main, COM_LOCAL) -- HUD Drawing Function local function spectator_hud(v) local player = getSelfPlayer(consoleplayer) if player and player.valid and player.realmo and player.realmo.valid and speccam and speccam.mo and speccam.mo.valid then if hud.enabled("textspectator") then hud.disable("textspectator") end if sc_camerahud.value == 0 then return end local smo = speccam.mo local roll, angle, aim = AngleFixed(player.viewrollangle) / FU, AngleFixed(smo.angle) / FU, AngleFixed(player.awayviewaiming) / FU local px, py, pz = smo.x / FU, smo.y / FU, smo.z / FU local speed_f = speccam.speed / 100 local speed = speed_f.."."..(speccam.speed - speed_f * 100) / 10 local vel = (R_PointToDist2(0, 0, smo.momx, smo.momy) + abs(smo.momz)) / FU local fov = speccam.fov / FU + 96 local function flag(b) return b and "[ON ]" or "[OFF]" end -- ========================= -- RIGHT PANEL (Camera Data) -- ========================= local ry = 0 local function drawR(text, offset) v.drawString(320, ry, text, V_SNAPTORIGHT | V_SNAPTOTOP, "small-right") ry = ry + offset end drawR("== CAMERA ==", 8) drawR("Yaw : "..angle, 4) drawR("Pitch: "..aim, 4) drawR("Roll : "..roll, 8) drawR("== POSITION ==", 8) drawR("X: "..px, 4) drawR("Y: "..py, 4) drawR("Z: "..pz, 8) drawR("FOV : "..fov, 4) if speccam.type == "freecamera" then drawR("Speed: x"..speed, 4) drawR("Vel : "..vel, 4) end -- ========================= -- LEFT PANEL (Mode / Info) -- ========================= local ly = 64 local function drawL(text, offset) v.drawString(0, ly, text, V_SNAPTOLEFT | V_SNAPTOTOP, "small") ly = ly + offset end if speccam.type == "freecamera" then drawL("=== MOVE CONTROLS ===", 4) if IsGamepad then drawL("Move "..string.char(23).." Axis - Forward move", 4) drawL("Move "..string.char(24).." Axis - Strafe", 4) drawL("Jump/Spin Axis - Upward move", 8) drawL("=== CAMERA ROTATION ===", 4) drawL("Camera "..string.char(23).." Axis - Look Up/Down", 4) drawL("Camera "..string.char(24).." Axis - Look Left/Right", 4) else drawL(getBindText("forward").."/"..getBindText("backward").." - Forward move", 4) drawL(getBindText("strafeleft").."/"..getBindText("straferight").." - Strafe", 4) drawL(getBindText("moveup").."/"..getBindText("movedown").." - Upward move", 8) drawL("=== CAMERA ROTATION ===", 4) drawL(getBindText("lookup").."/"..getBindText("lookdown").." - Look Up/Down", 4) drawL(getBindText("turnleft").."/"..getBindText("turnright").." - Look Left/Right", 4) end drawL(getBindText("rollleft").."/"..getBindText("rollright").." - Roll Left/Right", 8) drawL("=== CAMERA STATES ===", 4) drawL(getBindText("togglecinema").." - Cinema "..flag(speccam.cinema), 4) drawL(getBindText("toggleglide").." - Glide "..flag(speccam.glide), 4) drawL(getBindText("togglesixdof").." - 6DOF "..flag(speccam.sixDOF), 4) drawL(getBindText("togglenoclip").." - Noclip "..flag(speccam.noclip), 4) drawL(getBindText("togglegravity").." - Gravity "..flag(speccam.gravity), 8) drawL("=== CAMERA ACTIONS ===", 4) drawL(getBindText("speedup").." - Speed Up", 4) drawL(getBindText("centerview").." - Center View", 4) drawL(getBindText("trackobject").."/Aim - Tracking Object", 8) elseif speccam.type == "object" then if camera.chase then drawL("=== CAMERA ROTATION ===", 4) if IsGamepad then drawL("Camera "..string.char(23).." Axis - Look Up/Down", 4) drawL("Camera "..string.char(24).." Axis - Look Left/Right", 8) else drawL(getBindText("lookup").."/"..getBindText("lookdown").." - Look Up/Down", 4) drawL(getBindText("turnleft").."/"..getBindText("turnright").." - Look Left/Right", 8) end end drawL("=== CAMERA STATES ===", 4) if camera.chase then drawL(getBindText("togglefollow").." - Object Tracking "..flag(speccam.follow), 4) drawL(getBindText("toggleangle").." - Angle Tracking "..flag(speccam.t_angle), 4) end if speccam.object ~= player.realmo or camera.chase then drawL(getBindText("togglebtplayer").." - Block Player Movement "..flag(speccam.blockplayer), 4) end drawL(getBindText("togglebtcamera").." - Block Camera Buttons "..flag(speccam.blockcontrolbuttons), 8) drawL("=== CAMERA ACTIONS ===", 4) if camera.chase and not speccam.follow then drawL(getBindText("resetposition").." - Reset Position", 4) end drawL(getBindText("chasecam").." - Toggle Third-Person", 8) end drawL("=== ADJUSTMENTS ===", 4) drawL(getBindText("togglehud").." - Toggle HUD", 4) drawL(getBindText("zoomin").."/"..getBindText("zoomout").." - FOV Control", 4) if speccam.type == "freecamera" then drawL(getBindText("increasespeed").."/"..getBindText("reducespeed").." - Speed Control", 4) end if speccam.type == "object" and camera.chase then drawL(getBindText("movecloser").."/"..getBindText("moveaway").." - Distance Control", 4) end drawL(getBindText("togglecamera").." - Toggle Camera", 4) drawL(getBindText("freecamera").." - Switch to Freecamera", 4) drawL(getBindText("trackself").." - Tracking Self", 8) end end hud.add(spectator_hud) addHook("MapChange", function(map) if sc_remove_on_mapchange.value == 1 then speccam = nil end end) addHook("SeenPlayer", do if speccam then return false end end) local cmd_angles = {angle = 0, aiming = 0} local ps2_movement = {angle = 0, forwardmove = 0, sidemove = 0} local last_move_angle = 0 addHook("PlayerCmd", function(player, cmd) if speccam and (speccam.type == "freecamera" or speccam.type == "object" and speccam.blockplayer and (speccam.object ~= player.realmo or camera.chase)) or sc_hud.enabled then cmd.angleturn = cmd_angles.angle cmd.aiming = cmd_angles.aiming cmd.forwardmove = 0 cmd.sidemove = 0 cmd.buttons = 0 else cmd_angles = {angle = cmd.angleturn, aiming = cmd.aiming} if speccam and speccam.type == "object" and player.realmo and player.playerstate == PST_LIVE and not player.spectator and (speccam.chase or speccam.object ~= player.realmo) then -- Do not change angle with mouse while locked cmd.angleturn = cmd.angleturn local f = cmd.forwardmove local s = cmd.sidemove if f ~= 0 or s ~= 0 then -- New analog control system. -- The old system forced players to jerk around on servers with high ping. -- Input angle from gamepad/keyboard. -- Subtract ANGLE_90 so that “straight ahead” gives 0 degrees deviation from the camera. local input_angle = R_PointToAngle2(0, 0, s * FRACUNIT, f * FRACUNIT) - ANGLE_90 -- Absolute angle of movement in the world (camera + input deviation) local world_angle = speccam.mo.angle + input_angle -- We calculate the total speed (hypotenuse) using Pythagoras' theorem from the engine. local speed = R_PointToDist2(0, 0, s * FRACUNIT, f * FRACUNIT) -- The character turns according to the movement vector and only moves “forward.” cmd.angleturn = world_angle >> 16 cmd.forwardmove = speed / FRACUNIT cmd.sidemove = 0 -- Save the recalculated movement ps2_movement.angle = cmd.angleturn ps2_movement.forwardmove = cmd.forwardmove ps2_movement.sidemove = cmd.sidemove end end end end) local function cmd_settings() local player = getSelfPlayer(consoleplayer) if not player or not player.valid then return end if LithiumCore if LithiumCore.menu.player_state and LithiumCore.menu.animation.closing == false or LithiumCore.localdata.AltScores.enabled == true then if sc_hud.enabled == false then return end end end sc_hud.enabled = not sc_hud.enabled sc_hud.index = 2 sc_hud.hud_scroll = 0 sc_hud.col = 1 sc_hud.binding = false sc_hud.cooldown = 10 sc_hud.last_tic = 0 end addHook("PreThinkFrame", settings_control) local function hud_settings(v, player) v.fadeScreen(0xFF00, sc_hud.fade) if sc_hud.fade > 0 then local bg = v.cachePatch("RECATKBG") local fg = v.cachePatch("RECATKFG") -- Screen dimensions in pixels local screen_w = v.width() / v.dupy() local screen_h = v.height() / v.dupx() -- Scale: 0.5 in Fixed Point local scale = FRACUNIT / 2 -- Width and height of the front patch after reduction local fg_w = fg.width / 2 local fg_h = fg.height / 2 local trans_arr = { V_90TRANS, V_90TRANS, V_90TRANS, V_90TRANS, V_80TRANS, V_80TRANS, V_70TRANS, V_70TRANS, V_60TRANS, V_60TRANS, V_50TRANS, V_50TRANS, V_40TRANS, V_40TRANS, V_30TRANS, V_30TRANS, V_20TRANS, V_20TRANS, V_10TRANS, V_10TRANS, 0 } local transFlag = trans_arr[sc_hud.fade + 1] or 0 -- Scrolling (using % of patch height) local bg_scroll = (leveltime / 2) % bg.height local fg_scroll = leveltime % fg_h -- Scroll down the reduced height -- Background for x = 320 - bg.width, 320 - screen_w - bg.width, -bg.width do for y = -bg.height + bg_scroll, screen_h, bg.height do v.draw(x, y, bg, V_SNAPTORIGHT | V_SNAPTOTOP | transFlag) end end -- The cycle runs at reduced height fg_h for y = -fg_h + fg_scroll, screen_h, fg_h do -- Left side panel v.drawScaled(0, y * FRACUNIT, scale, fg, V_SNAPTOLEFT | V_SNAPTOTOP | transFlag) -- Right side panel v.drawScaled(320 * FRACUNIT, y * FRACUNIT, scale, fg, V_SNAPTORIGHT | V_SNAPTOTOP | transFlag | V_FLIP) end end -- Fading if sc_hud.enabled then if sc_hud.fade ~= 20 then sc_hud.fade = $ + 1 end else if sc_hud.fade ~= 0 then sc_hud.fade = $ - 1 end end if controlPressed("settings") and not sc_hud.enabled or sc_hud.enabled and not sc_hud.binding and isNavPressed("back") and sc_hud.cooldown <= 0 then cmd_settings() end if not sc_hud.enabled then return end settings_rendering(v, player) end addHook("HUD", function(v, player, camera) if LithiumCore then return end hud_settings(v, player, camera) end) COM_AddCommand("sc_settings", cmd_settings, COM_LOCAL) local LC_Inserted = false addHook("AddonLoaded", do if LC_Inserted then return end if not LithiumCore then return end -- LithiumCore Hook System local lc_sc_hud = { name = "SC.SPECTATOR CAMERA", type = "HUD", typehud = {"game", "intermission"}, toggle = true, priority = 1100, TimeMicros = 0, func = hud_settings } LithiumCore.functions.AddHook(lc_sc_hud) -- LithiumCore Menu System local lang_text = { ["ru"] = { SC_MENU_NAME = "Конфиг Камеры наблюдения", SC_MENU_TIP = "Настройте конфигурацию камеры наблюдениия по своему вкусу." }, ["en"] = { SC_MENU_NAME = "Spectator Camera Settings", SC_MENU_TIP = "Customize your spectator camera settings to suit your preferences." } } LithiumCore.functions.addTextLanguage(lang_text) local lc_sc_menu = { name = "SC_MENU_NAME", description = "SC_MENU_TIP", type = "misc", funchud = function(v) if not sc_hud.enabled then LithiumCore.functions.saveloadmenustate("load") end end, funcenter = function() local player = consoleplayer local LC_menu = LithiumCore.menu.player_state sc_hud.enabled = true end, funchook = function(key) return true end } LithiumCore.functions.AddSubcatMenu(lc_sc_menu) LC_Inserted = true end)