fix(camera): align FaceCam with head 3D orientation, prevent detachment during ultimates and separate hotkeys
This commit is contained in:
1 parent
276cb14299
commit
a396f1227c
3 files changed
+338
-102
No files matched your search
@@ -0,0 +1,129 @@
|
||||
# Offline Reverse Engineering & Development Guide for AI Agents
|
||||
*(How to Develop and Debug Game Servers Without Keeping the Game Running with Frida)*
|
||||
|
||||
> **Target Audience**: AI Assistants (ChatGPT, Claude, Codex, Copilot) assisting developers on the `GaC-hkrpg-go` emulator and similar Unity/il2cpp game server projects.
|
||||
> **Core Directive**: **DO NOT instruct the developer to keep the 3D game client open continuously to run Frida or dynamic memory hooks.** Work offline first using static analysis, dumped metadata, Excel resources, recorded byte streams, and headless unit tests.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Continuous Live Hooking (Frida) is Anti-Pattern
|
||||
|
||||
Demanding that a developer keeps a live 3D game client running with dynamic Frida hooks in the background causes severe friction:
|
||||
1. **Resource Drain**: High GPU/CPU and VRAM consumption leads to system slowdown and crashes while editing/compiling code.
|
||||
2. **Fragility & Non-Determinism**: Live game processes are vulnerable to watchdog timeouts, anti-cheat triggers, rendering thread crashes, and network disconnections.
|
||||
3. **Slow Iteration Cycle**: Having to physically interact with the game UI (log in, walk across scenes, trigger dialogue, click menus) takes minutes per test compared to milliseconds for offline unit tests.
|
||||
4. **Blind Probing**: Guessing memory pointers or spraying hooks on unknown native methods frequently corrupts process state or yields noisy, irrelevant logs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The 5 Pillars of Offline Emulation Development
|
||||
|
||||
Every feature or bug in a game server project can be solved using one or more of the following offline pillars:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Problem / Feature Request"] --> B{"Is it a Schema, Class, or Enum?"}
|
||||
B -- Yes --> C["Pillar 1: Static Dumps<br>(Dummy DLLs / dump.cs / Proto)"]
|
||||
B -- No --> D{"Is it Game Data, Stats, or Formulas?"}
|
||||
D -- Yes --> E["Pillar 2: Excel / Resource JSON<br>(ExcelBinOutput / Configs)"]
|
||||
D -- No --> F{"Is it Protocol / Byte Wire Bug?"}
|
||||
F -- Yes --> G["Pillar 3: Packet Capture & Replay<br>(*.bin payloads / Go tests)"]
|
||||
F -- No --> H{"Is it State Machine / Flow Logic?"}
|
||||
H -- Yes --> I["Pillar 4: Headless Unit Tests<br>(Mock Sessions / State Simulation)"]
|
||||
H -- No --> J["Pillar 5: Bounded One-Shot Capture<br>(Targeted probe -> Save JSON -> Exit)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pillar 1: Static Metadata & C# Assembly Inspection (No Game Required)
|
||||
|
||||
For Unity/il2cpp games, all types, classes, interfaces, protobuf fields, enums, RPC CmdIDs, and method signatures are static per client patch.
|
||||
|
||||
1. **Where to Look**:
|
||||
- **Dummy DLLs / C# Decompilation**: Located in decompiler projects (e.g., `dnSpy`, `ILSpy`, `Cpp2IL`).
|
||||
- **`dump.cs` / `script.json`**: Generated once via `Il2CppDumper`. Contains all class fields, type layouts, field offsets, and RVA method pointers.
|
||||
- **Protocol Definitions**: Protobuf `.proto` files and CmdID mapping tables (e.g., `cmd_ids_*.json`, `protocol/`).
|
||||
2. **What to Search**:
|
||||
- Want to know what fields `GridFightQuitScRsp` contains? Inspect the compiled Protobuf class in the decompiled assembly or `.proto`.
|
||||
- Want to know enum values (e.g., `GridFightPhaseType`, `BattleEndStatus`)? Look them up in `dump.cs` or the dummy DLLs.
|
||||
- **Never ask the user to hook memory just to read a struct layout.**
|
||||
|
||||
---
|
||||
|
||||
### Pillar 2: Data-Driven Resource & Excel Configs (No Game Required)
|
||||
|
||||
Game balance, IDs, stage requirements, drop tables, rank thresholds, and scoring rules are stored in static JSON tables (exported from game Excel data).
|
||||
|
||||
1. **Where to Look**:
|
||||
- `resources/` (e.g., `resources/CurrencyWarsCatalog.json`, `resources/CurrencyWarsXaodev.json`).
|
||||
- `ExcelBinOutput/*.json` or community resource dumps (e.g., `StarRailRes`).
|
||||
2. **Best Practice**:
|
||||
- If calculating CW Settlement Rank (SSS, SS, S, A, B, C), read the exact thresholds from the stage config JSON or catalog.
|
||||
- Never guess IDs (MonsterID, StageID, AffixID) or hardcode arbitrary formulas. Check the static catalog first.
|
||||
|
||||
---
|
||||
|
||||
### Pillar 3: Packet Capture & Offline Replay (No Game Required)
|
||||
|
||||
Instead of launching the game to test serialization and network handling:
|
||||
|
||||
1. **Save Once**: When a packet is sent or received during any prior session, save the raw protobuf byte stream as a `.bin` or hex file (e.g., `battle_last.bin`, `battle_last_response.bin`).
|
||||
2. **Inspect Offline**:
|
||||
- Use offline parsing scripts (e.g., Go scripts or tools like `cmd/inspect_...`) to unmarshal the bytes into Go structs.
|
||||
- Print the decoded JSON to see exactly what the client sent or received.
|
||||
3. **Verify Wire Compliance**:
|
||||
- Compare marshaled Go output with the captured `.bin` to ensure field tags and types match 1:1.
|
||||
|
||||
---
|
||||
|
||||
### Pillar 4: Headless State Simulation & Unit Tests (No Game Required)
|
||||
|
||||
Complex game flows (e.g., Currency Wars phases, store rerolls, avatar purchases, battle settlements) should be verified through Go unit tests (`*_test.go`) or CLI mock runners.
|
||||
|
||||
1. **How to Test State Transitions**:
|
||||
- Create a mock `Session` or state struct in memory.
|
||||
- Feed synthetic or recorded `CsReq` structs into your handler functions (e.g., `HandleGridFightQuitCsReq`).
|
||||
- Assert that:
|
||||
- The returned `ScRsp` has `Retcode == 0`.
|
||||
- State variables (e.g., Gold, HP, Level, Score, Items) mutate correctly according to game rules.
|
||||
2. **Speed & Reliability**:
|
||||
- Runs in `< 10 milliseconds` via `go test ./...`.
|
||||
- Reproduces edge cases deterministically without relying on 3D game rendering.
|
||||
|
||||
---
|
||||
|
||||
### Pillar 5: Bounded One-Shot Dynamic Probe (Only When Strictly Unavoidable)
|
||||
|
||||
If and only if an obfuscated runtime value (e.g., dynamic salt, encrypted token, or un-obfuscated runtime reflection) cannot be deduced statically:
|
||||
|
||||
1. **Do NOT run interactive Frida loops.**
|
||||
2. **Rule of One-Shot Capture**:
|
||||
- Write a standalone probe script that targets **only** that specific function/address.
|
||||
- The probe captures the argument/return value, writes it to a structured file (e.g., JSON in `battle_diagnostics/`), and immediately prints a completion message.
|
||||
- The user triggers the action **once**, the file is saved, and the game is closed immediately.
|
||||
3. **Script Hygiene**:
|
||||
- Store diagnostic probes outside `scripts/` (e.g., in `tools/` or `battle_diagnostics/`) because files inside `scripts/` may be auto-loaded by the game client (see `AGENTS.md`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Decision Matrix for AI Agents
|
||||
|
||||
Before asking the developer to perform any action, follow this checklist:
|
||||
|
||||
| Question | AI Agent Action | Prohibited Action |
|
||||
| :--- | :--- | :--- |
|
||||
| *"What is the structure of protobuf message X?"* | Inspect `protocol/`, `dummy.dll`, or `dump.cs`. | Do not ask the user to inspect memory with Frida. |
|
||||
| *"What is the formula or ID for feature Y?"* | Search `resources/` or Excel JSON tables. | Do not guess or invent arbitrary IDs in code. |
|
||||
| *"Did my server handler process the request correctly?"* | Write a Go unit test (`_test.go`) with mock data. | Do not ask the user to open the game just to check. |
|
||||
| *"Why does the client reject packet Z?"* | Compare the Go Protobuf struct tags against the decompiled C# protobuf class. | Do not tell the user to blindly inject random packets. |
|
||||
| *"Need to inspect a native runtime value?"* | Provide a single-use script that saves output to a file and exits. | Do not tell the user to keep the game running in the background. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Repository Rules Compliance (`AGENTS.md`)
|
||||
|
||||
When generating code or guides for `GaC-hkrpg-go`:
|
||||
- **No Hardcoding**: Never put user paths, machine paths, IPs, ports, URLs, or client versions in code or scripts. Use `server.json` / `server.local.json`.
|
||||
- **Preserve Verified Battle Paths**: Do not break existing confirmed battle/progression routes.
|
||||
- **Probe Cleanup**: Never leave experimental probe scripts in `scripts/`.
|
||||
- **Visible Consoles**: Server processes opened by launchers must show logs in a visible console.
|
||||
+57
-13
@@ -4,19 +4,19 @@
|
||||
"auto_technique_food": true,
|
||||
"camera_settings": {
|
||||
"distance": 2,
|
||||
"mode": "facecam"
|
||||
"mode": "freecam"
|
||||
},
|
||||
"currency_wars": {
|
||||
"52022": {
|
||||
"format_version": 1,
|
||||
"uid": 52022,
|
||||
"client_version": "4.6.53",
|
||||
"assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144",
|
||||
"client_version": "4.6.53",
|
||||
"format_version": 1,
|
||||
"revision": 989,
|
||||
"wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG",
|
||||
"runtime": {
|
||||
"run_id": 951
|
||||
}
|
||||
},
|
||||
"uid": 52022,
|
||||
"wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG"
|
||||
}
|
||||
},
|
||||
"currency_wars_commands": [
|
||||
@@ -53,10 +53,10 @@
|
||||
4001
|
||||
],
|
||||
"position": {
|
||||
"rot_y": 45000,
|
||||
"x": 28515,
|
||||
"y": -4933,
|
||||
"z": 1080
|
||||
"rot_y": 195849,
|
||||
"x": -1115,
|
||||
"y": -152077,
|
||||
"z": -642542
|
||||
},
|
||||
"profile": {
|
||||
"display_avatars": [
|
||||
@@ -85,9 +85,9 @@
|
||||
"signature": "test"
|
||||
},
|
||||
"scene": {
|
||||
"entry_id": 102020107,
|
||||
"floor_id": 10202001,
|
||||
"plane_id": 10202
|
||||
"entry_id": 1030402,
|
||||
"floor_id": 10304001,
|
||||
"plane_id": 10304
|
||||
},
|
||||
"scene_group_properties": {
|
||||
"20571001": {
|
||||
@@ -340,6 +340,50 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"30225": {
|
||||
"buffs": [
|
||||
3111094,
|
||||
3111065,
|
||||
3111092
|
||||
],
|
||||
"nodes": {
|
||||
"0": {
|
||||
"result": "EgcIlb8FEMkOMMkOSAF6agoXCOILEAMgUDIMCFEQkbQBGFAgBigFOgAKGQjdCxADGAEgUDIMCEwQjbQBGFAgBigBOgAKGQjnCxADGAIgUDIMCFYQmbQBGFAgBigBOgAKGQjfCxADGAMgUDIMCE4QyKQBGFAgBigFOgA=",
|
||||
"score": 1865,
|
||||
"won": true
|
||||
},
|
||||
"1": {
|
||||
"result": "EgcIlb8FEPIOMPIOQAFIAXpqChcI/woQAyBQMgwIRBCAtAEYUCAGKAU6AAoZCIULEAMYASBQMgwISRCJtAEYUCAGKAU6AAoZCIcLEAMYAiBQMgwISxCMtAEYUCAGKAU6AAoZCIELEAMYAyBQMgwIRhCCtAEYUCAGKAE6AA==",
|
||||
"score": 1906,
|
||||
"won": true
|
||||
},
|
||||
"2": {
|
||||
"result": "EgcIlb8FEKoOMKoOQAJIAXpqChcIgAsQAyBQMgwIRRCEtAEYUCAGKAU6AAoZCIQLEAMYASBQMgwISBCItAEYUCAGKAU6AAoZCKEKEAMYAiBQMgwIORD6swEYUCAGKAE6AAoZCIYLEAMYAyBQMgwIShCLtAEYUCAGKAE6AA==",
|
||||
"score": 1834,
|
||||
"won": true
|
||||
}
|
||||
},
|
||||
"teams": [
|
||||
[
|
||||
1506,
|
||||
1501,
|
||||
1511,
|
||||
1503
|
||||
],
|
||||
[
|
||||
1407,
|
||||
1413,
|
||||
1415,
|
||||
1409
|
||||
],
|
||||
[
|
||||
1408,
|
||||
1412,
|
||||
1313,
|
||||
1414
|
||||
]
|
||||
]
|
||||
},
|
||||
"5513": {
|
||||
"buffs": [
|
||||
0,
|
||||
|
||||
+152
-89
@@ -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,6 +713,7 @@ function _G.__FREECAM_TICK()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
set_cinemachine_enabled(cam, false)
|
||||
|
||||
if not _G.__FC_POS then
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user