fix(camera): align FaceCam with head 3D orientation, prevent detachment during ultimates and separate hotkeys

This commit is contained in:
CAtlas0784 committed 2026-10-31 22:47:07 +07:00
1 parent 276cb14299
commit a396f1227c
3 files changed
+339 -103

No files matched your search

+153 -90
View File
@@ -125,18 +125,11 @@ end
-- 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 = nil
if manager then
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
local field = typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags)
if field then
phase = field:GetValue(manager)
end
if not phase and manager.PhaseContext then
phase = manager.PhaseContext
end
end
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
@@ -203,7 +196,7 @@ local function current_facecam_target()
return nil, nil, nil, false
end
if not entity then return nil, nil, nil, true end
if not entity then return nil, nil, nil, false end
local model = model_for(entity)
if not model and mode then
@@ -213,7 +206,7 @@ local function current_facecam_target()
entity, model = fallback, fallbackModel
end
end
if not model then return nil, nil, nil, true 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
@@ -228,7 +221,7 @@ local function current_facecam_target()
return head, model.transform, entity.RuntimeID, true
end
return nil, model.transform, entity.RuntimeID, true
return nil, model.transform, entity.RuntimeID, false
end
-- Log only changes, so diagnostics stay useful during long battles.
@@ -263,11 +256,9 @@ end
local function facecam_target(cam)
local ok, head, root = pcall(function()
local battleHead, battleRoot, id, tracked = current_facecam_target()
if tracked then
if tracked and battleHead and battleRoot then
_G.__FC_FACE_ENTITY = id
if battleHead and battleRoot then
return battleHead, battleRoot
end
return battleHead, battleRoot
end
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
local virtual = brain and brain.ActiveVirtualCamera
@@ -277,11 +268,22 @@ local function facecam_target(cam)
if not animator and follow.root then
animator = follow.root:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
end
if not animator or not animator.isHuman then return nil, nil end
local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head)
return bone, animator.transform
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 then return head, root end
if ok and head and root then return head, root end
return nil, nil
end
@@ -291,9 +293,14 @@ local function update_facecam(cam, settings)
_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 then
if head and root then
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id
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
@@ -302,10 +309,29 @@ local function update_facecam(cam, settings)
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
-- Character transformations or ultimate cutscenes can briefly unload/hide a model.
-- Hold last valid camera transform; do not drop FaceCam lock during ultimates.
-- 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 > 8.0 then
if now - _G.__FC_FACE_MISSING_SINCE > 10.0 then
return false
end
if _G.__FC_POS and _G.__FC_EULER then
@@ -330,44 +356,42 @@ local function update_facecam(cam, settings)
local eyeOffset = 0.08
local faceCenter = head.position + Vector3(0, eyeOffset, 0)
-- Prioritize character model forward direction so camera stays solidly centered
-- in front of the avatar without snapping/flipping during attacks or ultimate cutscenes.
local fwd
if root then
local rX, rZ = root.forward.x, root.forward.z
local rMagSq = rX * rX + rZ * rZ
if rMagSq > 0.001 then
local invR = 1.0 / math.sqrt(rMagSq)
fwd = Vector3(rX * invR, 0, rZ * invR)
end
end
if not fwd then
local hX, hZ = head.up.x, head.up.z
local hMagSq = hX * hX + hZ * hZ
if hMagSq > 0.001 then
local invH = 1.0 / math.sqrt(hMagSq)
fwd = Vector3(hX * invH, 0, hZ * invH)
else
fwd = Vector3(0, 0, 1)
end
-- Rotate according to head bone in full 3D (user request: "ให้มันหมุนตามหัวเหมือนเดิม")
-- In HSR rigs, head.up points directly outward from the face (facial normal).
local fwd = head.up
if not fwd or fwd.sqrMagnitude < 0.001 then
fwd = root and root.forward or Vector3(0, 0, 1)
else
fwd = fwd.normalized
end
local desiredPos = faceCenter + fwd * distance
local lookDir = faceCenter - desiredPos
local desiredRot = CS.UnityEngine.Quaternion.LookRotation(lookDir, Vector3.up)
-- Smooth interpolation to remove micro-jitter while tracking
-- Prevent camera gimbal flip when head tilts steeply up or down
local upVector = Vector3.up
if math.abs(fwd.y) > 0.88 then
upVector = root and root.up or Vector3(0, 0, 1)
end
local desiredRot = CS.UnityEngine.Quaternion.LookRotation(lookDir, upVector)
-- Dynamic snapping & smoothing:
-- If character dashed / teleported during an attack animation (gap > 3.0m),
-- snap immediately so the camera stays solidly glued to the head and never detaches!
local dt = Time.unscaledDeltaTime or 0.016
if dt <= 0 or dt > 0.1 then dt = 0.016 end
local smoothSpeed = 16.0
local factor = math.min(1.0, dt * smoothSpeed)
if _G.__FC_LAST_TRACKED_ENTITY ~= _G.__FC_FACE_ENTITY or not _G.__FC_POS then
local currentCamPos = cam.transform.position
local distGap = (currentCamPos - desiredPos).magnitude
if distGap > 3.0 or _G.__FC_LAST_TRACKED_ENTITY ~= _G.__FC_FACE_ENTITY or not _G.__FC_POS then
_G.__FC_LAST_TRACKED_ENTITY = _G.__FC_FACE_ENTITY
cam.transform.position = desiredPos
cam.transform.rotation = desiredRot
else
cam.transform.position = Vector3.Lerp(cam.transform.position, desiredPos, factor)
local smoothSpeed = 22.0
local factor = math.min(1.0, dt * smoothSpeed)
cam.transform.position = Vector3.Lerp(currentCamPos, desiredPos, factor)
cam.transform.rotation = CS.UnityEngine.Quaternion.Slerp(cam.transform.rotation, desiredRot, factor)
end
@@ -514,7 +538,7 @@ local function update_mouse_gate()
end
function _G.__FREECAM_TICK()
pcall(function()
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
@@ -529,13 +553,46 @@ function _G.__FREECAM_TICK()
return
end
_G.__FC_INPUT_FRAME = frame
if Input.GetKeyDown(KeyCode.F8) or Input.GetKeyDown(KeyCode.Insert) then
_G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED
-- Hotkey Handling:
-- F7: Toggle FaceCam directly
-- F8: Toggle FreeCam directly
-- Insert: Toggle active / last camera mode
local toggleFaceCam = Input.GetKeyDown(KeyCode.F7)
local toggleFreeCam = Input.GetKeyDown(KeyCode.F8)
local toggleInsert = Input.GetKeyDown(KeyCode.Insert)
if toggleFaceCam or toggleFreeCam or toggleInsert then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2}
local defaultMode = settings.mode or "freecam"
if toggleFaceCam then
if _G.__FREECAM_ENABLED and _G.__FC_ACTIVE_MODE == "facecam" then
_G.__FREECAM_ENABLED = false
else
_G.__FREECAM_ENABLED = true
_G.__FC_ACTIVE_MODE = "facecam"
end
elseif toggleFreeCam then
if _G.__FREECAM_ENABLED and _G.__FC_ACTIVE_MODE == "freecam" then
_G.__FREECAM_ENABLED = false
else
_G.__FREECAM_ENABLED = true
_G.__FC_ACTIVE_MODE = "freecam"
end
elseif toggleInsert then
_G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED
if _G.__FREECAM_ENABLED and not _G.__FC_ACTIVE_MODE then
_G.__FC_ACTIVE_MODE = defaultMode
end
end
_G.__FC_LAST_TIME = nil
local cam = get_active_cam()
local activeMode = _G.__FC_ACTIVE_MODE or defaultMode
if _G.__FREECAM_ENABLED then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2}
if settings.mode == "facecam" then
if activeMode == "facecam" then
_G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2
local head, root
if cam then head, root = facecam_target(cam) end
@@ -550,13 +607,12 @@ function _G.__FREECAM_TICK()
_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> | F8/Insert: Close</color>")
if activeMode == "facecam" then
show_ui("<color=#00FF88><b>[FaceCam: ON]</b> | F7/Insert: Close | Scroll: Dist | F8: FreeCam</color>")
else
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</color>")
end
else
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam"}
if cam then
set_cinemachine_enabled(cam, true)
end
@@ -568,7 +624,7 @@ function _G.__FREECAM_TICK()
_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'
local label = (activeMode == 'facecam') and 'FaceCam' or 'FreeCam'
show_ui('<color=#00FF88><b>[' .. label .. ': OFF]</b></color>')
end
end
@@ -591,16 +647,13 @@ function _G.__FREECAM_TICK()
-- 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 currentPhaseName = ""
if manager then
if manager.CurrentGamePhaseType then
currentPhaseName = tostring(manager.CurrentGamePhaseType)
end
if currentPhaseName == "" and manager.PhaseContext then
currentPhaseName = manager.PhaseContext:GetType().Name
end
end
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)
@@ -642,7 +695,9 @@ function _G.__FREECAM_TICK()
local cam = get_active_cam()
if cam then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2}
if settings.mode == "facecam" then
local activeMode = _G.__FC_ACTIVE_MODE or settings.mode or "freecam"
if activeMode == "facecam" then
if not update_facecam(cam, settings) then
set_cinemachine_enabled(cam, true)
_G.__FREECAM_ENABLED = false
@@ -658,8 +713,9 @@ function _G.__FREECAM_TICK()
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
@@ -671,32 +727,35 @@ function _G.__FREECAM_TICK()
_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: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</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: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</color>")
end
-- Raw Unity mouse input remains available to the camera while
-- InControl battle clicks are gated separately below.
local cameraAdjusted = false
-- RMB is held; our InControl mouse gate blocks gameplay clicks.
if Input.GetMouseButton(1) then
local rotX = Input.GetAxis("Mouse X") or 0
local rotY = Input.GetAxis("Mouse Y") or 0
cameraAdjusted = rotX ~= 0 or rotY ~= 0
_G.__FC_EULER = Vector3(_G.__FC_EULER.x - rotY * 2.5,
_G.__FC_EULER.y + rotX * 2.5, 0)
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
-- Compute 3D direction vectors from camera Euler angles
local radY = _G.__FC_EULER.y * (math.pi / 180.0)
local radX = _G.__FC_EULER.x * (math.pi / 180.0)
local cosX = math.cos(radX)
local fwd = Vector3(math.sin(radY) * cosX, -math.sin(radX), math.cos(radY) * cosX)
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)
@@ -736,6 +795,10 @@ function _G.__FREECAM_TICK()
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