775 lines
34 KiB
Lua
775 lines
34 KiB
Lua
local Input = CS.UnityEngine.Input
|
|
local KeyCode = CS.UnityEngine.KeyCode
|
|
local Vector3 = CS.UnityEngine.Vector3
|
|
local Time = CS.UnityEngine.Time
|
|
local Camera = CS.UnityEngine.Camera
|
|
local GameObject = CS.UnityEngine.GameObject
|
|
|
|
|
|
_G.__FREECAM_ENABLED = _G.__FREECAM_ENABLED or false
|
|
_G.__FREECAM_SPEED = _G.__FREECAM_SPEED or 15.0
|
|
|
|
local WATERMARK_PREFIX = "<color=#bb00ff>นี่คือเวอร์ชั่นทดสอบของเกย์ </color> "
|
|
local WATERMARK_NAME = "Honkai Star Gay"
|
|
|
|
-- A closely spaced hue gradient sweeps from right to left.
|
|
-- Keep the server name, UID, and the Freecam controls independent.
|
|
local function rainbow_watermark(now)
|
|
local parts = {WATERMARK_PREFIX}
|
|
for i = 1, #WATERMARK_NAME do
|
|
local h = ((now * 0.12 + (i - 1) * 0.035) % 1) * 6
|
|
local x = 1 - math.abs(h % 2 - 1)
|
|
local r, g, b
|
|
if h < 1 then r, g, b = 1, x, 0
|
|
elseif h < 2 then r, g, b = x, 1, 0
|
|
elseif h < 3 then r, g, b = 0, 1, x
|
|
elseif h < 4 then r, g, b = 0, x, 1
|
|
elseif h < 5 then r, g, b = x, 0, 1
|
|
else r, g, b = 1, 0, x end
|
|
parts[#parts + 1] = string.format("<color=#%02X%02X%02X>%s</color>",
|
|
math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5), WATERMARK_NAME:sub(i, i))
|
|
end
|
|
return table.concat(parts)
|
|
end
|
|
|
|
local function show_ui(msg)
|
|
if msg:find('[FreeCam:', 1, true) or msg:find('[FaceCam:', 1, true) or msg:find('FaceCam:', 1, true) then
|
|
_G.__FC_STATUS_UNTIL = (Time.unscaledTime or 0) + 3
|
|
end
|
|
pcall(function()
|
|
local obj = GameObject.Find("VersionText")
|
|
if obj then
|
|
local textComp = obj:GetComponentInChildren(typeof(CS.RPG.Client.LocalizedText))
|
|
if textComp then
|
|
textComp.text = msg
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function get_active_cam()
|
|
local cam = Camera.main
|
|
if cam and cam.gameObject.activeInHierarchy then
|
|
return cam
|
|
end
|
|
local all = Camera.allCameras
|
|
if all and all.Length > 0 then
|
|
for i = 0, all.Length - 1 do
|
|
local c = all[i]
|
|
if c and c.gameObject.activeInHierarchy and c.name ~= "UICamera" and not string.find(c.name, "UI") then
|
|
return c
|
|
end
|
|
end
|
|
return all[0]
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Resolve the controlled member or current battle actor through the Client's
|
|
-- entity state, and anchor to that model's observed head bone. If the current
|
|
-- phase does not expose either, do not guess a world position.
|
|
local function model_for(candidate)
|
|
local ok, result = pcall(function()
|
|
local component = candidate and candidate:GetComponent(typeof(CS.RPG.GameCore.CharacterModelComponent))
|
|
return component and component:GetModelGO()
|
|
end)
|
|
if ok then return result end
|
|
return nil
|
|
end
|
|
|
|
local function is_player_avatar(entity, model)
|
|
if not entity or not model then return false end
|
|
local name = tostring(model.name or '')
|
|
if name:find('Monster', 1, true) then return false end
|
|
if name:find('Avatar_00', 1, true) then return false end
|
|
if name:find('Avatar_', 1, true) or name:find('Player_', 1, true) then
|
|
return true
|
|
end
|
|
local ok, isHuman = pcall(function()
|
|
local anim = model:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
|
|
return anim and anim.isHuman
|
|
end)
|
|
return ok and (isHuman == true)
|
|
end
|
|
|
|
local function find_head_bone(model)
|
|
if not model then return nil end
|
|
local ok, bone = pcall(function()
|
|
local animator = model:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
|
|
if animator and animator.isHuman then
|
|
return animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head)
|
|
end
|
|
return nil
|
|
end)
|
|
if ok and bone and bone.gameObject.activeInHierarchy then
|
|
return bone
|
|
end
|
|
local nodes = model:GetComponentsInChildren(typeof(CS.UnityEngine.Transform), true)
|
|
for i = 0, nodes.Length - 1 do
|
|
local node = nodes[i]
|
|
if node.name == 'Head_M' and node.gameObject.activeInHierarchy then
|
|
return node
|
|
end
|
|
end
|
|
for i = 0, nodes.Length - 1 do
|
|
local node = nodes[i]
|
|
local lname = node.name:lower()
|
|
if (lname == 'head' or lname == 'bip001 head' or lname:find('head_m', 1, true)) and node.gameObject.activeInHierarchy then
|
|
return node
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Resolve the controlled member or current battle actor through the Client's
|
|
-- entity state, and anchor to that model's observed head bone. If the current
|
|
-- phase does not expose either, do not guess a world position.
|
|
local function current_facecam_target()
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
|
local phaseField = _G.__FC_PHASE_FIELD or typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags)
|
|
_G.__FC_PHASE_FIELD = phaseField
|
|
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
|
local phase = manager and phaseField and phaseField:GetValue(manager)
|
|
if not phase then return nil, nil, nil, false end
|
|
|
|
local now = Time.unscaledTime or 0
|
|
local entity, mode
|
|
|
|
if typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then
|
|
local instance = phase.BattleInstanceRef
|
|
mode = instance and instance.TurnBasedGameModeRef
|
|
if mode then
|
|
-- 1. Attacking Avatar (skill, ultimate, basic attack)
|
|
local attackers = mode:GetAttackingEntityList()
|
|
if attackers and attackers.Count > 0 then
|
|
for i = attackers.Count - 1, 0, -1 do
|
|
local cand = attackers[i]
|
|
local candModel = model_for(cand)
|
|
if is_player_avatar(cand, candModel) then
|
|
entity = cand
|
|
_G.__FC_BATTLE_LAST_ATTACKER = cand
|
|
_G.__FC_BATTLE_ATTACK_TIME = now
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 2. Keep tracking recent ultimate / attacking avatar during cutscenes (up to 4.5s)
|
|
if not entity and _G.__FC_BATTLE_LAST_ATTACKER then
|
|
local lastModel = model_for(_G.__FC_BATTLE_LAST_ATTACKER)
|
|
if lastModel and (now - (_G.__FC_BATTLE_ATTACK_TIME or 0)) < 4.5 then
|
|
entity = _G.__FC_BATTLE_LAST_ATTACKER
|
|
end
|
|
end
|
|
|
|
-- 3. Turn owner (only if player avatar)
|
|
if not entity then
|
|
local turnOwner = mode.CurrentTurnOwnerEntity
|
|
local turnModel = model_for(turnOwner)
|
|
if is_player_avatar(turnOwner, turnModel) then
|
|
entity = turnOwner
|
|
_G.__FC_BATTLE_LAST_AVATAR = turnOwner
|
|
end
|
|
end
|
|
|
|
-- 4. Stick to last active player avatar if turn owner is an enemy monster
|
|
if not entity and _G.__FC_BATTLE_LAST_AVATAR then
|
|
local lastModel = model_for(_G.__FC_BATTLE_LAST_AVATAR)
|
|
if lastModel then
|
|
entity = _G.__FC_BATTLE_LAST_AVATAR
|
|
end
|
|
end
|
|
|
|
-- 5. Fallback: MVP entity if it's an avatar
|
|
if not entity and mode.CurrentMVPEntity then
|
|
local mvp = mode.CurrentMVPEntity
|
|
local mvpModel = model_for(mvp)
|
|
if is_player_avatar(mvp, mvpModel) then
|
|
entity = mvp
|
|
_G.__FC_BATTLE_LAST_AVATAR = mvp
|
|
end
|
|
end
|
|
end
|
|
elseif phase:GetType().Name == 'AdventurePhase' then
|
|
entity = phase.CurrentPlayingTeamMember
|
|
else
|
|
return nil, nil, nil, false
|
|
end
|
|
|
|
if not entity then return nil, nil, nil, false end
|
|
|
|
local model = model_for(entity)
|
|
if not model and mode then
|
|
local fallback = mode.CurrentMVPEntity
|
|
local fallbackModel = model_for(fallback)
|
|
if fallbackModel and is_player_avatar(fallback, fallbackModel) then
|
|
entity, model = fallback, fallbackModel
|
|
end
|
|
end
|
|
if not model then return nil, nil, nil, false end
|
|
|
|
local cachedAlive, cachedActive = pcall(function()
|
|
return _G.__FC_FACE_HEAD and _G.__FC_FACE_ROOT
|
|
and _G.__FC_FACE_HEAD.gameObject.activeInHierarchy and _G.__FC_FACE_ROOT.gameObject.activeInHierarchy
|
|
end)
|
|
if _G.__FC_FACE_ENTITY == entity.RuntimeID and cachedAlive and cachedActive then
|
|
return _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, entity.RuntimeID, true
|
|
end
|
|
|
|
local head = find_head_bone(model)
|
|
if head then
|
|
return head, model.transform, entity.RuntimeID, true
|
|
end
|
|
|
|
return nil, model.transform, entity.RuntimeID, false
|
|
end
|
|
|
|
-- Log only changes, so diagnostics stay useful during long battles.
|
|
local function log_facecam_target(ok, head, root, id, tracked)
|
|
local success, detail = pcall(function()
|
|
if not ok then return 'resolve_error=' .. tostring(head) end
|
|
return 'tracked=' .. tostring(tracked) .. ' entity=' .. tostring(id)
|
|
.. ' model=' .. tostring(root and root.name)
|
|
.. ' head=' .. tostring(head and head.name)
|
|
.. ' head_parent=' .. tostring(head and head.parent and head.parent.name)
|
|
.. ' head_active=' .. tostring(head and head.gameObject.activeInHierarchy)
|
|
end)
|
|
if not success then detail = 'diagnostic_error=' .. tostring(detail) end
|
|
if _G.__FC_FACE_LAST_DIAGNOSTIC ~= detail then
|
|
_G.__FC_FACE_LAST_DIAGNOSTIC = detail
|
|
local message = '[GaC FaceCam] ' .. detail
|
|
CS.UnityEngine.Debug.Log(message)
|
|
local written, err = pcall(function()
|
|
local dir = _G.__GAC_DIAGNOSTICS_DIR
|
|
if not dir or dir == '' then error('server diagnostics directory unavailable') end
|
|
CS.System.IO.Directory.CreateDirectory(dir)
|
|
local path = CS.System.IO.Path.Combine(dir, 'facecam.log')
|
|
CS.System.IO.File.AppendAllText(path, tostring(CS.System.DateTime.Now) .. ' ' .. message .. '\n')
|
|
end)
|
|
if not written and _G.__FC_FACE_FILE_ERROR ~= tostring(err) then
|
|
_G.__FC_FACE_FILE_ERROR = tostring(err)
|
|
CS.UnityEngine.Debug.Log('[GaC FaceCam] file log error: ' .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
local function facecam_target(cam)
|
|
local ok, head, root = pcall(function()
|
|
local battleHead, battleRoot, id, tracked = current_facecam_target()
|
|
if tracked and battleHead and battleRoot then
|
|
_G.__FC_FACE_ENTITY = id
|
|
return battleHead, battleRoot
|
|
end
|
|
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
|
local virtual = brain and brain.ActiveVirtualCamera
|
|
local follow = virtual and virtual.Follow
|
|
if not follow then return nil, nil end
|
|
local animator = follow:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
|
|
if not animator and follow.root then
|
|
animator = follow.root:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
|
|
end
|
|
if animator and animator.isHuman then
|
|
local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head)
|
|
if bone then return bone, animator.transform end
|
|
end
|
|
local nodes = follow:GetComponentsInChildren(typeof(CS.UnityEngine.Transform), true)
|
|
if nodes then
|
|
for i = 0, nodes.Length - 1 do
|
|
local node = nodes[i]
|
|
if node.name == 'Head_M' and node.gameObject.activeInHierarchy then
|
|
return node, follow.transform
|
|
end
|
|
end
|
|
end
|
|
return nil, nil
|
|
end)
|
|
if ok and head and root then return head, root end
|
|
return nil, nil
|
|
end
|
|
|
|
local function update_facecam(cam, settings)
|
|
local now = Time.unscaledTime or 0
|
|
if now >= (_G.__FC_FACE_NEXT or 0) then
|
|
_G.__FC_FACE_NEXT = now + 0.08
|
|
local ok, head, root, id, tracked = pcall(current_facecam_target)
|
|
log_facecam_target(ok, head, root, id, tracked)
|
|
if ok and tracked and head and root then
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
else
|
|
-- Fall back to Cinemachine active follow target if current phase entity is not resolved
|
|
local fbHead, fbRoot = facecam_target(cam)
|
|
if fbHead and fbRoot then
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = fbHead, fbRoot
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local head, root = _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT
|
|
local alive, active = pcall(function() return head and root and head.gameObject.activeInHierarchy end)
|
|
if not alive or not active then
|
|
-- Immediate re-acquisition: cutscenes or transformations can swap model rigs.
|
|
-- Try current target or Cinemachine follow target right now in the same frame!
|
|
local ok, nHead, nRoot, nId, tracked = pcall(current_facecam_target)
|
|
if ok and tracked and nHead and nRoot then
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = nHead, nRoot, nId
|
|
head, root = nHead, nRoot
|
|
alive, active = true, true
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
else
|
|
local fbHead, fbRoot = facecam_target(cam)
|
|
if fbHead and fbRoot then
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = fbHead, fbRoot
|
|
head, root = fbHead, fbRoot
|
|
alive, active = true, true
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
if not alive or not active then
|
|
-- Hold last valid camera transform within grace period (10s); do not drop FaceCam lock.
|
|
_G.__FC_FACE_MISSING_SINCE = _G.__FC_FACE_MISSING_SINCE or now
|
|
if now - _G.__FC_FACE_MISSING_SINCE > 10.0 then
|
|
return false
|
|
end
|
|
if _G.__FC_POS and _G.__FC_EULER then
|
|
cam.transform.position = _G.__FC_POS
|
|
cam.transform.eulerAngles = _G.__FC_EULER
|
|
return true
|
|
end
|
|
return true
|
|
end
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
|
|
local distance = _G.__FC_FACE_DISTANCE or tonumber(settings.distance) or 2
|
|
local scroll = Input.mouseScrollDelta.y
|
|
if scroll ~= 0 then distance = distance - scroll * 0.2 end
|
|
distance = math.max(0.5, math.min(5, distance))
|
|
_G.__FC_FACE_DISTANCE = distance
|
|
if scroll ~= 0 then
|
|
show_ui('<color=#00FF88><b>[FaceCam: ON]</b> Distance: ' .. string.format('%.1f', distance) .. '</color>')
|
|
end
|
|
|
|
local target = head.position
|
|
local anchor = _G.__FC_FACE_ANCHOR
|
|
if not anchor or anchor.transform.parent ~= head then
|
|
if anchor then pcall(function() CS.UnityEngine.Object.Destroy(anchor) end) end
|
|
anchor = GameObject('GaC_FaceCamAnchor')
|
|
anchor.transform:SetParent(head, false)
|
|
_G.__FC_FACE_ANCHOR = anchor
|
|
end
|
|
|
|
-- หมุนตามหัวเหมือนเดิม: ล็อคตรงหน้าหัวตัวละครแบบ 1:1 ไม่หน่วง ไม่ดีเลย์ และไม่มีการเด้งไปมา
|
|
local direction = head.up
|
|
anchor.transform.position = target + direction * distance
|
|
anchor.transform.rotation = CS.UnityEngine.Quaternion.LookRotation(target - anchor.transform.position, Vector3.up)
|
|
|
|
cam.transform.position = anchor.transform.position
|
|
cam.transform.rotation = anchor.transform.rotation
|
|
|
|
_G.__FC_POS = cam.transform.position
|
|
_G.__FC_EULER = cam.transform.eulerAngles
|
|
return true
|
|
end
|
|
|
|
-- Detach only the active camera brain and restore its recorded state. Virtual
|
|
-- cameras continue updating animation/effect state; do not scan/disable every
|
|
-- virtual camera on each render frame.
|
|
_G.__FC_BRAINS = _G.__FC_BRAINS or {}
|
|
local function set_cinemachine_enabled(cam, enabled)
|
|
if enabled then
|
|
for id, state in pairs(_G.__FC_BRAINS) do
|
|
pcall(function() if state.brain then state.brain.enabled = state.enabled end end)
|
|
_G.__FC_BRAINS[id] = nil
|
|
end
|
|
pcall(function()
|
|
if cam then
|
|
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
|
if brain then brain.enabled = true end
|
|
cam.enabled = true
|
|
end
|
|
local mainCam = CS.UnityEngine.Camera.main
|
|
if mainCam and mainCam ~= cam then
|
|
local brain = mainCam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
|
if brain then brain.enabled = true end
|
|
mainCam.enabled = true
|
|
end
|
|
end)
|
|
return
|
|
end
|
|
pcall(function()
|
|
if not cam then return end
|
|
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
|
if not brain then return end
|
|
local id = brain:GetInstanceID()
|
|
if not _G.__FC_BRAINS[id] then
|
|
_G.__FC_BRAINS[id] = {brain=brain, enabled=brain.enabled}
|
|
end
|
|
brain.enabled = false
|
|
end)
|
|
end
|
|
|
|
-- Client inspection: InControlInputModule.allowMouseInput controls UI
|
|
-- clicks. Battle shortcuts also read MouseBindingSource independently.
|
|
-- Mask only mouse button controls in memory; leave keyboard bindings intact.
|
|
-- Do not use RemoveBinding/AddBinding (which emit OnBindingsChanged).
|
|
_G.__FC_MOUSE_GATE = _G.__FC_MOUSE_GATE or {modules={}, bindings={}}
|
|
local function restore_mouse_gate()
|
|
local gate = _G.__FC_MOUSE_GATE
|
|
for _, state in ipairs(gate.modules) do
|
|
pcall(function() state.module.allowMouseInput = true end)
|
|
end
|
|
for _, state in ipairs(gate.bindings) do
|
|
pcall(function()
|
|
if state.binding.Control == CS.InControl.Mouse.None then
|
|
gate.controlField:SetValue(state.binding, state.control)
|
|
state.action:ClearInputState()
|
|
end
|
|
end)
|
|
end
|
|
pcall(function()
|
|
local eventSystem = CS.UnityEngine.EventSystems.EventSystem.current
|
|
if eventSystem then
|
|
local module = eventSystem:GetComponent(typeof(CS.InControl.InControlInputModule))
|
|
if module then module.allowMouseInput = true end
|
|
end
|
|
end)
|
|
gate.modules, gate.bindings, gate.sets = {}, {}, {}
|
|
gate.active = false
|
|
end
|
|
|
|
local function update_mouse_gate()
|
|
local gate = _G.__FC_MOUSE_GATE
|
|
if not gate.phaseField then
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
|
gate.phaseField = typeof(CS.RPG.Client.GamePhaseManager):GetField("_CurrentPhase", flags)
|
|
end
|
|
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
|
local phase = manager and gate.phaseField:GetValue(manager)
|
|
local inBattle = phase and typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase)
|
|
local cameraSettings = _G.__GAC_CAMERA_SETTINGS
|
|
local wanted = _G.__FREECAM_ENABLED and inBattle and (not cameraSettings or cameraSettings.mode ~= 'facecam')
|
|
-- Releasing Freecam while holding RMB must not become a battle click.
|
|
if not wanted then
|
|
if gate.active and not Input.GetMouseButton(0) and not Input.GetMouseButton(1) then
|
|
restore_mouse_gate()
|
|
end
|
|
return
|
|
end
|
|
gate.active = true
|
|
if not gate.controlField then
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
|
gate.controlField = typeof(CS.InControl.MouseBindingSource):GetField("<Control>k__BackingField", flags)
|
|
gate.setsField = typeof(CS.InControl.InputManager):GetField("playerActionSets", flags)
|
|
assert(gate.controlField and gate.setsField, "Client mouse input schema is unavailable")
|
|
end
|
|
local eventSystem = CS.UnityEngine.EventSystems.EventSystem.current
|
|
if eventSystem then
|
|
local module = eventSystem:GetComponent(typeof(CS.InControl.InControlInputModule))
|
|
if module then
|
|
local recorded = false
|
|
for _, state in ipairs(gate.modules) do
|
|
if state.module == module then recorded = true; break end
|
|
end
|
|
if not recorded then
|
|
gate.modules[#gate.modules + 1] = {module=module, allowed=module.allowMouseInput}
|
|
end
|
|
if Input.GetMouseButton(1) then
|
|
module.allowMouseInput = false
|
|
else
|
|
module.allowMouseInput = true
|
|
end
|
|
end
|
|
end
|
|
gate.sets = gate.sets or {}
|
|
local sets = gate.setsField:GetValue(nil)
|
|
-- Scan each newly created action set once, rather than reflecting/scanning
|
|
-- the assembly or all bindings on every render callback.
|
|
for i = 0, sets.Count - 1 do
|
|
local actionSet = sets[i]
|
|
if not gate.sets[actionSet] then
|
|
gate.sets[actionSet] = true
|
|
local actions = actionSet.Actions
|
|
for j = 0, actions.Count - 1 do
|
|
local action = actions[j]
|
|
local bindings = action.Bindings
|
|
for k = 0, bindings.Count - 1 do
|
|
local binding = bindings[k]
|
|
if typeof(CS.InControl.MouseBindingSource):IsInstanceOfType(binding) then
|
|
local control = binding.Control
|
|
if control == CS.InControl.Mouse.LeftButton or control == CS.InControl.Mouse.RightButton then
|
|
gate.bindings[#gate.bindings + 1] = {binding=binding, control=control, action=action}
|
|
gate.controlField:SetValue(binding, CS.InControl.Mouse.None)
|
|
action:ClearInputState()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function _G.__FREECAM_TICK()
|
|
local ok, tickErr = pcall(function()
|
|
-- Render callbacks run for several cameras/passes in one frame. Consume
|
|
-- key-down events and integrate movement only once per Unity frame.
|
|
local frame = Time.frameCount
|
|
if _G.__FC_INPUT_FRAME == frame then
|
|
if _G.__FREECAM_ENABLED then
|
|
local cam = get_active_cam()
|
|
if cam and _G.__FC_POS then
|
|
cam.transform.position = _G.__FC_POS
|
|
cam.transform.eulerAngles = _G.__FC_EULER
|
|
end
|
|
end
|
|
return
|
|
end
|
|
_G.__FC_INPUT_FRAME = frame
|
|
|
|
-- Hotkey Handling:
|
|
-- Tab: Toggle (as set by user)
|
|
if Input.GetKeyDown(KeyCode.Tab) then
|
|
_G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED
|
|
_G.__FC_LAST_TIME = nil
|
|
local cam = get_active_cam()
|
|
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="facecam", distance=2}
|
|
|
|
if _G.__FREECAM_ENABLED then
|
|
if settings.mode == "facecam" then
|
|
_G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2
|
|
local head, root
|
|
if cam then head, root = facecam_target(cam) end
|
|
if head and root then
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = head, root
|
|
else
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
|
end
|
|
end
|
|
if cam then
|
|
_G.__FC_POS = cam.transform.position
|
|
_G.__FC_EULER = cam.transform.eulerAngles
|
|
set_cinemachine_enabled(cam, false)
|
|
end
|
|
if settings.mode == "facecam" then
|
|
show_ui("<color=#00FF88><b>[FaceCam: ON]</b> | Tab: Close | Scroll: Distance</color>")
|
|
else
|
|
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | Tab: Close</color>")
|
|
end
|
|
else
|
|
if cam then
|
|
set_cinemachine_enabled(cam, true)
|
|
end
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
|
_G.__FC_FACE_ENTITY = nil
|
|
_G.__FC_LAST_TRACKED_ENTITY = nil
|
|
_G.__FC_BATTLE_LAST_ATTACKER = nil
|
|
_G.__FC_BATTLE_LAST_AVATAR = nil
|
|
_G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
if _G.__FC_FACE_ANCHOR then CS.UnityEngine.Object.Destroy(_G.__FC_FACE_ANCHOR); _G.__FC_FACE_ANCHOR = nil end
|
|
local label = (settings.mode == 'facecam') and 'FaceCam' or 'FreeCam'
|
|
show_ui('<color=#00FF88><b>[' .. label .. ': OFF]</b></color>')
|
|
end
|
|
end
|
|
|
|
local now = Time.unscaledTime or 0
|
|
if now >= (_G.__FC_STATUS_UNTIL or 0) then
|
|
if now >= (_G.__FC_WATERMARK_NEXT or 0) then
|
|
_G.__FC_WATERMARK_NEXT = now + 0.05
|
|
show_ui(rainbow_watermark(now))
|
|
end
|
|
end
|
|
|
|
local gateOK, gateError = pcall(update_mouse_gate)
|
|
if not gateOK and not _G.__FC_GATE_ERROR then
|
|
_G.__FC_GATE_ERROR = true
|
|
CS.UnityEngine.Debug.Log("[FreeCam] Mouse gate error: " .. tostring(gateError))
|
|
end
|
|
|
|
-- Detect game phase changes (e.g. Battle <-> Adventure or Scene loading transitions)
|
|
-- Immediately restore Cinemachine and deactivate FreeCam/FaceCam so the camera does not
|
|
-- get pinned in the void (black screen) when returning to overworld scenes like Dewlight Pavilion.
|
|
pcall(function()
|
|
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
|
local phaseField = _G.__FC_PHASE_FIELD or typeof(CS.RPG.Client.GamePhaseManager):GetField("_CurrentPhase", flags)
|
|
_G.__FC_PHASE_FIELD = phaseField
|
|
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
|
local currentPhase = manager and phaseField and phaseField:GetValue(manager)
|
|
local currentPhaseName = currentPhase and currentPhase:GetType().Name or ""
|
|
|
|
if _G.__FC_LAST_PHASE_NAME and _G.__FC_LAST_PHASE_NAME ~= currentPhaseName and currentPhaseName ~= "" then
|
|
local cam = get_active_cam()
|
|
set_cinemachine_enabled(cam, true)
|
|
restore_mouse_gate()
|
|
_G.__FREECAM_ENABLED = false
|
|
_G.__FC_POS = nil
|
|
_G.__FC_EULER = nil
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
|
_G.__FC_FACE_ENTITY = nil
|
|
_G.__FC_LAST_TRACKED_ENTITY = nil
|
|
_G.__FC_BATTLE_LAST_ATTACKER = nil
|
|
_G.__FC_BATTLE_LAST_AVATAR = nil
|
|
_G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
if _G.__FC_FACE_ANCHOR then
|
|
CS.UnityEngine.Object.Destroy(_G.__FC_FACE_ANCHOR)
|
|
_G.__FC_FACE_ANCHOR = nil
|
|
end
|
|
end
|
|
if currentPhaseName ~= "" then
|
|
_G.__FC_LAST_PHASE_NAME = currentPhaseName
|
|
end
|
|
end)
|
|
|
|
-- Ensure Cinemachine is always active when Freecam is off to prevent black screens
|
|
if not _G.__FREECAM_ENABLED then
|
|
pcall(function()
|
|
local mainCam = CS.UnityEngine.Camera.main
|
|
if mainCam then
|
|
local brain = mainCam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
|
if brain and not brain.enabled then
|
|
brain.enabled = true
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
if _G.__FREECAM_ENABLED then
|
|
local cam = get_active_cam()
|
|
if cam then
|
|
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="facecam", distance=2}
|
|
|
|
if settings.mode == "facecam" then
|
|
if not update_facecam(cam, settings) then
|
|
set_cinemachine_enabled(cam, true)
|
|
_G.__FREECAM_ENABLED = false
|
|
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
|
_G.__FC_FACE_ENTITY = nil
|
|
_G.__FC_LAST_TRACKED_ENTITY = nil
|
|
_G.__FC_BATTLE_LAST_ATTACKER = nil
|
|
_G.__FC_BATTLE_LAST_AVATAR = nil
|
|
_G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil
|
|
_G.__FC_FACE_MISSING_SINCE = nil
|
|
if _G.__FC_FACE_ANCHOR then CS.UnityEngine.Object.Destroy(_G.__FC_FACE_ANCHOR); _G.__FC_FACE_ANCHOR = nil end
|
|
show_ui("<color=#FFAA44>FaceCam: character unavailable</color>")
|
|
end
|
|
return
|
|
end
|
|
|
|
set_cinemachine_enabled(cam, false)
|
|
|
|
if not _G.__FC_POS then
|
|
_G.__FC_POS = cam.transform.position
|
|
_G.__FC_EULER = cam.transform.eulerAngles
|
|
end
|
|
|
|
local now = Time.unscaledTime or 0
|
|
local dt = now - (_G.__FC_LAST_TIME or now)
|
|
if dt <= 0 or dt > 0.1 then dt = 0.016 end
|
|
_G.__FC_LAST_TIME = now
|
|
|
|
-- Speed adjustment (+ / -)
|
|
local cameraAdjusted = false
|
|
if Input.GetKeyDown(KeyCode.Equals) or Input.GetKeyDown(KeyCode.KeypadPlus) then
|
|
_G.__FREECAM_SPEED = math.min(300.0, _G.__FREECAM_SPEED * 1.5)
|
|
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | Tab: Close</color>")
|
|
end
|
|
if Input.GetKeyDown(KeyCode.Minus) or Input.GetKeyDown(KeyCode.KeypadMinus) then
|
|
_G.__FREECAM_SPEED = math.max(0.5, _G.__FREECAM_SPEED / 1.5)
|
|
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | Tab: Close</color>")
|
|
end
|
|
|
|
-- Raw Unity mouse input remains available to the camera while
|
|
-- RMB is held; our InControl mouse gate blocks gameplay clicks.
|
|
if Input.GetMouseButton(1) then
|
|
local mouseX = Input.GetAxis("Mouse X") or 0
|
|
local mouseY = Input.GetAxis("Mouse Y") or 0
|
|
local rotSpeed = 3.0
|
|
local euler = _G.__FC_EULER or cam.transform.eulerAngles
|
|
local newPitch = euler.x - mouseY * rotSpeed
|
|
if newPitch > 180 then newPitch = newPitch - 360 end
|
|
newPitch = math.max(-89.0, math.min(89.0, newPitch))
|
|
local newYaw = euler.y + mouseX * rotSpeed
|
|
_G.__FC_EULER = Vector3(newPitch, newYaw, 0)
|
|
cameraAdjusted = true
|
|
end
|
|
|
|
local curEuler = _G.__FC_EULER or cam.transform.eulerAngles
|
|
local radY = curEuler.y * math.pi / 180.0
|
|
local radX = curEuler.x * math.pi / 180.0
|
|
local fwd = Vector3(math.sin(radY) * math.cos(radX), -math.sin(radX), math.cos(radY) * math.cos(radX))
|
|
local right = Vector3(math.cos(radY), 0, -math.sin(radY))
|
|
|
|
local move = Vector3(0, 0, 0)
|
|
|
|
-- Continuous flying via Arrow Keys
|
|
if Input.GetKey(KeyCode.UpArrow) then
|
|
move = move + fwd
|
|
end
|
|
if Input.GetKey(KeyCode.DownArrow) then
|
|
move = move - fwd
|
|
end
|
|
if Input.GetKey(KeyCode.RightArrow) then
|
|
move = move + right
|
|
end
|
|
if Input.GetKey(KeyCode.LeftArrow) then
|
|
move = move - right
|
|
end
|
|
if Input.GetKey(KeyCode.PageUp) then
|
|
move = move + Vector3(0, 1, 0)
|
|
end
|
|
if Input.GetKey(KeyCode.PageDown) then
|
|
move = move - Vector3(0, 1, 0)
|
|
end
|
|
|
|
if move.sqrMagnitude > 0.0001 then
|
|
_G.__FC_POS = _G.__FC_POS + move.normalized * (_G.__FREECAM_SPEED * dt)
|
|
cameraAdjusted = true
|
|
end
|
|
if cameraAdjusted and now >= (_G.__FC_INDICATOR_NEXT or 0) then
|
|
_G.__FC_INDICATOR_NEXT = now + 0.1
|
|
show_ui('<color=#00FF88><b>[FreeCam: ON]</b> Speed: ' .. string.format('%.1f', _G.__FREECAM_SPEED) .. '</color>')
|
|
end
|
|
|
|
-- Enforce locked free camera transform EVERY frame
|
|
cam.transform.position = _G.__FC_POS
|
|
cam.transform.eulerAngles = _G.__FC_EULER
|
|
end
|
|
end
|
|
end)
|
|
if not ok and _G.__FC_LAST_TICK_ERROR ~= tostring(tickErr) then
|
|
_G.__FC_LAST_TICK_ERROR = tostring(tickErr)
|
|
CS.UnityEngine.Debug.LogError("[GaC FreeCam Error] " .. tostring(tickErr))
|
|
end
|
|
end
|
|
|
|
-- Multiple hooks for complete Unity & xLua render pipeline coverage
|
|
if not _G.__FREECAM_REGISTERED then
|
|
_G.__FREECAM_REGISTERED = true
|
|
|
|
local function hook(target, evt)
|
|
pcall(function() target[evt]('+', function(c) _G.__FREECAM_TICK() end) end)
|
|
pcall(function() target[evt] = target[evt] + function(c) _G.__FREECAM_TICK() end end)
|
|
end
|
|
|
|
hook(Camera, "onPreRender")
|
|
hook(Camera, "onPreCull")
|
|
hook(Camera, "onPostRender")
|
|
pcall(function() CS.UnityEngine.Application.onBeforeRender('+', function() _G.__FREECAM_TICK() end) end)
|
|
pcall(function() CS.UnityEngine.Application.onBeforeRender = CS.UnityEngine.Application.onBeforeRender + function() _G.__FREECAM_TICK() end end)
|
|
end
|
|
|
|
if not _G.__FREECAM_ENABLED and (Time.unscaledTime or 0) >= (_G.__FC_STATUS_UNTIL or 0) then
|
|
show_ui(rainbow_watermark(Time.unscaledTime or 0))
|
|
end
|
|
|
|
-- Restore the in-memory controls before the Client tears down its settings.
|
|
if not _G.__FC_MOUSE_QUIT_REGISTERED then
|
|
local ok = pcall(function()
|
|
CS.UnityEngine.Application.quitting('+', function() restore_mouse_gate() end)
|
|
end)
|
|
_G.__FC_MOUSE_QUIT_REGISTERED = ok
|
|
end
|