From 8c1cf9be36b6f8c4863a8e3a9fc952d549ae4670 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:27:03 +0400 Subject: [PATCH 1/2] rnd(M02): extract third-person camera rig Work-Package: M02-RND-CAMERA-001 --- coordination/claims/M02-RND-CAMERA-001.md | 90 +++++++ docs/modules/README.md | 1 + docs/modules/local-player-movement.md | 7 +- docs/modules/player-input.md | 20 +- docs/modules/third-person-camera.md | 253 ++++++++++++++++++ src/scenes/player/camera_collision_policy.gd | 15 ++ .../player/camera_collision_policy.gd.uid | 1 + .../player/no_camera_collision_policy.gd | 12 + .../player/no_camera_collision_policy.gd.uid | 1 + src/scenes/player/third_person_camera_rig.gd | 169 ++++++++++++ .../player/third_person_camera_rig.gd.uid | 1 + .../player/third_person_wow_controller.gd | 67 +---- .../streaming/eastern_kingdoms_streaming.tscn | 2 + src/scenes/streaming/kalimdor_streaming.tscn | 2 + src/tests/scenes/player_input_regression.tscn | 4 +- src/tools/verify_player_input.gd | 5 +- src/tools/verify_third_person_camera_rig.gd | 174 ++++++++++++ 17 files changed, 753 insertions(+), 71 deletions(-) create mode 100644 coordination/claims/M02-RND-CAMERA-001.md create mode 100644 docs/modules/third-person-camera.md create mode 100644 src/scenes/player/camera_collision_policy.gd create mode 100644 src/scenes/player/camera_collision_policy.gd.uid create mode 100644 src/scenes/player/no_camera_collision_policy.gd create mode 100644 src/scenes/player/no_camera_collision_policy.gd.uid create mode 100644 src/scenes/player/third_person_camera_rig.gd create mode 100644 src/scenes/player/third_person_camera_rig.gd.uid create mode 100644 src/tools/verify_third_person_camera_rig.gd diff --git a/coordination/claims/M02-RND-CAMERA-001.md b/coordination/claims/M02-RND-CAMERA-001.md new file mode 100644 index 0000000..bad5a53 --- /dev/null +++ b/coordination/claims/M02-RND-CAMERA-001.md @@ -0,0 +1,90 @@ +# M02-RND-CAMERA-001 — Third-person camera rig boundary + + + +## Ownership + +- Target: M02 +- Program: RND/GMP +- Owner/Agent ID: sindo-main-codex +- Branch: `work/sindo-main-codex/m02-camera-rig` +- Lease expires UTC: 2026-07-16 +- Integrator: M02 milestone integrator + +## Outcome + +Move mouse capture, orbit yaw/pitch, zoom state and Camera3D transforms from +`ThirdPersonWowController` into a reusable `ThirdPersonCameraRig`, with an +explicit replaceable camera-collision policy that preserves the current +no-collision sandbox baseline. + +## Non-goals + +- Enable a new collision/raycast behavior before build-12340 comparison. +- Change initial yaw/pitch, zoom limits, sensitivity, FOV, far plane or camera path. +- Implement first-person, follow lag, shoulder offsets or cinematic cameras. +- Change movement, terrain query, character presentation or renderer streaming focus. +- Mark M02 complete or edit its checklist/Evidence. + +## Paths + +- Exclusive: `src/scenes/player/third_person_camera_rig.gd`, + `src/scenes/player/camera_collision_policy.gd`, + `src/scenes/player/no_camera_collision_policy.gd`, + `src/tools/verify_third_person_camera_rig.gd`, + `docs/modules/third-person-camera.md`, this claim +- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`, + runtime streaming scenes, player regression scene, + `src/tools/verify_player_input.gd`, `docs/modules/player-input.md`, + `docs/modules/local-player-movement.md`, `docs/modules/README.md` +- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus + +## Contracts and data + +- Public API: `ThirdPersonCameraRig.initialize_for_character`, + `handle_camera_input`, `godot_world_flight_movement_basis`, state getters and + `set_collision_policy` +- Policy: `CameraCollisionPolicy.resolve_camera_distance`; default + `NoCameraCollisionPolicy` returns requested distance unchanged +- Scene paths and renderer camera path remain unchanged +- Schema/cache/coordinate contracts: unchanged +- Consumers: player composition, sandbox flight movement and renderer viewport + +## Dependencies + +- Requires: merged TerrainQuery package on master `f5bb64e` +- Blocks: character appearance/animation extraction from remaining player hotspot +- External state: none; tests use asset-free camera/player scene + +## Verification + +- Commands: dedicated orbit/zoom/clamp/policy verifier, input/movement/terrain + regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run + and repository gates +- Fixtures: initial state, remappable action events, mouse deltas and fixed-distance policy +- Fidelity evidence: preserve current `-18°` pitch, `8` distance, `1` zoom step, + `2..18` clamp, `0.003` sensitivity and no-collision output +- Performance budget: constant-time state/transform update; no raycast in baseline policy + +## Documentation deliverables + +- Inline API/export documentation for rig and collision policies +- Camera module spec with inputs/outputs, data-flow, state and sequence diagrams +- Updated input/movement consumer diagrams/source maps and module registry + +## Simplicity and naming + +- Important names: `ThirdPersonCameraRig`, `CameraCollisionPolicy`, + `NoCameraCollisionPolicy` +- Simplest approach: one Node3D rig on existing CameraPivot and one explicit identity policy +- Rejected complexity: generic camera framework, spring arm migration and hidden physics raycast +- Unavoidable complexity: rig mutates Camera3D/character Node transforms on main thread +- Measured optimization evidence: not applicable + +## Status + +- State: active +- Done: claim and isolated worktree from updated master +- Next: implement rig/policy, migrate scenes/player, test and document +- Blocked by: + diff --git a/docs/modules/README.md b/docs/modules/README.md index d02b1ee..62440f4 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -12,6 +12,7 @@ | Player input | Partial | [`player-input.md`](player-input.md) | | Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) | | Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) | +| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index e31eebe..bef0f04 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Implemented | -| Target/work package | M02 / M02-GMP-MOVEMENT-001 | +| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-RND-CAMERA-001 basis producer update | | Owners | Gameplay local movement state; scene composition owns the instance | -| Last verified | `a45d521`, 2026-07-14 | +| Last verified | `working tree`, 2026-07-14 | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending | ## Purpose @@ -79,7 +79,7 @@ Constructor units: | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | |---|---|---|---|---|---| | Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick | -| Input | Godot-world `Basis` | Player scene adapter | Movement controller | Value copy | One physics tick | +| Input | Godot-world `Basis` | Player or `ThirdPersonCameraRig` adapter | Movement controller | Value copy | One physics tick | | Input | Delta seconds | Godot physics loop | Movement controller | Scalar value | One physics tick | | Input | Flight toggle command | Remappable sandbox action via scene adapter | Movement controller | Synchronous command | Input event dispatch | | Output | Displacement `Vector3` | Movement controller | Player scene adapter | Value copy | Applied in same tick | @@ -233,6 +233,7 @@ separate versioned movement snapshot contract. |---|---| | `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration | | `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary | +| `src/scenes/player/third_person_camera_rig.gd` | Camera-relative sandbox flight basis producer | | `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters | | `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression | | `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture | diff --git a/docs/modules/player-input.md b/docs/modules/player-input.md index fd69540..f2d8bdd 100644 --- a/docs/modules/player-input.md +++ b/docs/modules/player-input.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 consumer update | +| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 and M02-RND-CAMERA-001 consumer updates | | Owners | Gameplay input boundary; `sindo-main-codex` for current package | -| Last verified | `435e1c9`, 2026-07-14 | +| Last verified | `working tree`, 2026-07-14 | | Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified | ## Purpose @@ -33,7 +33,7 @@ flowchart LR Source --> Intent[MoveIntent] Intent --> Movement[LocalPlayerMovementController] Movement --> Controller[ThirdPersonWowController adapter] - Controller --> Camera[Current camera adapter] + Controller --> Camera[ThirdPersonCameraRig] ``` Allowed dependencies: @@ -63,15 +63,15 @@ Forbidden dependencies: | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | |---|---|---|---|---|---| | Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick | -| Input | Mouse action events | Godot `_unhandled_input` dispatch | Current scene controller | Engine-owned event | Current input dispatch only | +| Input | Mouse action events | Godot `_unhandled_input` dispatch | `ThirdPersonCameraRig` through player dispatch | Engine-owned event | Current input dispatch only | | Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick | -| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime | +| Output | Camera action names | `PlayerInputActions` | `ThirdPersonCameraRig` | Static constants | Process lifetime | Side effects: - `PlayerInputSource` has no side effects beyond reading current engine input. -- The existing controller still mutates player/camera transforms and mouse mode; - those responsibilities are intentionally not moved by this package. +- `ThirdPersonCameraRig` owns mouse mode, orbit state and camera transforms; + player remains only the event-dispatch composition boundary. - No filesystem, cache, network, database or renderer-resource mutation occurs. ## Data flow @@ -81,6 +81,7 @@ flowchart LR Project[project.godot action defaults] --> Map[InputMap] Remap[User or editor remapping] --> Map Map -->|movement strengths| Source[PlayerInputSource] + Map -->|camera events| Camera[ThirdPersonCameraRig] Source -->|clamp cancel normalize| Intent[MoveIntent] Intent --> Consumer[LocalPlayerMovementController] Consumer --> Adapter[ThirdPersonWowController] @@ -192,7 +193,7 @@ provide a settings migration once user settings are persisted. - The defaults preserve the pre-M02 sandbox behavior, not verified original-client semantics. - Sprint and free flight are still executable by the sandbox controller until a typed build-profile gate is introduced. -- Camera input is action-mapped but camera state/collision remains in the monolithic controller. +- Camera state is extracted; active collision remains an explicit planned policy. - There is no persisted user keybinding format or UI yet. ## Source map @@ -204,7 +205,8 @@ provide a settings migration once user settings are persisted. | `src/gameplay/input/player_input_actions.gd` | Stable project action names | | `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition | | `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer | -| `src/scenes/player/third_person_wow_controller.gd` | Composition and camera/input-event adapter | +| `src/scenes/player/third_person_camera_rig.gd` | Camera-action consumer and orbit/zoom state | +| `src/scenes/player/third_person_wow_controller.gd` | Composition and input-event dispatcher | | `src/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture | | `src/tools/verify_player_input.gd` | Headless contract/integration regression | diff --git a/docs/modules/third-person-camera.md b/docs/modules/third-person-camera.md new file mode 100644 index 0000000..6165168 --- /dev/null +++ b/docs/modules/third-person-camera.md @@ -0,0 +1,253 @@ +# Third-Person Camera + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M02 / M02-RND-CAMERA-001 | +| Owners | Camera presentation state and CameraPivot/Camera3D lifecycle | +| Last verified | `working tree`, 2026-07-14 | +| Profiles/capabilities | Current sandbox orbit/zoom; identity collision policy | + +## Purpose + +Own third-person mouse capture, orbit yaw/pitch, zoom state and Camera3D local +transforms on the existing `CameraPivot` node. Expose an explicit replaceable +camera-distance collision policy while preserving the current no-collision +sandbox baseline. + +## Non-goals + +- Implement an unverified physics raycast or claim build-12340 collision parity. +- Own player movement, terrain queries, streaming focus or character visuals. +- Implement first-person, follow lag, shoulder camera, cinematics or view modes. +- Change current sensitivity, pitch/zoom limits, FOV or far plane. + +## Context and boundaries + +```mermaid +flowchart LR + InputMap[Remappable camera actions] --> Player[ThirdPersonWowController dispatch] + Player --> Rig[ThirdPersonCameraRig] + Mouse[Mouse motion] --> Player + Rig --> Character[Character yaw transform] + Rig --> Pivot[CameraPivot local transform] + Rig --> Policy[CameraCollisionPolicy] + Policy --> Distance[Resolved distance] + Distance --> Camera[Camera3D local transform] + Rig --> FlightBasis[Godot-world pivot basis] + FlightBasis --> Movement[LocalPlayerMovementController adapter] +``` + +Allowed dependencies: + +- Existing `PlayerInputActions` names and Godot input events. +- Explicit character, pivot and Camera3D Node references on the main thread. +- Replaceable `CameraCollisionPolicy` selected by composition/tests. +- Movement adapter may read the rig's Godot-world basis during sandbox flight. + +Forbidden dependencies: + +- ADT/terrain parsing, movement velocity, gameplay state or animation ownership. +- Camera rig discovering renderer/network/server services. +- Default collision policy performing hidden raycasts or changing baseline distance. +- Collision policy mutating rig/camera/character nodes. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `ThirdPersonCameraRig` | `Node3D` component | Owns orbit/zoom state on CameraPivot | Main thread; scene-owned | Reports missing Camera3D | +| `initialize_for_character(character_body)` | Composition method | Attaches yaw target and captures initial character yaw | Main thread after scene ready | Null leaves yaw target absent | +| `handle_camera_input(event)` | Input adapter | Applies capture/release/zoom/orbit actions | Main input thread | Returns whether camera consumed state | +| `set_collision_policy(policy)` | Composition method | Replaces distance resolution policy | Main thread | Null rejected; old policy retained | +| `refresh_camera_transform()` | Presentation update | Applies pivot pose and resolved camera distance | Main/physics thread | Missing camera leaves state but no Camera3D mutation | +| `godot_world_flight_movement_basis()` | Read model | Returns pivot global basis for sandbox flight | Main thread | None | +| `CameraCollisionPolicy.resolve_camera_distance(pivot, requested)` | Policy boundary | Resolves camera distance in Godot units | Main thread in current implementation | Rig clamps output to `[0, requested]` | +| `NoCameraCollisionPolicy` | Identity policy | Preserves requested distance with no physics query | Any main-thread call | None | + +Read-only rig state: `yaw_radians`, `pitch_radians`, +`requested_distance_units`, `resolved_distance_units` and `is_mouse_captured`. + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Camera action `InputEvent` | Godot/player dispatch | Camera rig | Engine-owned event | One dispatch | +| Input | Mouse relative pixels | Godot input | Camera rig | Engine-owned event | One dispatch | +| Input | Character `Node3D` | Player composition | Camera rig | Scene-owned reference | Rig scene lifetime | +| Input | Collision policy | Composition/test | Camera rig | Rig-held RefCounted | Until replacement | +| Output | Character yaw | Camera rig | Player transform | Scene mutation | Orbit event | +| Output | Pivot pitch/height | Camera rig | CameraPivot | Scene mutation | Refresh | +| Output | Requested/resolved distance | Camera rig/policy | Camera3D | Rig-owned scalar/read model | Until next input/refresh | +| Output | Godot-world `Basis` | Camera rig | Flight movement adapter | Value copy | Physics tick | + +Side effects: + +- Mutates mouse mode, character yaw, CameraPivot local transform and Camera3D local transform. +- Marks Camera3D current and sets its far plane once at ready. +- Releases captured mouse on rig removal. +- Creates no files, caches, RIDs, workers, network messages or gameplay state. + +## Data flow + +```mermaid +flowchart LR + Action[Camera action or mouse delta] --> State[Yaw pitch requested distance capture] + State --> Clamp[Pitch/zoom clamps] + Clamp --> Pivot[Pivot height and pitch] + Clamp --> Request[Requested distance] + Request --> Policy[CameraCollisionPolicy] + Policy --> Safe[Clamp 0..requested] + Safe --> Camera[Camera3D Z distance] + State --> Character[Character yaw] +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> MouseVisible + MouseVisible --> MouseCaptured: camera rotate pressed + MouseCaptured --> MouseVisible: camera rotate released + MouseCaptured --> MouseVisible: release cursor + MouseCaptured --> MouseCaptured: mouse motion / orbit and clamp + MouseVisible --> MouseVisible: zoom and clamp + MouseCaptured --> [*]: rig removed / release mouse + MouseVisible --> [*]: rig removed +``` + +Zoom and collision distance are orthogonal to capture state. The requested zoom +persists while a policy may temporarily choose a shorter resolved distance. + +## Main sequence + +```mermaid +sequenceDiagram + participant Engine as Godot Input + participant Player as ThirdPersonWowController + participant Rig as ThirdPersonCameraRig + participant Policy as CameraCollisionPolicy + participant Camera as Camera3D + Engine->>Player: InputEvent + Player->>Rig: handle_camera_input(event) + Rig->>Rig: update capture/orbit/zoom state + Rig->>Policy: resolve_camera_distance(pivot, requested) + Policy-->>Rig: resolved distance + Rig->>Camera: apply local position/rotation + opt captured mouse motion + Rig->>Player: mutate character yaw transform + end +``` + +## Ownership, threading and resources + +- Parent player scene owns the CameraPivot rig and Camera3D child. +- Rig holds non-owning scene references to character and camera. +- Rig owns scalar camera state and the RefCounted collision policy. +- All input/state/scene mutations occur on the main thread. +- Policy output is a scalar; policies must not mutate supplied nodes. +- No background tasks, resources, RIDs or caches are created. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Camera3D missing | `_ready` lookup | State continues; transform application skipped | `ThirdPersonCameraRig` error with path | Restore child/path and recreate scene | +| Character missing | Initialization | Orbit state updates without character yaw mutation | Composition/test failure | Call initializer with player node | +| Null collision policy | Setter guard | Current policy retained | Rig error | Pass valid policy | +| Policy returns negative/too large | Rig clamp | Distance clamped to `[0, requested]` | Policy regression tests | Fix/replace policy | +| Rig removed while captured | `_exit_tree` | Mouse made visible | Lifecycle regression | Recreate rig normally | + +There is no asynchronous cancellation or persisted rollback. Recreating the rig +restores configured defaults. + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Initial pitch | `-18°` | Current sandbox | State-owned after ready | Initial orbit elevation | +| Pitch limits | `-65°..35°` | Current sandbox | Exported | Mouse clamp | +| Mouse sensitivity | `0.003` rad/pixel | Current sandbox | Exported | Orbit delta | +| Pivot height | `1.7` units | Current sandbox | Exported | Camera eye origin | +| Initial distance | `8` units | Current sandbox | Exported before ready | Requested zoom | +| Zoom limits/step | `2..18`, step `1` | Current sandbox | Exported | Requested distance clamp | +| Far plane | `50000` units | Renderer sandbox | Exported | Camera far plane | +| Collision policy | `NoCameraCollisionPolicy` | Current sandbox | Replaceable | Identity distance/no raycast | + +## Persistence, cache and migration + +Camera state and policy are transient. No SavedVariables/settings schema, cache +or migration is introduced. Scene paths remain +`ThirdPersonPlayer/CameraPivot/Camera3D`, so renderer diagnostics and capture +tools require no path migration. + +## Diagnostics and observability + +- Logs: missing camera/null policy emit explicit component errors. +- Read models: yaw, pitch, requested/resolved distance and capture state. +- Tests: dedicated verifier reports state/policy/scene counts. +- Metrics/correlation IDs: not applicable to constant-time local presentation. + +## Verification + +- State regression: initial yaw/pitch/distance/height/far plane, capture/release, + mouse orbit, character yaw, pitch clamps and zoom clamps. +- Policy regression: fixed-distance test policy changes Camera3D Z; identity + policy restores requested distance; baseline policy contains no physics query. +- Scene integration: Eastern Kingdoms, Kalimdor and asset-free regression scenes + attach the rig to the existing CameraPivot. +- Boundary: player no longer owns capture/yaw/pitch/distance or camera transform helper. +- Existing input/movement/terrain regressions and renderer dry-run remain required. +- Fidelity evidence: current numeric defaults and identity collision behavior + only; original-client orbit/collision comparison remains open. +- Performance: constant-time scalar/transform work once per physics tick and + input event; no default raycast. + +## Extension points + +- Add an opt-in raycast collision policy after reference fixtures define mask, + margin, recovery smoothing and first-person limit. +- Add follow/shoulder/cinematic rigs as separate components, not branches inside this rig. +- Persist user camera settings through a future versioned settings module. +- Input contexts may suppress camera actions before dispatch without changing rig state. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Independent third-person rig | Implemented | Three scene wirings and player boundary regression | Integrate target checklist after review | +| Existing orbit/zoom behavior | Implemented | Numeric state/transform regression | Compare against build 12340 | +| Camera-relative sandbox flight basis | Implemented | Basis and movement regressions | Restrict flight to sandbox profile | +| Replaceable collision policy | Implemented | Fixed and identity policy tests | Implement verified collision policy | +| Active camera collision | Planned | Identity policy intentionally preserves baseline | Capture original-client occluder fixtures first | +| First-person/follow policies | Planned | Outside current sandbox baseline | Later completeness work | + +## Known gaps and risks + +- Default collision policy performs no occluder avoidance, matching the previous sandbox. +- Exact WoW camera orbit direction, pitch limits, zoom curve, collision margin and + recovery smoothing are unverified. +- Exported initial values are captured at ready; runtime mutation may require an + explicit reconfiguration API later. +- Mouse mode remains process-global Godot state and must be coordinated with future UI contexts. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/scenes/player/third_person_camera_rig.gd` | CameraPivot state, input and transforms | +| `src/scenes/player/camera_collision_policy.gd` | Replaceable distance-policy boundary | +| `src/scenes/player/no_camera_collision_policy.gd` | Identity baseline policy | +| `src/scenes/player/third_person_wow_controller.gd` | Camera event dispatch and movement-basis consumer | +| `src/scenes/streaming/*_streaming.tscn` | Runtime rig composition | +| `src/tests/scenes/player_input_regression.tscn` | Asset-free rig/player composition | +| `src/tools/verify_third_person_camera_rig.gd` | State, policy, scene and boundary regression | + +## Related decisions and references + +- ADR: none; current behavior and scene paths are preserved. +- Upstream/reference: `targets/02-player-decomposition.md`, + `targets/roadmap/02-rendering-and-graphics.md`, + `targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`. diff --git a/src/scenes/player/camera_collision_policy.gd b/src/scenes/player/camera_collision_policy.gd new file mode 100644 index 0000000..164705d --- /dev/null +++ b/src/scenes/player/camera_collision_policy.gd @@ -0,0 +1,15 @@ +class_name CameraCollisionPolicy +extends RefCounted + +## Replaceable third-person camera-distance policy. +## Implementations may inspect the explicit pivot/world boundary but must return +## a distance in Godot units without mutating the rig or camera nodes. + + +## Resolves the camera distance for the current pivot and requested zoom. +## The base policy preserves the request unchanged. +func resolve_camera_distance( + _camera_pivot: Node3D, + requested_distance_units: float +) -> float: + return requested_distance_units diff --git a/src/scenes/player/camera_collision_policy.gd.uid b/src/scenes/player/camera_collision_policy.gd.uid new file mode 100644 index 0000000..c1fb246 --- /dev/null +++ b/src/scenes/player/camera_collision_policy.gd.uid @@ -0,0 +1 @@ +uid://bgd41bb3hvbl1 diff --git a/src/scenes/player/no_camera_collision_policy.gd b/src/scenes/player/no_camera_collision_policy.gd new file mode 100644 index 0000000..ebe6d7f --- /dev/null +++ b/src/scenes/player/no_camera_collision_policy.gd @@ -0,0 +1,12 @@ +class_name NoCameraCollisionPolicy +extends CameraCollisionPolicy + +## Identity collision policy preserving the pre-M02 sandbox camera behavior. + + +## Returns the requested distance without physics queries or allocations. +func resolve_camera_distance( + _camera_pivot: Node3D, + requested_distance_units: float +) -> float: + return requested_distance_units diff --git a/src/scenes/player/no_camera_collision_policy.gd.uid b/src/scenes/player/no_camera_collision_policy.gd.uid new file mode 100644 index 0000000..e52cf23 --- /dev/null +++ b/src/scenes/player/no_camera_collision_policy.gd.uid @@ -0,0 +1 @@ +uid://csts45n5rnxro diff --git a/src/scenes/player/third_person_camera_rig.gd b/src/scenes/player/third_person_camera_rig.gd new file mode 100644 index 0000000..b6b3943 --- /dev/null +++ b/src/scenes/player/third_person_camera_rig.gd @@ -0,0 +1,169 @@ +class_name ThirdPersonCameraRig +extends Node3D + +## Third-person orbit/zoom state and Camera3D presentation adapter. +## The rig owns camera input state and local transforms. Character movement, +## terrain, rendering and collision-policy decisions remain external boundaries. + +const NO_CAMERA_COLLISION_POLICY_SCRIPT := preload("res://src/scenes/player/no_camera_collision_policy.gd") + +## Relative Camera3D child used by this rig. +@export var camera_path: NodePath = NodePath("Camera3D") +## Mouse radians applied per input pixel. +@export var mouse_sensitivity_radians_per_pixel: float = 0.003 +## Minimum orbit pitch in radians. +@export var minimum_pitch_radians: float = deg_to_rad(-65.0) +## Maximum orbit pitch in radians. +@export var maximum_pitch_radians: float = deg_to_rad(35.0) +## Pivot height above the character origin in Godot units. +@export var pivot_height_units: float = 1.7 +## Initial requested camera distance in Godot units. +@export var initial_distance_units: float = 8.0 +## Minimum user-requested zoom distance in Godot units. +@export var minimum_distance_units: float = 2.0 +## Maximum user-requested zoom distance in Godot units. +@export var maximum_distance_units: float = 18.0 +## Distance change per zoom action in Godot units. +@export var zoom_step_units: float = 1.0 +## Camera far plane retained from the renderer sandbox baseline. +@export var camera_far_plane_units: float = 50000.0 + +var yaw_radians: float: + get: + return _yaw_radians + +var pitch_radians: float: + get: + return _pitch_radians + +var requested_distance_units: float: + get: + return _requested_distance_units + +var resolved_distance_units: float: + get: + return _resolved_distance_units + +var is_mouse_captured: bool: + get: + return _is_mouse_captured + +var _character_body: Node3D +var _camera: Camera3D +var _collision_policy: CameraCollisionPolicy +var _yaw_radians := 0.0 +var _pitch_radians := deg_to_rad(-18.0) +var _requested_distance_units := 8.0 +var _resolved_distance_units := 8.0 +var _is_mouse_captured := false + + +func _ready() -> void: + _camera = get_node_or_null(camera_path) as Camera3D + if _camera == null: + push_error("ThirdPersonCameraRig: Camera3D not found at %s" % camera_path) + if _collision_policy == null: + _collision_policy = NO_CAMERA_COLLISION_POLICY_SCRIPT.new() + _pitch_radians = clampf(_pitch_radians, minimum_pitch_radians, maximum_pitch_radians) + _requested_distance_units = clampf( + initial_distance_units, + minimum_distance_units, + maximum_distance_units + ) + if _camera != null: + _camera.current = true + _camera.far = camera_far_plane_units + refresh_camera_transform() + + +func _physics_process(_delta_seconds: float) -> void: + refresh_camera_transform() + + +func _exit_tree() -> void: + if _is_mouse_captured: + _is_mouse_captured = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + +## Attaches the character transform rotated by orbit input and captures its initial yaw. +func initialize_for_character(character_body: Node3D) -> void: + _character_body = character_body + if _character_body != null: + _yaw_radians = _character_body.rotation.y + refresh_camera_transform() + + +## Replaces camera-distance collision behavior and immediately refreshes the camera. +## Passing null is rejected and preserves the current policy. +func set_collision_policy(collision_policy: CameraCollisionPolicy) -> void: + if collision_policy == null: + push_error("ThirdPersonCameraRig: collision policy cannot be null") + return + _collision_policy = collision_policy + refresh_camera_transform() + + +## Applies remappable camera actions and orbit mouse motion. +## Returns true when the event changed or consumed camera state. +func handle_camera_input(event: InputEvent) -> bool: + if event.is_action_pressed(PlayerInputActions.CAMERA_ROTATE): + _is_mouse_captured = true + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + return true + if event.is_action_released(PlayerInputActions.CAMERA_ROTATE): + _is_mouse_captured = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + return true + if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_IN): + _requested_distance_units = clampf( + _requested_distance_units - zoom_step_units, + minimum_distance_units, + maximum_distance_units + ) + refresh_camera_transform() + return true + if event.is_action_pressed(PlayerInputActions.CAMERA_ZOOM_OUT): + _requested_distance_units = clampf( + _requested_distance_units + zoom_step_units, + minimum_distance_units, + maximum_distance_units + ) + refresh_camera_transform() + return true + if event.is_action_pressed(PlayerInputActions.RELEASE_CURSOR): + _is_mouse_captured = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + return true + if _is_mouse_captured and event is InputEventMouseMotion: + _yaw_radians -= event.relative.x * mouse_sensitivity_radians_per_pixel + _pitch_radians = clampf( + _pitch_radians - event.relative.y * mouse_sensitivity_radians_per_pixel, + minimum_pitch_radians, + maximum_pitch_radians + ) + if _character_body != null: + _character_body.rotation.y = _yaw_radians + refresh_camera_transform() + return true + return false + + +## Returns the pivot's Godot-world basis for camera-relative sandbox flight. +func godot_world_flight_movement_basis() -> Basis: + return global_basis + + +## Applies pivot and Camera3D local transforms using the active collision policy. +func refresh_camera_transform() -> void: + position = Vector3(0.0, pivot_height_units, 0.0) + rotation.x = _pitch_radians + if _camera == null: + return + var policy_distance := _collision_policy.resolve_camera_distance( + self, + _requested_distance_units + ) if _collision_policy != null else _requested_distance_units + _resolved_distance_units = clampf(policy_distance, 0.0, _requested_distance_units) + _camera.position = Vector3(0.0, 0.0, _resolved_distance_units) + _camera.rotation = Vector3.ZERO diff --git a/src/scenes/player/third_person_camera_rig.gd.uid b/src/scenes/player/third_person_camera_rig.gd.uid new file mode 100644 index 0000000..f90c485 --- /dev/null +++ b/src/scenes/player/third_person_camera_rig.gd.uid @@ -0,0 +1 @@ +uid://cq60al4uw0mgo diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index 3b4591e..bd69455 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -24,19 +24,10 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai @export var sprint_multiplier: float = 6.0 @export var flight_vertical_speed: float = 7.0 @export var acceleration: float = 28.0 -@export var mouse_sensitivity: float = 0.003 -@export var camera_pitch_min: float = deg_to_rad(-65.0) -@export var camera_pitch_max: float = deg_to_rad(35.0) -@export var camera_height: float = 1.7 -@export var camera_distance: float = 8.0 -@export var camera_min_distance: float = 2.0 -@export var camera_max_distance: float = 18.0 -@export var camera_zoom_step: float = 1.0 @export var ground_offset: float = 0.05 @export var ground_snap_speed: float = 24.0 @export var camera_pivot_path: NodePath = NodePath("CameraPivot") -@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D") @export var visual_path: NodePath = NodePath("Visual") @export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb" @export var character_class_id: int = 1 @@ -44,15 +35,11 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai @export var character_yaw_offset_degrees: float = 90.0 @export var animation_blend_time: float = 0.15 -var _camera_pivot: Node3D -var _camera: Camera3D +var _camera_rig: ThirdPersonCameraRig var _visual: Node3D var _character_root: Node3D var _animation_player: AnimationPlayer var _active_animation := "" -var _captured := false -var _yaw := 0.0 -var _pitch := deg_to_rad(-18.0) var _player_input_source: PlayerInputSource var _local_movement_controller: LocalPlayerMovementController var _terrain_query: TerrainQuery @@ -79,12 +66,12 @@ func _ready() -> void: acceleration, sprint_multiplier ) - _camera_pivot = get_node_or_null(camera_pivot_path) as Node3D - _camera = get_node_or_null(camera_path) as Camera3D + _camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig _visual = get_node_or_null(visual_path) as Node3D - if _camera: - _camera.current = true - _camera.far = 50000.0 + if _camera_rig == null: + push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path) + else: + _camera_rig.initialize_for_character(self) if spawn_at_tile_center: var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y) var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5 @@ -95,41 +82,20 @@ func _ready() -> void: var spawn_ground_sample := _sample_ground_height(global_position) if spawn_ground_sample.is_available: global_position.y = spawn_ground_sample.height_units + ground_offset - _yaw = rotation.y - _apply_camera_transform() func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): - _captured = true - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED - elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): - _captured = false - Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN): - camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance) - _apply_camera_transform() - elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT): - camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance) - _apply_camera_transform() - elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR): - _captured = false - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE - elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo(): + if _camera_rig != null: + _camera_rig.handle_camera_input(event) + if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo(): _local_movement_controller.toggle_sandbox_flight() - if _captured and event is InputEventMouseMotion: - _yaw -= event.relative.x * mouse_sensitivity - _pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max) - rotation.y = _yaw - _apply_camera_transform() - func _physics_process(delta: float) -> void: var move_intent := _player_input_source.sample_move_intent() var godot_world_movement_basis := global_basis - if _local_movement_controller.is_flight_enabled and _camera_pivot: - godot_world_movement_basis = _camera_pivot.global_basis + if _local_movement_controller.is_flight_enabled and _camera_rig != null: + godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis() global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta) if not _local_movement_controller.is_flight_enabled: @@ -145,8 +111,6 @@ func _physics_process(delta: float) -> void: _update_character_animation(horizontal_motion.length_squared() > 0.04) - _apply_camera_transform() - func _load_character_visual() -> void: if character_model_path.is_empty() or _visual == null: return @@ -298,15 +262,6 @@ func _character_textures_dir(model_path: String) -> String: return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures") -func _apply_camera_transform() -> void: - if _camera_pivot: - _camera_pivot.position = Vector3(0.0, camera_height, 0.0) - _camera_pivot.rotation.x = _pitch - if _camera: - _camera.position = Vector3(0.0, 0.0, camera_distance) - _camera.rotation = Vector3.ZERO - - func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample: return _terrain_query.sample_ground_height( GODOT_WORLD_POSITION_SCRIPT.new( diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 814b3df..9411095 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -3,6 +3,7 @@ [ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"] [ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"] [ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"] +[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] radius = 0.45 @@ -107,6 +108,7 @@ mesh = SubResource("PlayerCapsuleMesh_1") [node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) +script = ExtResource("4_camera_rig") [node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index 2480e73..bc44f99 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -3,6 +3,7 @@ [ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_sisqv"] [ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"] [ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"] +[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"] [sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] radius = 0.45 @@ -108,6 +109,7 @@ mesh = SubResource("PlayerCapsuleMesh_1") [node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) +script = ExtResource("4_camera_rig") [node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) diff --git a/src/tests/scenes/player_input_regression.tscn b/src/tests/scenes/player_input_regression.tscn index 19474f1..c942201 100644 --- a/src/tests/scenes/player_input_regression.tscn +++ b/src/tests/scenes/player_input_regression.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=3 format=3] [ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"] +[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="2_camera_rig"] [node name="PlayerInputRegression" type="CharacterBody3D"] script = ExtResource("1_player") @@ -10,5 +11,6 @@ character_model_path = "" [node name="Visual" type="Node3D" parent="."] [node name="CameraPivot" type="Node3D" parent="."] +script = ExtResource("2_camera_rig") [node name="Camera3D" type="Camera3D" parent="CameraPivot"] diff --git a/src/tools/verify_player_input.gd b/src/tools/verify_player_input.gd index f3098d5..6a09714 100644 --- a/src/tools/verify_player_input.gd +++ b/src/tools/verify_player_input.gd @@ -102,12 +102,13 @@ func _verify_regression_scene(failures: Array[String]) -> void: Input.action_release(PlayerInputActions.MOVE_FORWARD) _expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures) - var camera_distance_before: float = player.get("camera_distance") + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + var camera_distance_before := camera_rig.requested_distance_units var zoom_event := InputEventMouseButton.new() zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP zoom_event.pressed = true player.call("_unhandled_input", zoom_event) - _expect_near(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures) + _expect_near(camera_rig.requested_distance_units, camera_distance_before - 1.0, "zoom action updates camera distance", failures) var flight_toggle_event := InputEventAction.new() flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT diff --git a/src/tools/verify_third_person_camera_rig.gd b/src/tools/verify_third_person_camera_rig.gd new file mode 100644 index 0000000..71df603 --- /dev/null +++ b/src/tools/verify_third_person_camera_rig.gd @@ -0,0 +1,174 @@ +extends SceneTree + +## Headless M02 camera state, scene wiring and collision-policy regression. + +const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd" +const CAMERA_RIG_PATH := "res://src/scenes/player/third_person_camera_rig.gd" +const NO_COLLISION_POLICY_PATH := "res://src/scenes/player/no_camera_collision_policy.gd" +const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn" +const RUNTIME_SCENE_PATHS: Array[String] = [ + "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", + "res://src/scenes/streaming/kalimdor_streaming.tscn", +] + + +class FixedDistanceCameraCollisionPolicy extends CameraCollisionPolicy: + var _fixed_distance_units: float + + func _init(fixed_distance_units: float) -> void: + _fixed_distance_units = fixed_distance_units + + func resolve_camera_distance( + _camera_pivot: Node3D, + _requested_distance_units: float + ) -> float: + return _fixed_distance_units + + +func _initialize() -> void: + _run_verification.call_deferred() + + +func _run_verification() -> void: + var failures: Array[String] = [] + var player := _instantiate_regression_player(failures) + if player != null: + _verify_initial_state(player, failures) + _verify_zoom_clamps(player, failures) + _verify_orbit_and_capture(player, failures) + _verify_collision_policy(player, failures) + player.free() + _verify_source_boundaries(failures) + _verify_scene_wiring(failures) + + if not failures.is_empty(): + for failure in failures: + push_error("THIRD_PERSON_CAMERA: %s" % failure) + quit(1) + return + + print("THIRD_PERSON_CAMERA PASS state_cases=12 policy_cases=3 scenes=3 player_boundary=1") + quit(0) + + +func _instantiate_regression_player(failures: Array[String]) -> CharacterBody3D: + var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene + _expect_true(packed_scene != null, "regression scene loads", failures) + if packed_scene == null: + return null + var player := packed_scene.instantiate() as CharacterBody3D + _expect_true(player != null, "regression player instantiates", failures) + if player == null: + return null + root.add_child(player) + player.set_physics_process(false) + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + camera_rig.set_physics_process(false) + return player + + +func _verify_initial_state(player: CharacterBody3D, failures: Array[String]) -> void: + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + var camera := camera_rig.get_node("Camera3D") as Camera3D + _expect_near(camera_rig.yaw_radians, 0.0, "initial yaw", failures) + _expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0), "initial pitch", failures) + _expect_near(camera_rig.requested_distance_units, 8.0, "initial requested distance", failures) + _expect_near(camera_rig.resolved_distance_units, 8.0, "initial resolved distance", failures) + _expect_near(camera_rig.position.y, 1.7, "initial pivot height", failures) + _expect_near(camera.position.z, 8.0, "initial camera local Z", failures) + _expect_near(camera.far, 50000.0, "camera far plane", failures) + _expect_true(camera_rig.godot_world_flight_movement_basis().is_equal_approx(camera_rig.global_basis), "flight basis is pivot basis", failures) + + +func _verify_zoom_clamps(player: CharacterBody3D, failures: Array[String]) -> void: + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + var zoom_in_event := _action_event(PlayerInputActions.CAMERA_ZOOM_IN, true) + for _step in range(20): + camera_rig.handle_camera_input(zoom_in_event) + _expect_near(camera_rig.requested_distance_units, 2.0, "minimum zoom clamp", failures) + for _step in range(30): + camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ZOOM_OUT, true)) + _expect_near(camera_rig.requested_distance_units, 18.0, "maximum zoom clamp", failures) + + +func _verify_orbit_and_capture(player: CharacterBody3D, failures: Array[String]) -> void: + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + _expect_true(camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, true)), "capture action consumed", failures) + _expect_true(camera_rig.is_mouse_captured, "mouse capture state", failures) + + var mouse_motion := InputEventMouseMotion.new() + mouse_motion.relative = Vector2(100.0, -50.0) + _expect_true(camera_rig.handle_camera_input(mouse_motion), "captured mouse motion consumed", failures) + _expect_near(camera_rig.yaw_radians, -0.3, "orbit yaw", failures) + _expect_near(camera_rig.pitch_radians, deg_to_rad(-18.0) + 0.15, "orbit pitch", failures) + _expect_near(player.rotation.y, -0.3, "character follows orbit yaw", failures) + + var clamp_up_motion := InputEventMouseMotion.new() + clamp_up_motion.relative = Vector2(0.0, -100000.0) + camera_rig.handle_camera_input(clamp_up_motion) + _expect_near(camera_rig.pitch_radians, deg_to_rad(35.0), "maximum pitch clamp", failures) + var clamp_down_motion := InputEventMouseMotion.new() + clamp_down_motion.relative = Vector2(0.0, 100000.0) + camera_rig.handle_camera_input(clamp_down_motion) + _expect_near(camera_rig.pitch_radians, deg_to_rad(-65.0), "minimum pitch clamp", failures) + + camera_rig.handle_camera_input(_action_event(PlayerInputActions.CAMERA_ROTATE, false)) + _expect_true(not camera_rig.is_mouse_captured, "mouse release state", failures) + + +func _verify_collision_policy(player: CharacterBody3D, failures: Array[String]) -> void: + var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig + var camera := camera_rig.get_node("Camera3D") as Camera3D + camera_rig.set_collision_policy(FixedDistanceCameraCollisionPolicy.new(3.0)) + _expect_near(camera_rig.resolved_distance_units, 3.0, "fixed policy distance", failures) + _expect_near(camera.position.z, 3.0, "fixed policy camera transform", failures) + camera_rig.set_collision_policy(NoCameraCollisionPolicy.new()) + _expect_near(camera_rig.resolved_distance_units, 18.0, "identity policy restores request", failures) + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures) + for forbidden_text in ["var _captured", "var _yaw", "var _pitch", "func _apply_camera_transform", "camera_distance"]: + _expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures) + _expect_true(player_source.contains("_camera_rig.handle_camera_input"), "player delegates camera input", failures) + + var rig_source := _read_text(CAMERA_RIG_PATH, failures) + _expect_true(rig_source.contains("extends Node3D"), "camera rig owns pivot Node3D", failures) + for forbidden_text in ["ADTLoader", "TerrainQuery", "MoveIntent", "AnimationPlayer"]: + _expect_true(not rig_source.contains(forbidden_text), "camera rig omits %s" % forbidden_text, failures) + + var no_collision_source := _read_text(NO_COLLISION_POLICY_PATH, failures) + for forbidden_text in ["intersect_ray", "direct_space_state", "PhysicsRayQueryParameters3D"]: + _expect_true(not no_collision_source.contains(forbidden_text), "baseline policy omits %s" % forbidden_text, failures) + + +func _verify_scene_wiring(failures: Array[String]) -> void: + for scene_path in RUNTIME_SCENE_PATHS + [REGRESSION_SCENE_PATH]: + var scene_source := _read_text(scene_path, failures) + _expect_true(scene_source.contains("third_person_camera_rig.gd"), "%s references camera rig" % scene_path, failures) + _expect_true(scene_source.contains('script = ExtResource("4_camera_rig")') or scene_source.contains('script = ExtResource("2_camera_rig")'), "%s wires CameraPivot script" % scene_path, failures) + + +func _action_event(action_name: StringName, is_pressed: bool) -> InputEventAction: + var input_event := InputEventAction.new() + input_event.action = action_name + input_event.pressed = is_pressed + return input_event + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void: + if absf(actual_value - expected_value) > 0.000001: + failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) From 02dec23c2ab13fa46d87b7c0e8fa655a86b31d59 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:27:41 +0400 Subject: [PATCH 2/2] docs(M02): publish camera rig handoff Work-Package: M02-RND-CAMERA-001 --- coordination/claims/M02-RND-CAMERA-001.md | 34 +++++++++++++++++++++-- docs/modules/local-player-movement.md | 2 +- docs/modules/player-input.md | 2 +- docs/modules/third-person-camera.md | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/coordination/claims/M02-RND-CAMERA-001.md b/coordination/claims/M02-RND-CAMERA-001.md index bad5a53..2ddf3f5 100644 --- a/coordination/claims/M02-RND-CAMERA-001.md +++ b/coordination/claims/M02-RND-CAMERA-001.md @@ -83,8 +83,36 @@ no-collision sandbox baseline. ## Status -- State: active -- Done: claim and isolated worktree from updated master -- Next: implement rig/policy, migrate scenes/player, test and document +- State: ready-for-review +- Done: extracted rig and collision policy, migrated all three scene compositions, + added regressions and completed required module documentation +- Next: milestone integrator reviews and merges `8c1cf9b` - Blocked by: + + +## Handoff + +- Outcome: `ThirdPersonCameraRig` now owns capture, orbit, zoom and camera + transforms; `ThirdPersonWowController` only dispatches camera events and + consumes the rig basis for sandbox flight. +- Public contracts: `initialize_for_character`, `handle_camera_input`, + `godot_world_flight_movement_basis`, `set_collision_policy`, read-only state, + and `CameraCollisionPolicy.resolve_camera_distance`. +- Verification: camera `state_cases=12 policy_cases=3 scenes=3`, input + `actions=12 intent_cases=6`, movement `cases=12 state_transitions=2`, terrain + `contract=4 interpolation=1 cache=1 failures=2`, coordinate boundary + `files=98 consumers=5`; documentation, coordination and diff checks passed. +- Renderer dry-run: seven checkpoint plans and report completed; expected + diagnostics only for unavailable proprietary corpus/character GLB. +- Fidelity: preserves the sandbox defaults (`-18 degrees`, distance `8`, zoom + `2..18` step `1`, sensitivity `0.003`, pivot `1.7`, far `50000`) and the prior + no-collision behavior. No build-12340 parity claim is made. +- Local ignored inputs: native extension DLL and generated ADT resource scripts + were used for the renderer dry-run and are not part of the commit. +- Migration: none; `ThirdPersonPlayer/CameraPivot/Camera3D` remains stable. +- Risks: active collision and exact original-client camera semantics remain + unverified; exported initial values are captured at ready; mouse mode is + process-global. +- Documentation: added the camera API/module spec plus data-flow, state and + sequence diagrams; updated player-input and local-movement consumer maps. diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index bef0f04..5cc4199 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -7,7 +7,7 @@ | Status | Implemented | | Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-RND-CAMERA-001 basis producer update | | Owners | Gameplay local movement state; scene composition owns the instance | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `8c1cf9b`, 2026-07-14 | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending | ## Purpose diff --git a/docs/modules/player-input.md b/docs/modules/player-input.md index f2d8bdd..d5a886b 100644 --- a/docs/modules/player-input.md +++ b/docs/modules/player-input.md @@ -7,7 +7,7 @@ | Status | Partial | | Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 and M02-RND-CAMERA-001 consumer updates | | Owners | Gameplay input boundary; `sindo-main-codex` for current package | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `8c1cf9b`, 2026-07-14 | | Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified | ## Purpose diff --git a/docs/modules/third-person-camera.md b/docs/modules/third-person-camera.md index 6165168..874436f 100644 --- a/docs/modules/third-person-camera.md +++ b/docs/modules/third-person-camera.md @@ -7,7 +7,7 @@ | Status | Implemented | | Target/work package | M02 / M02-RND-CAMERA-001 | | Owners | Camera presentation state and CameraPivot/Camera3D lifecycle | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `8c1cf9b`, 2026-07-14 | | Profiles/capabilities | Current sandbox orbit/zoom; identity collision policy | ## Purpose