diff --git a/README.md b/README.md
index d99af99..57fff87 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,8 @@ Hide UI ใช้ปุ่ม Backslash เพื่อซ่อน HUD รว
Ping colors อยู่ใน `scripts/ping_rainbow.lua` เปลี่ยนสีทั้งแท่งสัญญาณและตัวเลขโดยใช้ค่า ping จริง ภาพหลอดใช้ alpha จาก sprite เดิมและสีพื้นขาวเพื่อไม่ให้สีเพี้ยน ส่วนตัวเลขลงสีที่ glyph โดยไม่เขียนข้อความทับ จึงไม่กะพริบเมื่อค่าเปลี่ยน ผู้ใช้ยืนยันผลหน้าเดินแล้ว รายละเอียดและขอบเขตอยู่ใน `docs/ping-colors.md`
+Freecam และ FaceCam เลือกได้จากหน้า **สวิตช์ระบบพิเศษของ GaC** ใน [GaCy Srtool](https://gacy-srtool.vercel.app/) โดยตั้งระยะเริ่มต้นของ FaceCam แล้วกด Tab ในเกมเพื่อเปิดหรือปิด Freecam ใช้ปุ่มลูกศรและ Page Up / Page Down เคลื่อนกล้อง พร้อมคลิกขวาค้างเพื่อหมุน ส่วน FaceCam ยึดกล้องกับหัวตัวละครที่ควบคุมหรือกำลังแอ็กชันใน Battle ลูกกลิ้งปรับระยะชั่วคราว และเมื่อปิด–เปิดใหม่จะกลับเป็นระยะที่ตั้งไว้ ข้อความสถานะกล้องแสดงเมื่อปรับและหายหลังหยุดปรับ 3 วินาที ผู้ใช้ยืนยันมุม Battle และร่าง Khaslana แล้ว
+
`resources/PlayerCosmetics.json` ใช้รายการ PlayerOutfit และ AvatarSkin ที่อ่านจาก Client การปลดของแต่งผู้บุกเบิกส่งไอเท็มเป็นเจ้าของครั้งละหนึ่งชิ้นใน GetBag ส่วนชุดที่เลือกผ่าน SetPlayerOutfit เก็บใน `persistent.player_outfits` และส่งกลับเมื่อ login ใหม่ ค่า Outfit ID ที่สวมรับจาก selector ของ Client โดยตรงและ sync ตามโครงสร้าง PearlSR; ยังไม่ได้ export ตารางจับคู่ OutfitID/ItemID จึงไม่แปลงเลขด้วยสูตร
ข้อมูลฉากที่ปรับรอบนี้จำกัดที่ Starfroster กับ Moment of Joy ใน `resources/SceneMetadata.json`: อัปเดตกลุ่ม/prop ที่ยังมีอยู่และเป็นของ Server พร้อม SavedValues จาก `RuntimeDimension.DefaultValue` จึงไม่ใช้ MaxValue เป็นค่าเริ่มต้นของสองแมพนี้ กลไกรถไฟเป็น prop ฝั่ง Client และมีตัวแปร FSV_Coaster ในตารางปัจจุบัน ผลการแสดงฉากและปุ่มรถไฟยังต้องยืนยันในเกม ส่วนแมพอื่นยังใช้ข้อมูลเดิม ไม่ได้อ้างว่าอัปเดตทั้ง resource แล้ว
diff --git a/scripts/facecam_probe_temp.lua b/scripts/facecam_probe_temp.lua
deleted file mode 100644
index a29cd57..0000000
--- a/scripts/facecam_probe_temp.lua
+++ /dev/null
@@ -1,20 +0,0 @@
-if not _G.__GAC_FACECAM_PROBE_BATTLE then
- local ok,err=pcall(function()
- local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
- local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
- local phase=typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
- if not phase or not typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
- _G.__GAC_FACECAM_PROBE_BATTLE=true
- local t=phase:GetType()
- CS.UnityEngine.Debug.Log('[FaceCam battle] phase='..tostring(t))
- for _,members in ipairs({t:GetFields(flags),t:GetProperties(flags)}) do
- for i=0,math.min(members.Length-1,199) do
- local m=members[i];local n=tostring(m.Name):lower()
- if n:find('actor') or n:find('entity') or n:find('avatar') or n:find('turn') or n:find('battle') or n:find('stage') then
- CS.UnityEngine.Debug.Log('[FaceCam battle] member='..tostring(m.Name)..' detail='..tostring(m))
- end
- end
- end
- end)
- if not ok then _G.__GAC_FACECAM_PROBE_BATTLE=true;CS.UnityEngine.Debug.Log('[FaceCam battle] error='..tostring(err)) end
-end
diff --git a/scripts/freecam.lua b/scripts/freecam.lua
index 33e4592..15c8ba4 100644
--- a/scripts/freecam.lua
+++ b/scripts/freecam.lua
@@ -33,6 +33,9 @@ local function rainbow_watermark(now)
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
@@ -62,11 +65,72 @@ local function get_active_cam()
return nil
end
--- Resolve the character through the active camera's own follow target. A
--- humanoid head bone gives a model-relative point as the character moves.
--- Do not guess a world position if the Client has no usable target.
+-- 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 manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
+ local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
+ local field = typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags)
+ local phase = manager and field and field:GetValue(manager)
+ if not phase then return nil, nil, nil, false end
+ local entity, mode
+ if typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then
+ local instance = phase.BattleInstanceRef
+ mode = instance and instance.TurnBasedGameModeRef
+ entity = mode and mode.CurrentTurnOwnerEntity
+ -- Inserted attacks can act outside the turn owner's turn. Prefer the
+ -- most recently attacking entity while the Client reports one.
+ if mode then
+ local attackers = mode:GetAttackingEntityList()
+ if attackers and attackers.Count > 0 then entity = attackers[attackers.Count - 1] 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, true end
+ 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 model = model_for(entity)
+ -- In a transformed turn the Client can report a model-less turn owner;
+ -- the live battle's MVP entity points to the visible transformed actor.
+ if not model and mode then
+ local fallback = mode.CurrentMVPEntity
+ local fallbackModel = model_for(fallback)
+ if fallbackModel then entity, model = fallback, fallbackModel end
+ end
+ if not model then return nil, nil, nil, true 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 nodes = model:GetComponentsInChildren(typeof(CS.UnityEngine.Transform), true)
+ for i = 0, nodes.Length - 1 do
+ if nodes[i].name == 'Head_M' and nodes[i].gameObject.activeInHierarchy then
+ return nodes[i], model.transform, entity.RuntimeID, true
+ end
+ end
+ return nil, nil, nil, true
+end
+
local function facecam_target(cam)
local ok, head, root = pcall(function()
+ local battleHead, battleRoot, id, tracked = current_facecam_target()
+ if tracked 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
@@ -84,15 +148,46 @@ local function facecam_target(cam)
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.15
+ local ok, head, root, id, tracked = pcall(current_facecam_target)
+ if ok and tracked and head and root then
+ _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id
+ end
+ end
local head, root = _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT
- if not head or not root or not head.gameObject.activeInHierarchy then return false end
- local distance = tonumber(settings.distance) or 2
+ local alive, active = pcall(function() return head and root and head.gameObject.activeInHierarchy end)
+ if not alive or not active then
+ -- Character transformations can briefly unload a model while keeping
+ -- the same entity ID. Give the replacement time to become available.
+ _G.__FC_FACE_MISSING_SINCE = _G.__FC_FACE_MISSING_SINCE or now
+ return now - _G.__FC_FACE_MISSING_SINCE < 2
+ 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('[FaceCam: ON] Distance: ' .. string.format('%.1f', distance) .. '')
+ end
local target = head.position
- local direction = root.forward
- local position = target + direction * distance
- cam.transform.position = position
- cam.transform.rotation = CS.UnityEngine.Quaternion.LookRotation(target - position, Vector3.up)
+ local anchor = _G.__FC_FACE_ANCHOR
+ if not anchor or anchor.transform.parent ~= head then
+ if anchor then CS.UnityEngine.Object.Destroy(anchor) end
+ anchor = GameObject('GaC_FaceCamAnchor')
+ anchor.transform:SetParent(head, false)
+ _G.__FC_FACE_ANCHOR = anchor
+ end
+ -- The observed game rig uses Head_M's local up axis as its facial
+ -- direction; model-root forward does not follow the animated pose.
+ 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
@@ -153,7 +248,8 @@ local function update_mouse_gate()
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 wanted = _G.__FREECAM_ENABLED and inBattle
+ 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
@@ -233,6 +329,7 @@ function _G.__FREECAM_TICK()
if _G.__FREECAM_ENABLED then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2}
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 not head or not root then
@@ -254,16 +351,22 @@ function _G.__FREECAM_TICK()
show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look")
end
else
+ local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam"}
if cam then
set_cinemachine_enabled(cam, true)
end
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
- show_ui(rainbow_watermark(Time.unscaledTime or 0))
+ _G.__FC_FACE_ENTITY = 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('[' .. label .. ': OFF]')
end
end
- if not _G.__FREECAM_ENABLED then
- local now = Time.unscaledTime or 0
+ 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))
@@ -285,6 +388,10 @@ function _G.__FREECAM_TICK()
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_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("FaceCam: character unavailable")
end
return
@@ -313,9 +420,11 @@ function _G.__FREECAM_TICK()
-- Raw Unity mouse input remains available to the camera while
-- InControl battle clicks are gated separately below.
+ local cameraAdjusted = false
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)
end
@@ -352,6 +461,11 @@ function _G.__FREECAM_TICK()
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('[FreeCam: ON] Speed: ' .. string.format('%.1f', _G.__FREECAM_SPEED) .. '')
end
-- Enforce locked free camera transform EVERY frame
@@ -378,7 +492,7 @@ if not _G.__FREECAM_REGISTERED then
pcall(function() CS.UnityEngine.Application.onBeforeRender = CS.UnityEngine.Application.onBeforeRender + function() _G.__FREECAM_TICK() end end)
end
-if not _G.__FREECAM_ENABLED then
+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