rnd(M02): extract third-person camera rig
Work-Package: M02-RND-CAMERA-001
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
# M02-RND-CAMERA-001 — Third-person camera rig boundary
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M02-RND-CAMERA-001:sindo-main-codex:2026-07-16 -->
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
| Player input | Partial | [`player-input.md`](player-input.md) |
|
| Player input | Partial | [`player-input.md`](player-input.md) |
|
||||||
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
||||||
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.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) |
|
| 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) |
|
| 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) |
|
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status | Implemented |
|
| 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 |
|
| 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 |
|
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -79,7 +79,7 @@ Constructor units:
|
|||||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
|
| 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 | 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 |
|
| 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 |
|
| 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/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/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/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/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 |
|
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status | Partial |
|
| 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 |
|
| 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 |
|
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -33,7 +33,7 @@ flowchart LR
|
|||||||
Source --> Intent[MoveIntent]
|
Source --> Intent[MoveIntent]
|
||||||
Intent --> Movement[LocalPlayerMovementController]
|
Intent --> Movement[LocalPlayerMovementController]
|
||||||
Movement --> Controller[ThirdPersonWowController adapter]
|
Movement --> Controller[ThirdPersonWowController adapter]
|
||||||
Controller --> Camera[Current camera adapter]
|
Controller --> Camera[ThirdPersonCameraRig]
|
||||||
```
|
```
|
||||||
|
|
||||||
Allowed dependencies:
|
Allowed dependencies:
|
||||||
@@ -63,15 +63,15 @@ Forbidden dependencies:
|
|||||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
| 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 | 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 | `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:
|
Side effects:
|
||||||
|
|
||||||
- `PlayerInputSource` has no side effects beyond reading current engine input.
|
- `PlayerInputSource` has no side effects beyond reading current engine input.
|
||||||
- The existing controller still mutates player/camera transforms and mouse mode;
|
- `ThirdPersonCameraRig` owns mouse mode, orbit state and camera transforms;
|
||||||
those responsibilities are intentionally not moved by this package.
|
player remains only the event-dispatch composition boundary.
|
||||||
- No filesystem, cache, network, database or renderer-resource mutation occurs.
|
- No filesystem, cache, network, database or renderer-resource mutation occurs.
|
||||||
|
|
||||||
## Data flow
|
## Data flow
|
||||||
@@ -81,6 +81,7 @@ flowchart LR
|
|||||||
Project[project.godot action defaults] --> Map[InputMap]
|
Project[project.godot action defaults] --> Map[InputMap]
|
||||||
Remap[User or editor remapping] --> Map
|
Remap[User or editor remapping] --> Map
|
||||||
Map -->|movement strengths| Source[PlayerInputSource]
|
Map -->|movement strengths| Source[PlayerInputSource]
|
||||||
|
Map -->|camera events| Camera[ThirdPersonCameraRig]
|
||||||
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
||||||
Intent --> Consumer[LocalPlayerMovementController]
|
Intent --> Consumer[LocalPlayerMovementController]
|
||||||
Consumer --> Adapter[ThirdPersonWowController]
|
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.
|
- 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
|
- Sprint and free flight are still executable by the sandbox controller until a
|
||||||
typed build-profile gate is introduced.
|
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.
|
- There is no persisted user keybinding format or UI yet.
|
||||||
|
|
||||||
## Source map
|
## 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_actions.gd` | Stable project action names |
|
||||||
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
|
| `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/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/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture |
|
||||||
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgd41bb3hvbl1
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://csts45n5rnxro
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cq60al4uw0mgo
|
||||||
@@ -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 sprint_multiplier: float = 6.0
|
||||||
@export var flight_vertical_speed: float = 7.0
|
@export var flight_vertical_speed: float = 7.0
|
||||||
@export var acceleration: float = 28.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_offset: float = 0.05
|
||||||
@export var ground_snap_speed: float = 24.0
|
@export var ground_snap_speed: float = 24.0
|
||||||
|
|
||||||
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
|
@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 visual_path: NodePath = NodePath("Visual")
|
||||||
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
||||||
@export var character_class_id: int = 1
|
@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 character_yaw_offset_degrees: float = 90.0
|
||||||
@export var animation_blend_time: float = 0.15
|
@export var animation_blend_time: float = 0.15
|
||||||
|
|
||||||
var _camera_pivot: Node3D
|
var _camera_rig: ThirdPersonCameraRig
|
||||||
var _camera: Camera3D
|
|
||||||
var _visual: Node3D
|
var _visual: Node3D
|
||||||
var _character_root: Node3D
|
var _character_root: Node3D
|
||||||
var _animation_player: AnimationPlayer
|
var _animation_player: AnimationPlayer
|
||||||
var _active_animation := ""
|
var _active_animation := ""
|
||||||
var _captured := false
|
|
||||||
var _yaw := 0.0
|
|
||||||
var _pitch := deg_to_rad(-18.0)
|
|
||||||
var _player_input_source: PlayerInputSource
|
var _player_input_source: PlayerInputSource
|
||||||
var _local_movement_controller: LocalPlayerMovementController
|
var _local_movement_controller: LocalPlayerMovementController
|
||||||
var _terrain_query: TerrainQuery
|
var _terrain_query: TerrainQuery
|
||||||
@@ -79,12 +66,12 @@ func _ready() -> void:
|
|||||||
acceleration,
|
acceleration,
|
||||||
sprint_multiplier
|
sprint_multiplier
|
||||||
)
|
)
|
||||||
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
|
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
|
||||||
_camera = get_node_or_null(camera_path) as Camera3D
|
|
||||||
_visual = get_node_or_null(visual_path) as Node3D
|
_visual = get_node_or_null(visual_path) as Node3D
|
||||||
if _camera:
|
if _camera_rig == null:
|
||||||
_camera.current = true
|
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
|
||||||
_camera.far = 50000.0
|
else:
|
||||||
|
_camera_rig.initialize_for_character(self)
|
||||||
if spawn_at_tile_center:
|
if spawn_at_tile_center:
|
||||||
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
|
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
|
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)
|
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||||
if spawn_ground_sample.is_available:
|
if spawn_ground_sample.is_available:
|
||||||
global_position.y = spawn_ground_sample.height_units + ground_offset
|
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||||
_yaw = rotation.y
|
|
||||||
_apply_camera_transform()
|
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
|
if _camera_rig != null:
|
||||||
_captured = true
|
_camera_rig.handle_camera_input(event)
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||||
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():
|
|
||||||
_local_movement_controller.toggle_sandbox_flight()
|
_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:
|
func _physics_process(delta: float) -> void:
|
||||||
var move_intent := _player_input_source.sample_move_intent()
|
var move_intent := _player_input_source.sample_move_intent()
|
||||||
var godot_world_movement_basis := global_basis
|
var godot_world_movement_basis := global_basis
|
||||||
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
if _local_movement_controller.is_flight_enabled and _camera_rig != null:
|
||||||
godot_world_movement_basis = _camera_pivot.global_basis
|
godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
|
||||||
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
||||||
|
|
||||||
if not _local_movement_controller.is_flight_enabled:
|
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)
|
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||||
|
|
||||||
_apply_camera_transform()
|
|
||||||
|
|
||||||
func _load_character_visual() -> void:
|
func _load_character_visual() -> void:
|
||||||
if character_model_path.is_empty() or _visual == null:
|
if character_model_path.is_empty() or _visual == null:
|
||||||
return
|
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")
|
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:
|
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||||
return _terrain_query.sample_ground_height(
|
return _terrain_query.sample_ground_height(
|
||||||
GODOT_WORLD_POSITION_SCRIPT.new(
|
GODOT_WORLD_POSITION_SCRIPT.new(
|
||||||
|
|||||||
@@ -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://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://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" 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"]
|
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||||
radius = 0.45
|
radius = 0.45
|
||||||
@@ -107,6 +108,7 @@ mesh = SubResource("PlayerCapsuleMesh_1")
|
|||||||
|
|
||||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
[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)
|
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]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||||
|
|||||||
@@ -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://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://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" 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"]
|
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||||
radius = 0.45
|
radius = 0.45
|
||||||
@@ -108,6 +109,7 @@ mesh = SubResource("PlayerCapsuleMesh_1")
|
|||||||
|
|
||||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
[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)
|
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]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||||
|
|||||||
@@ -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_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"]
|
[node name="PlayerInputRegression" type="CharacterBody3D"]
|
||||||
script = ExtResource("1_player")
|
script = ExtResource("1_player")
|
||||||
@@ -10,5 +11,6 @@ character_model_path = ""
|
|||||||
[node name="Visual" type="Node3D" parent="."]
|
[node name="Visual" type="Node3D" parent="."]
|
||||||
|
|
||||||
[node name="CameraPivot" type="Node3D" parent="."]
|
[node name="CameraPivot" type="Node3D" parent="."]
|
||||||
|
script = ExtResource("2_camera_rig")
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
|
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
|
||||||
|
|||||||
@@ -102,12 +102,13 @@ func _verify_regression_scene(failures: Array[String]) -> void:
|
|||||||
Input.action_release(PlayerInputActions.MOVE_FORWARD)
|
Input.action_release(PlayerInputActions.MOVE_FORWARD)
|
||||||
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
|
_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()
|
var zoom_event := InputEventMouseButton.new()
|
||||||
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
|
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
|
||||||
zoom_event.pressed = true
|
zoom_event.pressed = true
|
||||||
player.call("_unhandled_input", zoom_event)
|
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()
|
var flight_toggle_event := InputEventAction.new()
|
||||||
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user