Compare commits

...

16 Commits

Author SHA1 Message Date
sindoring c3094c9413 Merge pull request 'gmp(M02): gate debug movement capabilities' (#26) from work/sindo-main-codex/m02-sandbox-capabilities into master
Reviewed-on: #26
2026-07-14 22:58:54 +03:00
sindoring 7d5d13f702 docs(M02): publish movement capability handoff
Work-Package: M02-GMP-SANDBOX-CAPABILITIES-001

Agent: sindo-main-codex
2026-07-14 23:56:00 +04:00
sindoring 742c415885 gmp(M02): gate debug movement capabilities
Work-Package: M02-GMP-SANDBOX-CAPABILITIES-001

Agent: sindo-main-codex

Tests: capability/input/movement/terrain/camera/presentation; coordinate/streaming/docs/coordination; renderer dry-run 7/7

Fidelity: proves debug sprint/free-flight exclusion in Blizzlike335; no positive movement parity claim
2026-07-14 23:55:27 +04:00
sindoring 8c71e46872 Merge pull request 'rnd(M02): extract character presenters' (#25) from work/sindo-main-codex/m02-character-presentation into master
Reviewed-on: #25
2026-07-14 22:44:11 +03:00
sindoring ad820699bc docs(M02): publish character presentation handoff
Work-Package: M02-RND-CHARACTER-PRESENTATION-001

Agent: sindo-main-codex
2026-07-14 23:42:42 +04:00
sindoring 97bb53f6cd rnd(M02): extract character presenters
Work-Package: M02-RND-CHARACTER-PRESENTATION-001

Agent: sindo-main-codex

Tests: presentation/input/movement/terrain/camera regressions; coordinate/streaming/docs/coordination gates; renderer dry-run 7/7

Fidelity: preserves current sandbox model/outfit and Stand/Idle/Run/Walk behavior; build-12340 parity remains unverified
2026-07-14 23:42:12 +04:00
sindoring 68e6f60d90 Merge pull request 'rnd(M02): extract third-person camera rig' (#24) from work/sindo-main-codex/m02-camera-rig into master
Reviewed-on: #24
2026-07-14 22:30:33 +03:00
sindoring 02dec23c2a docs(M02): publish camera rig handoff
Work-Package: M02-RND-CAMERA-001
2026-07-14 23:27:41 +04:00
sindoring 8c1cf9be36 rnd(M02): extract third-person camera rig
Work-Package: M02-RND-CAMERA-001
2026-07-14 23:27:03 +04:00
sindoring f5bb64e6c7 Merge pull request 'gmp(M02): extract player terrain query' (#23) from work/sindo-main-codex/m02-terrain-query into master
Reviewed-on: #23
2026-07-14 22:17:12 +03:00
sindoring 0ce3cae208 docs(M02): publish terrain query handoff
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents inherited height/snap regression and remaining collision evidence gap
2026-07-14 23:11:23 +04:00
sindoring a45d521567 gmp(M02): extract player terrain query
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: terrain query, player input and movement regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves existing ADT outer-grid interpolation and player ground snap; build-12340 collision parity remains unverified
2026-07-14 23:10:55 +04:00
sindoring 5206c42cf0 Merge pull request 'gmp(M02): extract local player movement' (#22) from work/sindo-main-codex/m02-local-movement into master
Reviewed-on: #22
2026-07-14 22:01:21 +03:00
sindoring d2d6df3655 docs(M02): publish local movement handoff
Work-Package: M02-GMP-MOVEMENT-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents preserved sandbox behavior and remaining build-12340 evidence gap
2026-07-14 22:59:59 +04:00
sindoring 435e1c95d2 gmp(M02): extract local player movement
Work-Package: M02-GMP-MOVEMENT-001
Agent: sindo-main-codex
Tests: local movement and player input regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves current sandbox speed, acceleration, sprint and camera-relative flight behavior; build-12340 parity remains unverified
2026-07-14 22:59:33 +04:00
sindoring 14dead194b Merge pull request 'gmp(M02): add player input intent seam' (#21) from work/sindo-main-codex/m02-player-input into master
Reviewed-on: #21
2026-07-14 21:52:26 +03:00
44 changed files with 3513 additions and 400 deletions
+117
View File
@@ -0,0 +1,117 @@
# M02-GMP-MOVEMENT-001 — Local movement state/controller
<!-- OPENWC_CLAIM:M02-GMP-MOVEMENT-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-MOVEMENT-001:435e1c9 -->
## Ownership
- Target: M02
- Program: GMP
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-local-movement`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Move local velocity, sandbox flight state, acceleration and directional speed
calculation from `ThirdPersonWowController` into a scene-free,
independently-testable local movement controller without changing current
sandbox motion.
## Non-goals
- Extract terrain queries, camera rig, character presentation or animation.
- Add jump/fall/swim, server prediction or reconciliation.
- Change current movement speeds, acceleration, camera-relative flight or ground snap.
- Gate debug movement by build profile; that remains a separate M02 package.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/movement/`,
`src/tools/verify_local_player_movement.gd`,
`docs/modules/local-player-movement.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`,
`docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
## Contracts and data
- Public API: stateful `LocalPlayerMovementController`
- Inputs: immutable `MoveIntent`, local Godot movement basis and physics delta seconds
- Outputs: local Godot displacement and read-only velocity/flight state
- Schema/cache/coordinate contracts: unchanged
- Consumer: current sandbox player scene; later gameplay composition root
## Dependencies
- Requires: merged input seam on master `14dead1`
- Blocks: terrain query and camera/presentation extraction from the player hotspot
- External state: none; unit tests use synthetic intents and bases
## Verification
- Commands: dedicated pure movement verifier, player-input regression, asset-free
scene smoke, coordinate/StreamingFocus gates, renderer dry-run and repository gates
- Fixtures: identity/rotated/pitched bases, acceleration steps, sprint and flight transitions
- Fidelity evidence: exact current 7.0/4.5/4.5 speeds, 28.0 acceleration,
6x sandbox sprint and camera-relative free flight are regression-locked only
- Performance budget: one small state update per physics tick; no allocation, I/O or scene lookup
## Documentation deliverables
- Inline public API docs for movement state/configuration and update methods
- Local movement module specification with data-flow, state and sequence diagrams
- Updated player-input consumer diagram/status and module registry
## Simplicity and naming
- Important name: `LocalPlayerMovementController`
- Simplest approach: one stateful RefCounted owning velocity and flight toggle
- Rejected complexity: generic locomotion framework, ECS component hierarchy and event bus
- Unavoidable complexity: movement basis remains an explicit local-space input
so the pure controller does not discover player/camera Nodes
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: scene-free movement state/controller, player scene migration, twelve
numeric cases, two flight transitions, integrated regression and module documentation
- Next: M02 integrator reviews and merges before TerrainQuery extraction
- Blocked by:
## Handoff
- Commit: `435e1c9`
- Branch: `work/sindo-main-codex/m02-local-movement`
- Outcome: `ThirdPersonWowController` no longer owns velocity, flight state,
acceleration or directional speed calculation. It selects an explicit
Godot-world basis, applies returned displacement, and retains terrain and
presentation adapters for later M02 packages.
- Public contract: additive `LocalPlayerMovementController` with read-only
Godot-world velocity/flight state, `toggle_sandbox_flight` and `advance`.
Input, coordinate, cache and renderer contracts remain unchanged.
- Verification: `LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2
scene_boundary=1`; `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
and StreamingFocus passed; renderer dry-run passed all eight pre-capture gates
and seven checkpoint plans; coordination/documentation/diff gates passed.
- Fidelity: exact existing defaults are regression-locked—forward `7.0`,
backward/strafe `4.5`, acceleration `28.0`, sandbox sprint `6x`, vertical
flight `7.0`, velocity reset on toggle and camera-relative pitched flight.
This preserves the sandbox baseline but is not original-client evidence.
- Local verification inputs: the isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
character GLB were absent, so renderer dry-run logged expected missing-corpus
diagnostics while every contract step returned success.
- Rebuild/migration: none; transient movement state is not persisted.
- Remaining risks: exported speed changes after `_ready` do not reconfigure the
controller; exact build-12340 movement semantics, terrain/collision state,
jump/fall/swim, server prediction and debug profile gating remain open.
- Documentation: inline public API plus `docs/modules/local-player-movement.md`
with inputs/outputs, data-flow, state and sequence diagrams, ownership,
recovery, capability status, risks and source map; player-input consumer
diagrams/status were updated.
@@ -0,0 +1,126 @@
# M02-GMP-SANDBOX-CAPABILITIES-001 — Debug movement capability gate
<!-- OPENWC_CLAIM:M02-GMP-SANDBOX-CAPABILITIES-001:sindo-main-codex:2026-07-16 -->
## Ownership
- Target: M02
- Program: GMP/FND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-sandbox-capabilities`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Introduce an immutable local-player movement capability contract and require it
at the movement boundary so debug sprint and free flight execute only in the
explicit `RenderSandbox` profile and are rejected by `Blizzlike335`.
## Non-goals
- Claim that current movement values or `Blizzlike335` locomotion are 1:1.
- Build a global application/profile framework before the application shell exists.
- Remove debug Input Map actions or change their current sandbox bindings.
- Implement jump, fall, swim, collision, server authority or reconciliation.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/movement/player_movement_capabilities.gd`,
`src/tools/verify_player_movement_capabilities.gd`, this claim
- Shared/hotspots: `src/gameplay/movement/local_player_movement_controller.gd`,
`src/scenes/player/third_person_wow_controller.gd`, movement/input verifiers,
player regression scene, `docs/modules/local-player-movement.md`,
`docs/modules/player-input.md`
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
## Contracts and data
- `PlayerMovementCapabilities` owns immutable profile ID plus explicit debug
sprint/flight booleans and safe named factories.
- `LocalPlayerMovementController` requires the capability value; missing input
falls back to `Blizzlike335`, never sandbox.
- Player scene maps its exported profile selection once at the composition boundary.
- Input actions and `MoveIntent` remain unchanged; rejected debug requests have no effect.
## Dependencies
- Requires: merged character presentation package on master `8c71e46`.
- Blocks: final M02 integration audit and milestone closeout.
- External state: none; verification is asset-free.
## Verification
- Pure profile factory/default and immutability regression.
- Sprint accepted in RenderSandbox and ignored in Blizzlike335.
- Flight toggle/vertical basis accepted in RenderSandbox and rejected in Blizzlike335.
- Real regression scene verifies both profile compositions.
- Existing input/movement/terrain/camera/presentation and renderer gates remain required.
- Fidelity evidence: negative proof that debug capabilities cannot affect the
compatibility profile; no positive movement parity claim.
## Documentation deliverables
- Inline public API and profile semantics on the capability/controller boundary.
- Updated movement and input specs: API, inputs/outputs, data flow, lifecycle,
failure/recovery, capability matrix and source map.
## Simplicity and naming
- Important name: `PlayerMovementCapabilities`.
- Simplest approach: one immutable value injected into the existing controller.
- Rejected complexity: autoload singleton, generic feature-flag service and global profile registry.
- Unavoidable complexity: one profile-to-capability mapping at scene composition.
- Measured optimization evidence: not applicable.
## Status
- State: ready-for-review
- Done: added immutable capability contract, gated sprint/free-flight, covered
pure and real-scene profiles and updated movement/input specifications
- Next: milestone integrator reviews and merges `742c415`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-GMP-SANDBOX-CAPABILITIES-001:742c415 -->
## Handoff
- Branch/commit: `work/sindo-main-codex/m02-sandbox-capabilities` at `742c415`.
- Outcome: debug sprint and free flight now require explicit
`PlayerMovementCapabilities.render_sandbox()`; `Blizzlike335`, unknown and
null capability inputs reject both effects.
- Public contract: immutable profile ID plus `allows_debug_sprint` and
`allows_debug_free_flight`; named factories map configuration once at the
composition boundary. The movement controller exposes its effective profile ID.
- Runtime composition: `ThirdPersonWowController.movement_profile_id` defaults
to `RenderSandbox`, preserving renderer-scene behavior. Selecting
`Blizzlike335` keeps ordinary movement while ignoring Shift/Space/E/Q effects.
- Verification: capability `profiles=3 sprint=2 flight=2 scenes=2 boundary=1`,
movement `cases=12 state_transitions=2`, input `actions=12 intent_cases=6`,
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
`state_cases=12 policy_cases=3`, presentation
`appearance_cases=9 animation_cases=10`, coordinate boundaries
`files=101 consumers=5`, and StreamingFocus `runtime_scenes=2 capture_tools=3` passed.
- Renderer: dry-run passed project/material/dedupe/cache/manifest/calibration/
coordinate/server-spawn gates and planned all seven checkpoints; the combined
run reported coordinate `files=103`. Missing proprietary GLB/DBC/ADT inputs
produced the expected diagnostics only.
- Repository gates: documentation `module_specs=8`, coordination
`fallback_claims=30`; five unrelated expired M00 claim warnings remain.
- Fidelity: this package provides negative evidence that sandbox-only debug
movement cannot alter the `Blizzlike335` profile. It does not prove ordinary
movement, bindings, flight or timing match build 12340.
- Migration: direct `LocalPlayerMovementController.new(...)` callers that need
debug behavior must now pass `render_sandbox()`; omitted/null capability is
intentionally compatibility-safe. Existing repository scenes require no edit.
- Cache/persistence: no schema, cache, binding or stored-profile migration.
- Local ignored inputs: native extension DLL, UID cache and generated ADT
resource scripts supported renderer verification and are not committed.
- Risks: runtime renderer scenes still default to `RenderSandbox`; the future
application shell must explicitly choose its profile. `Blizzlike335` currently
guarantees only debug exclusion, not complete movement fidelity.
- Recommended merge order: merge after presentation (already on master), then
perform the final M02 integration/evidence audit before any target closeout.
- Documentation: updated movement/input API, inputs/outputs, data flow, state,
sequence, ownership, failure/recovery, capabilities, verification and source maps.
@@ -0,0 +1,118 @@
# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-TERRAIN-QUERY-001:a45d521 -->
## Ownership
- Target: M02
- Program: GMP/RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Introduce a typed `TerrainQuery` boundary and move ADT loading, caching, chunk
selection and height interpolation out of `ThirdPersonWowController` without
changing current spawn/ground-snap behavior.
## Non-goals
- Change ADT topology, coordinate formulas, terrain mesh or renderer caches.
- Add physics collision, slopes, water, jump/fall/swim or server movement.
- Reuse the streaming renderer cache before its public terrain-query contract exists.
- Extract camera or character presentation.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/terrain/`, `src/tools/verify_terrain_query.gd`,
`docs/modules/terrain-query.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
`src/tools/verify_player_input.gd`,
`src/tools/verify_coordinate_conversion_boundaries.gd`,
`docs/modules/local-player-movement.md`,
`docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
## Contracts and data
- Public API: `TerrainQuery.sample_ground_height(GodotWorldPosition)` returning
immutable `TerrainGroundSample`
- Production adapter: `AdtTerrainQuery` with owned per-tile parse cache
- Test/data-source seam: optional ADT tile loader callable at construction
- Coordinate contract version, ADT format, renderer cache and persistence: unchanged
- Consumers: current player scene; future movement/collision policies
## Dependencies
- Requires: merged movement controller on master `5206c42`
- Blocks: camera/presentation extraction from the player hotspot
- External state: production uses existing native `ADTLoader`; synthetic tests need no proprietary data
## Verification
- Commands: synthetic height/cache/failure verifier, player input/movement
regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run
and repository gates
- Fixtures: synthetic 145-height chunk payload and injected flat terrain query
- Fidelity evidence: preserves existing outer-grid bilinear interpolation,
spawn ground offset and per-tick snap formula only
- Performance budget: one cached tile lookup and local interpolation per ground query;
no new I/O after first tile load
## Documentation deliverables
- Inline public API docs for query, sample and ADT adapter
- Terrain-query module spec with inputs/outputs, data-flow, cache lifecycle and sequence diagrams
- Updated movement/coordinate consumer boundaries, source maps and module registry
## Simplicity and naming
- Important names: `TerrainQuery`, `TerrainGroundSample`, `AdtTerrainQuery`
- Simplest approach: one narrow query contract, one typed result and one existing-format adapter
- Rejected complexity: general collision framework, terrain service locator and renderer-cache coupling
- Unavoidable complexity: ADT 145-height layout and chunk lookup stay isolated in adapter
- Measured optimization evidence: existing per-tile cache retained; no new optimization
## Status
- State: ready-for-review
- Done: typed query/sample contract, cached ADT adapter, player migration,
synthetic interpolation/cache/failure tests, injected scene regression and documentation
- Next: M02 integrator reviews and merges before camera-rig extraction
- Blocked by:
## Handoff
- Commit: `a45d521`
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Outcome: `ThirdPersonWowController` no longer loads ADTs, caches tile
dictionaries, selects chunks or interpolates height grids. It consumes a
replaceable typed `TerrainQuery` and retains only existing offset/snap policy.
- Public contracts: additive immutable `TerrainGroundSample`, replaceable
`TerrainQuery`, cached `AdtTerrainQuery` and player `set_terrain_query`.
Coordinate contract version, ADT/native format and renderer caches are unchanged.
- Verification: `TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1
failures=2 player_injection=1`; player-input and local-movement regressions
passed; asset-free scene exited `0`; coordinate boundary passed with five
required consumers and StreamingFocus passed; renderer dry-run passed eight
pre-capture gates and seven checkpoint plans; coordination/documentation/diff gates passed.
- Fidelity: synthetic fractional-grid fixture locks the inherited 145-height
outer-grid bilinear result within `0.002` Godot units; injected scene locks
spawn offset `0.05` and physics snap. This is sandbox-regression evidence,
not build-12340 triangle/hole/collision parity.
- Local verification inputs: isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
character GLB were absent, so renderer dry-run logged expected missing-corpus
diagnostics while all contract steps returned success.
- Rebuild/migration: none; caches are transient and existing formats remain unchanged.
- Remaining risks: first ADT parse remains synchronous as before; unavailable
tiles cache until query replacement; player still owns snap policy; exact
ADT triangle choice, holes, slopes, liquids and collision remain open.
- Documentation: inline public APIs plus `docs/modules/terrain-query.md` with
inputs/outputs, data-flow, cache state and cross-boundary sequence diagrams,
ownership, failure/recovery, capability status and source map. Coordinate and
movement consumer specifications were updated.
+118
View File
@@ -0,0 +1,118 @@
# 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: 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:
<!-- OPENWC_HANDOFF:READY:M02-RND-CAMERA-001:8c1cf9b -->
## 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.
@@ -0,0 +1,124 @@
# M02-RND-CHARACTER-PRESENTATION-001 — Character presentation boundary
<!-- OPENWC_CLAIM:M02-RND-CHARACTER-PRESENTATION-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-character-presentation`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Move character GLB composition, geoset/texture/outfit setup and locomotion
animation selection from `ThirdPersonWowController` into independently
replaceable `CharacterAppearancePresenter` and `CharacterAnimationPresenter`
components without changing the current sandbox presentation.
## Non-goals
- Add new model, equipment, animation or build-12340 behavior.
- Change character asset formats, DBC parsing, texture composition or geoset rules.
- Add network-driven appearance/movement snapshots.
- Change input, movement, terrain, camera or renderer streaming behavior.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/scenes/character/character_appearance_presenter.gd`,
`src/scenes/character/character_animation_presenter.gd`,
`src/tools/verify_character_presentation.gd`,
`docs/modules/character-presentation.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/local-player-movement.md`, `docs/modules/README.md`
- Reused unchanged: geoset controller, texture compositor and outfit resolver
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
## Contracts and data
- Appearance API loads/replaces one character root under the existing `Visual` node.
- Animation API binds an `AnimationPlayer` from that root and accepts a typed
moving/stationary presentation state.
- Scene paths, model defaults, DBC/cache/coordinate contracts remain unchanged.
- Consumers: player scene composition and renderer viewport only.
## Dependencies
- Requires: merged camera package on master `68e6f60`.
- Blocks: final M02 sandbox-profile gate and target integration audit.
- External state: asset-free tests use a synthetic mesh and AnimationPlayer.
## Verification
- Dedicated appearance composition, replacement, grounding and failure regression.
- Dedicated animation discovery, fallback, looping, blend and state regression.
- Player source-boundary and three-scene wiring checks.
- Existing input/movement/terrain/camera regressions, coordinate gates and renderer dry-run.
- Fidelity evidence: preserve current sandbox model path, scale, yaw, outfit,
Stand/Idle and Run/Walk selection only; no original-client parity claim.
## Documentation deliverables
- Inline public API/export documentation for both presenters.
- Module specification with inputs/outputs, data-flow, lifecycle/state and sequence diagrams.
- Updated movement consumer/source map and module registry.
## Simplicity and naming
- Important names: `CharacterAppearancePresenter`, `CharacterAnimationPresenter`.
- Simplest approach: two small scene components on the existing `Visual` subtree.
- Rejected complexity: generic presenter framework, async asset scheduler and duplicated character systems.
- Unavoidable complexity: scene-tree composition and AnimationPlayer discovery run on the main thread.
- Measured optimization evidence: not applicable.
## Status
- State: ready-for-review
- Done: extracted both presenters, migrated all runtime/regression scenes,
added asset-free regressions and completed required module documentation
- Next: milestone integrator reviews and merges `97bb53f`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M02-RND-CHARACTER-PRESENTATION-001:97bb53f -->
## Handoff
- Branch/commit: `work/sindo-main-codex/m02-character-presentation` at `97bb53f`.
- Outcome: `CharacterAppearancePresenter` owns model/geoset/compositor/outfit
composition and `CharacterAnimationPresenter` owns AnimationPlayer discovery,
loop preparation and moving/stationary playback. The player is now only the
composition and horizontal-velocity adapter.
- Changed composition: both runtime scenes and the player regression scene keep
`ThirdPersonPlayer/Visual`; they add `Visual/AnimationPresenter`.
- Public contracts: appearance load/read-root/ready signal and animation
bind/present/read-state APIs documented in the module specification.
- Verification: presentation `appearance_cases=9 animation_cases=10 scenes=3`,
input `actions=12 intent_cases=6`, movement `cases=12 state_transitions=2`,
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
`state_cases=12 policy_cases=3`, coordinate boundaries `files=99 consumers=5`,
and StreamingFocus `runtime_scenes=2 capture_tools=3` passed.
- Renderer: dry-run passed project/material/dedupe/cache/manifest/calibration/
coordinate/server-spawn gates and planned all seven checkpoints. Missing
proprietary HumanMale GLB, DBC and ADT corpus produced expected diagnostics.
- Repository gates: documentation `module_specs=8`, coordination
`fallback_claims=29`; five unrelated expired M00 claim warnings remain.
- Fidelity: current model path, scale `1`, yaw `90 degrees`, class `1`, blend
`0.15 seconds`, starter outfit and Stand/Idle/Run/Walk selection are preserved.
No build-12340 appearance or animation parity claim is made.
- Migration: repository scenes are migrated. External/custom scenes must move
model/class/scale/yaw exports from the player node to the scripted `Visual`
node and add `Visual/AnimationPresenter`. No cache/database rebuild is needed.
- Local ignored inputs: native extension DLL, UID cache and generated ADT
resource scripts supported the dry-run and are not committed.
- Risks: synchronous model load may hitch; first descendant AnimationPlayer wins;
moving state is a sandbox threshold boolean; complete equipment/animation
fidelity remains open.
- Recommended merge order: merge this package after camera (already on master),
then implement the sandbox profile gate and perform the final M02 integration audit.
- Documentation: added character-presentation API, inputs/outputs, data-flow,
lifecycle/state, sequence, ownership/failure and source-map sections; updated
the module registry and movement presentation consumer map.
+4
View File
@@ -10,6 +10,10 @@
| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) |
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
| 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) |
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.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) |
+273
View File
@@ -0,0 +1,273 @@
# Character Presentation
## Metadata
| Field | Value |
|---|---|
| Status | Implemented boundary / Partial fidelity |
| Target/work package | M02 / M02-RND-CHARACTER-PRESENTATION-001 |
| Owners | Character appearance composition and locomotion animation presentation |
| Last verified | `97bb53f`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox character experiment |
## Purpose
Compose the current sandbox character GLB, geosets, skin textures and starter
outfit below the existing `Visual` node, then independently present its
stationary or moving animation. Keep visual asset loading and AnimationPlayer
state out of gameplay movement and the player scene composition root.
## Non-goals
- Define authoritative character identity, equipment or movement state.
- Change the existing GLB, geoset, texture-compositor, DBC or outfit formats.
- Implement full build-12340 animation selection, blending or equipment visuals.
- Load world assets, query terrain, process input or control the camera.
- Introduce asynchronous streaming, background scene mutation or persistence.
## Context and boundaries
```mermaid
flowchart LR
Scene[ThirdPersonWowController composition] --> Appearance[CharacterAppearancePresenter]
Model[Configured PackedScene] --> Appearance
DBC[Extracted DBC/item textures] --> Appearance
Appearance --> Root[CharacterModel root]
Root --> Geosets[Existing geoset controller]
Root --> Compositor[Existing texture compositor]
Appearance -->|ready root| Scene
Scene --> Animation[CharacterAnimationPresenter]
Velocity[Movement read model] -->|moving boolean| Animation
Animation --> Player[AnimationPlayer below root]
```
Allowed dependencies:
- configured Godot `PackedScene` character asset;
- existing `CharacterGeosetController`, `CharacterTextureCompositor` and
`WowCharacterOutfitResolver` presentation components;
- `AnimationPlayer` and animation resources below the composed character root;
- main-thread player scene composition and movement velocity read model.
Forbidden dependencies:
- `MoveIntent`, input devices or movement integration;
- `TerrainQuery`, ADT parsing, coordinate conversion or streaming decisions;
- cameras, network packets, server databases or gameplay reducers;
- direct production database writes or cache-format ownership.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `CharacterAppearancePresenter.load_character_appearance(content_root_override)` | Command | Replaces the owned character root and schedules final grounding/outfit composition | Main thread; may be called again during scene lifetime | Returns `false` for empty/invalid model; warning for invalid resource |
| `CharacterAppearancePresenter.character_root` | Read-only state | Current owned model root or null | Stable until next load | None |
| `character_appearance_ready(character_root)` | Signal | Publishes a fully attached, grounded root | Deferred main-thread completion | Not emitted for invalid/empty model |
| `CharacterAnimationPresenter.bind_character_root(character_root)` | Command | Discovers and prepares the first descendant AnimationPlayer | Main thread; binding lifetime | Returns `false` and clears state when absent |
| `CharacterAnimationPresenter.present_locomotion(is_moving)` | Command | Selects current stationary/moving candidate and starts it once | Main/physics thread | Returns `false` when unavailable, missing or unchanged |
| `active_animation_name` | Read-only state | Selected animation name | Stable until bind/state transition | Empty when unbound |
| `has_animation_player` | Read-only state | Whether the presenter has a valid bound player | Stable until bind | None |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Model resource path, class, scale and yaw | Runtime scene composition | Appearance presenter | Exported configuration | Captured at load |
| Input | Extracted content root | Player scene configuration | Appearance presenter | Copied string | Captured at load |
| Input | Packed character scene | Godot resource loader | Appearance presenter | Shared resource; presenter owns instance | Scene lifetime |
| Output | Composed `CharacterModel` root | Appearance presenter | Scene/animation presenter | Appearance presenter owns node | Until replacement |
| Output | Ready signal | Appearance presenter | Player composition | Synchronous signal after deferred completion | One per successful load |
| Input | Moving/stationary boolean | Player adapter from horizontal velocity | Animation presenter | Value copy | One physics tick |
| Output | AnimationPlayer playback mutation | Animation presenter | Rendered character | Bound root owns player/resources | Until next transition |
Side effects:
- loads and instantiates the configured model on the main thread;
- adds/removes one owned `CharacterModel` subtree;
- composes the existing texture/outfit components and reads their extracted inputs;
- changes model transform for scale, yaw and ground alignment;
- configures locomotion loop modes and starts AnimationPlayer playback;
- does not write files, caches, databases or network state.
## Data flow
```mermaid
flowchart TD
Config[Model/class/scale/yaw/content root] --> Load[Load PackedScene]
Load --> Replace[Replace owned CharacterModel]
Replace --> Attach[Attach model and compositor]
Attach --> Ground[Fit mesh bounds to visual ground]
DBC[Existing outfit resolver] --> Outfit[Apply starter outfit]
Ground --> Ready[Emit ready root]
Outfit --> Ready
Ready --> Bind[Discover AnimationPlayer]
Velocity[Horizontal movement velocity] --> State[Moving or stationary]
State --> Select[Run/Walk or Stand/Idle selection]
Bind --> Select
Select --> Playback[Loop and play with blend]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> Empty
Empty --> Loading: valid load command
Loading --> Ready: attach + deferred composition
Loading --> Empty: invalid resource
Ready --> Loading: replacement load
Ready --> Empty: empty model command
state Animation {
[*] --> Unbound
Unbound --> Stationary: bind root with animations
Stationary --> Moving: moving=true
Moving --> Stationary: moving=false
Stationary --> Unbound: bind missing player
Moving --> Unbound: bind missing player
}
```
Appearance replacement removes the old root from the scene immediately and
queues it for deletion. Deferred completion checks root identity, so a stale
completion cannot publish a replaced model.
## Main sequence
```mermaid
sequenceDiagram
participant Scene as ThirdPersonWowController
participant Appearance as CharacterAppearancePresenter
participant Existing as Geoset/Compositor/Outfit
participant Animation as CharacterAnimationPresenter
participant Player as AnimationPlayer
Scene->>Appearance: load_character_appearance(content root)
Appearance->>Appearance: load, instantiate and attach root
Appearance->>Existing: compose textures and starter outfit
Appearance-->>Scene: character_appearance_ready(root)
Scene->>Animation: bind_character_root(root)
Animation->>Player: prepare loops and play Stand/Idle
loop Physics ticks
Scene->>Animation: present_locomotion(is moving)
Animation->>Player: play only on state/name change
end
```
## Ownership, threading and resources
- The runtime scene owns one appearance presenter on `Visual` and one animation
presenter below it.
- The appearance presenter exclusively owns the generated `CharacterModel`
subtree; imported shared resources remain Godot-owned.
- The animation presenter borrows an AnimationPlayer reference until rebind or
subtree replacement; the player scene orders rebind through the ready signal.
- Loading, node mutation, bounds calculation, outfit setup and playback occur
on the main thread. There are no workers, locks, RIDs or cancellation tokens.
- The player controller owns neither model resources nor animation state.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Empty model path | Load guard | Clears old root and returns `false` | Contract regression | Configure a path and retry |
| Invalid model resource | Failed PackedScene cast | Remains empty | Named warning with path | Correct/extract asset and retry |
| Replaced deferred completion | Root identity check | Stale completion ignored | Replacement regression | No action required |
| Missing DBC/outfit inputs | Existing resolver returns false/empty | Base model remains visible | Existing resolver diagnostics | Restore extracted inputs and reload |
| Missing AnimationPlayer | Recursive discovery returns null | Presenter becomes unbound | Contract return/read state | Bind a root with animations |
| Missing candidate animation | Candidate selection returns empty | Current playback unchanged | Verifier/source diagnostics | Extend verified candidate policy later |
There is no background load cancellation. Replacement is the recovery path and
invalid model state is explicit through the return value and null read model.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Character model | Human male sandbox GLB | Current sandbox | Before reload | Model scene input |
| Character class ID | `1` | Current sandbox | Before reload | Starter outfit lookup |
| Uniform scale | `1.0` | Current sandbox | Before reload | Character root scale |
| Yaw offset | `90` degrees | Current sandbox | Before reload | Imported model correction |
| Extracted content root | `res://data/extracted` | Local content | Per load override | DBC/item texture input |
| Animation blend | `0.15` seconds | Current sandbox | Before bind/transition | Playback cross-fade/default blend |
| Stationary candidates | `Stand`, then `Idle` | Current sandbox | Fixed | Exact then substring selection |
| Moving candidates | `Run`, then `Walk` | Current sandbox | Fixed | Exact then substring selection |
## Persistence, cache and migration
Presenter state is transient. No saved settings, user profile, database schema
or cache format changes. Existing scene paths remain
`ThirdPersonPlayer/Visual` and add only `Visual/AnimationPresenter`; model and
extracted-data paths retain their defaults. Moving the model exports from the
player node to `Visual` is a scene-authoring ownership change, not persisted
runtime data migration.
## Diagnostics and observability
- Logs: invalid model path warns with the responsible presenter and path.
- Read models: current character root, animation binding and active name.
- Tests: dedicated verifier reports appearance, animation, scene and player boundaries.
- Metrics/correlation IDs: not applicable to this synchronous sandbox presenter.
## Verification
- Unit/contract: synthetic model fixture verifies load, scale, yaw, grounding,
compositor composition, ready signal, replacement and empty-model clearing.
- Animation: synthetic libraries verify nested discovery, exact and substring
candidate selection, loop policy, blend configuration and duplicate suppression.
- Boundary: player source contains no model loading, outfit, bounds or
AnimationPlayer logic; appearance and animation dependencies remain separate.
- Integration: Eastern Kingdoms, Kalimdor and player regression scenes compose
both presenters; existing input/movement/terrain/camera tests remain required.
- Fidelity evidence: only preservation of the current sandbox defaults and
selection rules. No build-12340 appearance or animation parity is claimed.
- Performance: one synchronous model composition per load and constant-time
locomotion calls except on state transition; asset streaming remains future work.
## Extension points
- Replace starter outfit inputs with a versioned appearance/equipment snapshot.
- Replace boolean locomotion input with a typed presentation state after
movement/network contracts define walk, run, jump, fall, swim, mount and emotes.
- Add asynchronous/preloaded model providers behind the appearance command
without moving resource loading back into gameplay.
- Add verified animation IDs/variation policy after build-12340 fixtures exist.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Independent appearance composition | Implemented | Synthetic fixture and three scene wirings | Integrator target update |
| Independent locomotion animation | Implemented | Exact/fallback/loop/blend regression | Add typed presentation state later |
| Current starter outfit | Partial | Existing resolver reused unchanged | Needs extracted DBC fixture and client comparison |
| Current skin/geoset composition | Partial | Existing components reused | Full equipment/customization fidelity incomplete |
| Build-12340 animation semantics | Planned | No original-client fixture | Capture animation IDs/transitions/timing |
| Runtime equipment/network updates | Planned | No snapshot contract | M08/M09/M12 work |
## Known gaps and risks
- Model loading remains synchronous and can hitch when not preloaded.
- Exact appearance, equipment, animation choice, playback speed and blending are unverified.
- The moving input is a sandbox threshold boolean, not authoritative locomotion state.
- The first descendant AnimationPlayer wins; multi-player imported scenes need a
more explicit binding contract if they appear.
- Existing geoset/compositor/outfit implementations retain their own fidelity gaps.
## Source map
| Path | Responsibility |
|---|---|
| `src/scenes/character/character_appearance_presenter.gd` | Model-root ownership, composition, ground fit and outfit setup |
| `src/scenes/character/character_animation_presenter.gd` | AnimationPlayer binding and locomotion playback state |
| `src/scenes/character/character_geoset_controller.gd` | Existing geoset visibility and outfit defaults |
| `src/scenes/character/character_texture_compositor.gd` | Existing skin/equipment texture composition |
| `src/scenes/character/wow_character_outfit_resolver.gd` | Existing DBC starter outfit lookup |
| `src/scenes/player/third_person_wow_controller.gd` | Scene composition and moving-state adapter |
| `src/tests/scenes/character_presentation_model_fixture.tscn` | Asset-free model/grounding fixture |
| `src/tools/verify_character_presentation.gd` | Presenter, scene and dependency regression |
## Related decisions and references
- ADR: none; current behavior and resource formats are preserved.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `RENDER.md`.
+10 -4
View File
@@ -5,9 +5,9 @@
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-001` |
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-001`, `M02-GMP-TERRAIN-QUERY-001` consumer migration |
| Owners | Foundation/domain |
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
| Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | All profiles; contract version 2 |
## Purpose
@@ -86,7 +86,7 @@ Forbidden dependencies:
| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
| Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
| Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference |
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content adapters | New immutable values | Caller-owned references |
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content/terrain-query adapters | New immutable values | Caller-owned references |
| Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references |
| Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime |
@@ -115,6 +115,10 @@ flowchart LR
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition]
Godot -->|typed direct adapter| Tile
Godot -->|typed direct adapter| Local
Godot --> TerrainQuery[AdtTerrainQuery consumer]
TerrainQuery --> Tile
TerrainQuery --> Local
TerrainQuery --> Chunk
Tile -->|tile plus local| Godot
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
@@ -185,7 +189,8 @@ raw three-number arrays are not an accepted new persistence contract.
- Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
validates the dedicated manifest against the same pinned fixture before GUI capture.
- Integration/E2E: sky, player, streamer and terrain probe use mapper boundaries;
- Integration/E2E: sky, player spawn, streamer, terrain probe and the extracted
`AdtTerrainQuery` use mapper boundaries;
`StreamingFocus` provides typed renderer focus, and the pinned AzerothCore
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
`(32,48)`, chunk `(3,12)`.
@@ -243,6 +248,7 @@ raw three-number arrays are not an accepted new persistence contract.
| `src/tests/fixtures/coordinate_golden_points.json` | Versioned cross-source values, provenance and tolerances |
| `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation |
| `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
| `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup |
| `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership |
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier |
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
+277
View File
@@ -0,0 +1,277 @@
# Local Player Movement
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-GMP-SANDBOX-CAPABILITIES-001 and presentation consumers |
| Owners | Gameplay local movement state; scene composition owns the instance |
| Last verified | `742c415`, 2026-07-14 |
| Profiles/capabilities | `RenderSandbox` debug movement; `Blizzlike335` rejects debug sprint/flight |
## Purpose
Own current local velocity, acceleration and sandbox flight state independently
of the player scene. Convert an immutable `MoveIntent` plus an explicit
Godot-world movement basis into deterministic per-tick displacement without
querying terrain, cameras, input devices or visual assets.
## Non-goals
- Apply world transforms or own a `CharacterBody3D`.
- Query terrain height, collision, water or slopes.
- Implement jump, fall, swim, server prediction or reconciliation.
- Select the player basis versus camera-pivot basis; the scene adapter owns that decision.
- Claim build-12340 movement fidelity from sandbox regression alone.
## Context and boundaries
```mermaid
flowchart LR
Input[MoveIntent] --> Movement[LocalPlayerMovementController]
Basis[Explicit local movement Basis] --> Movement
Delta[Physics delta seconds] --> Movement
Profile[Scene profile ID] --> Capabilities[PlayerMovementCapabilities]
Capabilities --> Movement
Movement --> Displacement[Godot-world displacement]
Movement --> Velocity[Read-only velocity state]
Scene[ThirdPersonWowController] --> Basis
Displacement --> Scene
Scene --> Transform[Player world transform]
Scene --> Terrain[TerrainQuery and ground snap adapter]
Velocity --> Presentation[Visual facing and CharacterAnimationPresenter adapter]
```
Allowed dependencies:
- `MoveIntent` from the gameplay input boundary.
- Godot value types `Basis` and `Vector3` for renderer world-space direction math.
- Main-thread scene adapter for basis selection and displacement application.
Forbidden dependencies:
- `Node`, `Resource`, `Input`, `Camera3D`, `ADTLoader` or scene lookup.
- World-coordinate conversion or manual ADT/tile formulas.
- Character assets, animation players, materials, packets or server state.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `PlayerMovementCapabilities.render_sandbox()` | Factory | Creates the only debug sprint/free-flight capability set | Immutable caller value | None |
| `PlayerMovementCapabilities.blizzlike_335()` | Factory | Creates the compatibility-safe set with debug movement disabled | Immutable caller value | Name is intent, not a parity claim |
| `PlayerMovementCapabilities.for_profile_id(...)` | Factory | Maps composition configuration once; unknown IDs fail closed | Immutable caller value | Unknown maps to `Blizzlike335` |
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration, sprint multiplier and movement capabilities | Created by composition root; retained for player scene lifetime | Null capability defaults to `Blizzlike335` |
| `godot_world_velocity_units_per_second` | Read-only state | Current Godot-world velocity after the last update | Stable until next mutation on owning thread | None |
| `is_flight_enabled` | Read-only state | Current sandbox free-flight mode | Stable until toggle | None |
| `movement_profile_id` | Read-only state | Effective capability profile ID | Controller lifetime | None |
| `toggle_sandbox_flight()` | Command | Toggles flight only when explicitly permitted and clears velocity | Owning physics/input thread | Rejected request returns `false` with state unchanged |
| `advance(move_intent, godot_world_movement_basis, delta_seconds)` | Stateful update | Accelerates toward requested velocity and returns displacement for the tick | Owning physics thread; one call per tick | Negative delta is treated as zero |
Constructor units:
| Parameter | Unit/meaning |
|---|---|
| `run_speed_units_per_second` | Forward Godot units per second |
| `backward_speed_units_per_second` | Backward Godot units per second |
| `strafe_speed_units_per_second` | Lateral Godot units per second |
| `flight_vertical_speed_units_per_second` | Sandbox vertical Godot units per second |
| `acceleration_units_per_second_squared` | Velocity change per second |
| `sandbox_sprint_multiplier` | Dimensionless debug multiplier |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
| Input | `PlayerMovementCapabilities` | Application/player scene composition | Movement controller | Immutable caller-held value | Controller lifetime |
| 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 |
| Output | Velocity `Vector3` | Movement controller | Facing and `CharacterAnimationPresenter` scene adapter | Read-only value copy | Until next update |
| Output | Flight state | Movement controller | Basis/terrain scene adapter | Read-only boolean | Until next toggle |
Side effects:
- Mutates only controller-owned velocity and flight state.
- Does not mutate scene tree, transforms, resources, filesystem, network or cache.
- The scene adapter remains responsible for transform mutation and ground snap.
## Data flow
```mermaid
flowchart LR
Intent[MoveIntent axes] --> Target[Calculate target velocity]
Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions]
Directions --> Target
Config[Speeds and sprint multiplier] --> Target
Profile[RenderSandbox or Blizzlike335] --> Capability[Immutable debug capabilities]
Capability -->|allow or reject sprint| Target
Capability -->|allow or reject flight toggle| Current
Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta]
Target --> Integrate
Integrate --> Current[Updated velocity]
Current --> Step[velocity × delta]
Step --> Displacement[Scene-applied displacement]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> GroundedSandbox
GroundedSandbox --> FlyingSandbox: toggle [debug flight allowed] / clear velocity
GroundedSandbox --> GroundedSandbox: toggle [debug flight rejected]
FlyingSandbox --> GroundedSandbox: toggle / clear velocity
GroundedSandbox --> GroundedSandbox: advance
FlyingSandbox --> FlyingSandbox: advance
```
The names describe the existing sandbox mode only. They are not authoritative
gameplay movement states and do not imply terrain contact or server permission.
## Main sequence
```mermaid
sequenceDiagram
participant Input as PlayerInputSource
participant Scene as ThirdPersonWowController
participant Move as LocalPlayerMovementController
participant Cap as PlayerMovementCapabilities
participant Present as CharacterAnimationPresenter adapter
Input-->>Scene: MoveIntent
Scene->>Cap: map configured profile once
Scene->>Move: construct with immutable capabilities
Scene->>Move: advance(intent, selected basis, delta)
Move-->>Scene: displacement
Scene->>Scene: apply global position and ground snap
Scene->>Move: godot_world_velocity_units_per_second
Scene->>Present: horizontal motion
```
## Ownership, threading and resources
- The player scene creates and exclusively owns one movement controller.
- The composition root creates one immutable capability value; the controller
retains it and exposes only its effective profile ID.
- Velocity and flight state have the same lifetime as that controller.
- All mutations currently occur on the main input/physics thread.
- Read-only value returns are copies; consumers cannot mutate owned state.
- No Nodes, Resources, RIDs, files, jobs or worker threads are created.
- The supplied `Basis` is a Godot-world direction basis, not a typed world-position contract or coordinate conversion.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta |
| Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency |
| Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity |
| Flight/sprint request in `Blizzlike335` | Capability check | Request has no effect | Profile regression | Select explicit `RenderSandbox` only for tooling/debug scenes |
| Unknown/null profile capability | Factory/constructor safe default | Uses `Blizzlike335`; debug movement disabled | Capability regression | Correct composition profile ID |
| Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend |
There is no asynchronous cancellation, retry or rollback. Recreating the
controller resets velocity and flight state deterministically.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Forward speed | `7.0` units/s | Current sandbox | Fixed for controller lifetime | Target forward velocity |
| Backward speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target backward velocity |
| Strafe speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target lateral velocity |
| Acceleration | `28.0` units/s² | Current sandbox | Fixed for controller lifetime | Acceleration and deceleration |
| Flight vertical speed | `7.0` units/s | Sandbox debug | Fixed for controller lifetime | Vertical free-flight target |
| Sprint multiplier | `6.0` | Sandbox debug | Fixed for controller lifetime | Multiplies planar and vertical targets |
| Camera-relative basis | Selected only while flight is enabled | Sandbox debug | Per tick | Preserves existing pitched flight direction |
| Debug sprint permission | Enabled | `RenderSandbox` only | Immutable | Allows sprint request multiplier |
| Debug free-flight permission | Enabled | `RenderSandbox` only | Immutable | Allows toggle and vertical movement |
| Compatibility-safe debug permissions | Disabled | `Blizzlike335` | Immutable | Ignores sprint/toggle requests |
## Persistence, cache and migration
Movement state is transient and not serialized. No schema, cache, migration or
saved settings change is introduced. Future reconnect/replay state requires a
separate versioned movement snapshot contract.
## Diagnostics and observability
- Logs: verifier emits `LOCAL_PLAYER_MOVEMENT PASS` or named invariant failures.
- Metrics: none yet; update is constant-time vector math.
- Debug views: current character facing and animation remain observable scene outputs.
- Correlation IDs: not applicable to synchronous local sandbox state.
## Verification
- Unit/contract: `src/tools/verify_local_player_movement.gd` covers forward,
backward, strafe, rotated basis, acceleration, deceleration, sprint, flight,
pitched camera basis, state reset and invalid delta.
- Capability: `verify_player_movement_capabilities.gd` covers three profile-ID
cases, safe defaults, sprint/flight allow/reject behavior and two real scene compositions.
- Boundary: verifier rejects Node/Input/Camera/ADT dependencies and movement
integration/state remaining in the player scene.
- Scene integration: `verify_player_input.gd` drives the asset-free player
regression scene through forward motion and immediate flight toggle.
- Fidelity evidence: default values and formulas are regression-locked against
the pre-extraction sandbox. No original-client comparison is claimed.
- Performance budget: constant-time vector math, no per-tick I/O or scene lookup;
`MoveIntent` allocation remains owned by the input package.
## Extension points
- Terrain/collision policy can consume displacement without changing input or velocity ownership.
- A future server-aware predictor may replace this controller behind the scene composition boundary.
- A typed movement snapshot may expose deterministic replay state when M08/M09 require it.
- A future application profile can map to this narrow capability value without
coupling the movement controller to the application shell.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review |
| Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim |
| Existing camera-relative free flight | Implemented in `RenderSandbox` | Pitched-basis, profile and toggle/reset cases | Original-client flight semantics remain out of scope |
| Typed sprint/free-flight exclusion | Implemented | Pure and real-scene profile regressions | Application shell must select profile explicitly later |
| Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned |
| Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later |
| Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts |
| Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract |
## Known gaps and risks
- Numeric defaults preserve the existing sandbox but are not original-client evidence.
- Configuration is captured at scene `_ready`; runtime mutation of exported speed
properties does not reconfigure an existing controller.
- Ground snap remains in the player scene, but its ADT data access is now behind
a replaceable `TerrainQuery`.
- Runtime renderer scenes intentionally default to `RenderSandbox`; a future
game-client composition must explicitly select `Blizzlike335`.
- `Blizzlike335` currently means only debug movement exclusion and must not be
interpreted as evidence that remaining movement semantics are 1:1.
- The module uses Godot math value types; a future engine-independent gameplay
predictor may require scalar/domain movement contracts.
## Source map
| Path | Responsibility |
|---|---|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
| `src/gameplay/movement/player_movement_capabilities.gd` | Immutable profile-to-debug-capability contract and safe defaults |
| `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/character/character_animation_presenter.gd` | Locomotion presentation consumer behind the scene adapter |
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain and presentation-state adapters |
| `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression |
| `src/tools/verify_player_movement_capabilities.gd` | Profile factory, allow/reject and real-scene regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
| `src/tools/verify_player_input.gd` | Input-to-movement scene regression |
## Related decisions and references
- ADR: none; this follows the existing M02 decomposition boundary.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+45 -29
View File
@@ -5,10 +5,10 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M02 / M02-GMP-INPUT-001 |
| Target/work package | M02 / input, movement, camera and sandbox-capability consumer updates |
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
| Last verified | `6bd2e84`, 2026-07-14 |
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
| Last verified | `742c415`, 2026-07-14 |
| Profiles/capabilities | Debug requests sampled in all profiles; execution allowed only by `RenderSandbox` movement capabilities |
## Purpose
@@ -21,8 +21,8 @@ independently testable local movement controller.
- Define authoritative server movement, prediction or reconciliation.
- Own character transform, terrain queries, camera state or animation state.
- Claim that the current W/S/A/D sandbox mapping exactly matches build 12340.
- Make sprint or free flight available to the production compatibility profile;
their profile gate remains a later M02 package.
- Suppress debug action sampling by physical device or UI context; execution is
rejected at the typed movement capability boundary.
## Context and boundaries
@@ -31,9 +31,10 @@ flowchart LR
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
InputMap --> Source[PlayerInputSource]
Source --> Intent[MoveIntent]
Intent --> Controller[ThirdPersonWowController]
Controller --> Movement[Current sandbox movement]
Controller --> Camera[Current camera adapter]
Intent --> Movement[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Movement
Movement --> Controller[ThirdPersonWowController adapter]
Controller --> Camera[ThirdPersonCameraRig]
```
Allowed dependencies:
@@ -63,15 +64,16 @@ 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 |
| Output | `MoveIntent` | `PlayerInputSource` | Current controller; later local movement controller | Immutable caller-held value | One physics tick |
| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime |
| 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 | Debug sprint request/vertical axes | `PlayerInputSource` | Capability-gated movement controller | Immutable intent fields | One physics tick |
| 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,16 +83,21 @@ 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[ThirdPersonWowController]
Intent --> Consumer[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Consumer
Consumer --> Adapter[ThirdPersonWowController]
```
## Lifecycle/state
The adapter is stateless. The scene composition root creates one
`PlayerInputSource` in `_ready`; each physics tick produces a new immutable
intent. Flight enabled/disabled state remains owned by the current sandbox
controller and is not hidden in the input adapter.
`PlayerInputSource` and `LocalPlayerMovementController` in `_ready`; each physics
tick produces a new immutable intent. Flight enabled/disabled state is owned by
the movement controller and is not hidden in the input adapter. Debug requests
remain data in the intent; the immutable movement capability value decides
whether they execute.
## Main sequence
@@ -99,11 +106,13 @@ sequenceDiagram
participant Engine as Godot Input
participant Source as PlayerInputSource
participant Intent as MoveIntent
participant Player as ThirdPersonWowController
participant Move as LocalPlayerMovementController
participant Player as ThirdPersonWowController adapter
Engine->>Source: movement action strengths
Source->>Intent: compose normalized axes and requests
Source-->>Player: sample_move_intent()
Player->>Player: update current sandbox movement state
Player->>Move: advance(intent, selected basis, delta)
Move-->>Player: displacement and velocity state
```
## Ownership, threading and resources
@@ -123,6 +132,7 @@ sequenceDiagram
| Conflicting opposite actions | Pure composition | Strengths cancel deterministically | Covered by contract test | Release/remap one action |
| Strength outside range | Pure composition | Value clamps to `[0, 1]` before axis calculation | Covered by contract test | Input adapter may be corrected independently |
| Controller disabled/unloaded | Scene lifecycle | Sampling stops with physics processing | Existing scene lifecycle | Re-enable or recreate scene |
| Debug request in `Blizzlike335` | Movement capability check | Request is sampled but has no movement effect | Capability regression | Use `RenderSandbox` only when debug movement is intended |
There is no asynchronous operation to cancel and no persisted state to roll back.
@@ -134,8 +144,8 @@ There is no asynchronous operation to cancel and no persisted state to roll back
| Strafe left/right | Physical A/D | Current render sandbox | Yes | Produces signed right axis |
| Camera rotate | Right mouse button | Current render sandbox | Yes | Captures/releases mouse for orbit |
| Camera zoom | Mouse wheel | Current render sandbox | Yes | Adjusts current camera distance |
| Debug sprint | Shift | Sandbox debug only by intent; profile gate pending | Yes | Requests existing speed multiplier |
| Debug flight up/down/toggle | E/Q/Space | Sandbox debug only by intent; profile gate pending | Yes | Requests existing free-flight controls |
| Debug sprint | Shift | Executable only in `RenderSandbox` | Yes | Requests multiplier; `Blizzlike335` ignores it |
| Debug flight up/down/toggle | E/Q/Space | Executable only in `RenderSandbox` | Yes | Requests free flight; `Blizzlike335` remains grounded |
## Persistence, cache and migration
@@ -158,6 +168,8 @@ provide a settings migration once user settings are persisted.
the verifier, which drives forward, camera-zoom and immediate sandbox-flight
actions through the real scene controller and also rejects direct
physical-key polling.
- Profile integration: `verify_player_movement_capabilities.gd` drives sprint
and flight actions through real `RenderSandbox` and `Blizzlike335` scene instances.
- Fidelity evidence: current sandbox defaults retain W/S/A/D, E/Q, Shift, Space,
right mouse and wheel bindings. This is observable-regression coverage only.
- Performance budgets: no I/O, jobs or scene lookup per sample; one immutable
@@ -171,25 +183,25 @@ provide a settings migration once user settings are persisted.
movement consumers.
- A later input-context owner may suppress gameplay actions while preserving the
same intent contract.
- The local movement controller may replace the current scene consumer without
changing `PlayerInputSource`.
- A future server-aware movement predictor may replace the current local
movement consumer without changing `PlayerInputSource`.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Extract local movement controller |
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Local movement consumer is now extracted |
| Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy |
| Production profile exclusion of sprint/flight | Partial | Debug actions are named explicitly | Add typed profile/capability gate in M02 |
| Compatibility-profile exclusion of sprint/flight | Implemented | Typed capability and two real-scene regressions | Application shell selection remains future work |
| Jump/fall/swim/fly gameplay semantics | Planned | M02 target and gameplay roadmap | Implement after terrain/movement state extraction |
## Known gaps and risks
- 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.
- Input Map still exposes debug bindings process-wide; the movement consumer now
rejects their effects outside `RenderSandbox`.
- Camera state is extracted; active collision remains an explicit planned policy.
- There is no persisted user keybinding format or UI yet.
## Source map
@@ -200,9 +212,13 @@ provide a settings migration once user settings are persisted.
| `src/domain/input/move_intent.gd` | Immutable locomotion request contract |
| `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/scenes/player/third_person_wow_controller.gd` | Current consumer and camera-event adapter |
| `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
| `src/gameplay/movement/player_movement_capabilities.gd` | Execution gate for sampled debug requests |
| `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 |
| `src/tools/verify_player_movement_capabilities.gd` | Sandbox versus compatibility-profile integration regression |
## Related decisions and references
+251
View File
@@ -0,0 +1,251 @@
# Terrain Query
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001 |
| Owners | Gameplay terrain-query contract; ADT adapter owns parsed tile cache |
| Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | Current sandbox ADT ground height; renderer/physics backend planned |
## Purpose
Provide a replaceable, scene-free ground-height query for player movement.
Isolate native ADT loading, per-tile caching, chunk selection and legacy outer
height-grid interpolation from the player scene behind a typed
`GodotWorldPosition → TerrainGroundSample` contract.
## Non-goals
- Own player transforms, movement velocity or ground-snap policy.
- Provide collision normals, slopes, holes, liquids, WMO/M2 collision or ray queries.
- Change ADT topology, coordinate conversion, renderer caches or native parser format.
- Claim original-client terrain-contact parity from height regression alone.
- Couple gameplay to the monolithic streaming renderer before a public backend exists.
## Context and boundaries
```mermaid
flowchart LR
Scene[ThirdPersonWowController adapter] --> Position[GodotWorldPosition]
Position --> Contract[TerrainQuery]
Contract --> Adapter[AdtTerrainQuery]
Adapter --> Mapper[CoordinateMapper]
Adapter --> Native[ADTLoader]
Native --> Tile[ADT tile Dictionary]
Tile --> Cache[Owned tile cache]
Cache --> Sample[TerrainGroundSample]
Sample --> Scene
Scene --> Snap[Existing ground-snap policy]
```
Allowed dependencies:
- Typed `GodotWorldPosition` and `CoordinateMapper` contracts.
- Existing native `ADTLoader` at the ADT adapter boundary.
- Dynamic dictionaries only inside the native-format adapter.
- Player scene composition may inject any `TerrainQuery` implementation.
Forbidden dependencies:
- `TerrainQuery` contract depending on Nodes, cameras, input or renderer state.
- Player scene loading/parsing ADT data or owning a parsed tile cache.
- Manual WoW/Godot/ADT world-coordinate conversion outside `CoordinateMapper`.
- Terrain query mutating transforms, gameplay movement state or visual meshes.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `TerrainGroundSample.available(height_units)` | Immutable value factory | Creates a finite Godot-world ground height | Any thread; caller-held value | Non-finite height becomes unavailable |
| `TerrainGroundSample.unavailable(failure_code)` | Immutable value factory | Creates explicit unavailable result | Any thread; caller-held value | Empty code normalizes to `terrain_unavailable` |
| `TerrainQuery.sample_ground_height(position)` | Replaceable boundary | Samples ground at typed Godot world position | Implementation-defined; current adapter main-thread owned | Base reports `terrain_query_not_implemented` |
| `AdtTerrainQuery.new(extracted_directory, map_directory_name, loader_override)` | Adapter constructor | Configures one map and optional test/data-source loader | Query lifetime | Production validates native loader and file presence lazily |
| `AdtTerrainQuery.sample_ground_height(position)` | Adapter method | Loads/caches tile and interpolates ADT outer heights | Main thread; cache lives with query | Returns stable failure code |
| `ThirdPersonWowController.set_terrain_query(query)` | Composition method | Replaces terrain backend independently of movement/presentation | Call before scene ready or on main thread | Null is rejected and current query retained |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `GodotWorldPosition` | Player scene adapter | `TerrainQuery` | Immutable caller-held value | One query |
| Input | Extracted directory/map name | Scene composition | `AdtTerrainQuery` | Copied strings | Query lifetime |
| Input | ADT file/dictionary | Filesystem/native loader or injected loader | `AdtTerrainQuery` | Adapter-owned cached dictionary | Query lifetime |
| Output | `TerrainGroundSample` | Terrain query | Player ground-snap adapter | Immutable caller-held value | One query |
| Output | Failure code | Terrain query | Diagnostics/tests | Immutable `StringName` | Sample lifetime |
Side effects:
- Production adapter performs lazy file existence check and native ADT parse on first tile query.
- Adapter mutates only its per-query tile/failure caches.
- No scene tree, transform, renderer RID, network, database or persisted setting mutation occurs.
## Data flow
```mermaid
flowchart LR
Position[GodotWorldPosition] --> TileCoord[CoordinateMapper tile/local/chunk]
TileCoord --> Lookup{Tile cached?}
Lookup -->|no| Load[ADTLoader or injected loader]
Load --> Cache[Cache Dictionary or unavailable result]
Lookup -->|yes| Cache
Cache --> Chunk[Select MCNK by origin]
Chunk --> Heights[Validate 145 heights]
Heights --> Bilinear[Interpolate 9x9 outer grid]
Bilinear --> Available[TerrainGroundSample available]
Cache -->|missing/invalid| Unavailable[TerrainGroundSample unavailable]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> TileUnknown
TileUnknown --> TileAvailable: first query / successful load
TileUnknown --> TileUnavailable: first query / missing or failed
TileAvailable --> TileAvailable: cached queries
TileUnavailable --> TileUnavailable: cached queries
TileAvailable --> [*]: query released
TileUnavailable --> [*]: query released
```
Unavailable tiles are cached intentionally to avoid repeated filesystem/native
work each physics tick. Recovery after files appear requires replacing the query
instance; hot-reload invalidation is outside this M02 package.
## Main sequence
```mermaid
sequenceDiagram
participant Player as ThirdPersonWowController
participant Query as TerrainQuery
participant ADT as AdtTerrainQuery
participant Mapper as CoordinateMapper
participant Native as ADTLoader
Player->>Query: sample_ground_height(GodotWorldPosition)
Query->>ADT: implementation dispatch
ADT->>Mapper: tile/local/chunk coordinates
alt tile not cached
ADT->>Native: load_adt(absolute path)
Native-->>ADT: ADT Dictionary
end
ADT->>ADT: select chunk and interpolate heights
ADT-->>Player: TerrainGroundSample
Player->>Player: apply existing offset/snap if available
```
## Ownership, threading and resources
- Player scene owns the active `TerrainQuery` reference.
- `AdtTerrainQuery` exclusively owns tile/failure dictionaries until released.
- Current production calls and cache mutations occur on the main physics thread.
- Native loader result dictionaries do not cross the adapter boundary.
- Samples and typed positions are immutable caller-held values.
- The module creates no Nodes, Resources, RIDs or background jobs.
- Future worker-backed queries require an explicit async/result contract rather
than concurrent mutation of the current dictionaries.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Base contract used directly | Base method | Unavailable sample | `terrain_query_not_implemented` | Inject implementation |
| Native loader absent | ClassDB check | Cache unavailable tile | `adt_loader_unavailable` | Load extension and replace query |
| ADT file absent | File check | Cache unavailable tile | `adt_source_missing` | Restore corpus and replace query |
| Native parse empty/fails | Result validation | Cache unavailable tile | `adt_load_failed` | Inspect source/parser; replace query |
| Injected loader returns wrong type | Type check | Cache unavailable tile | `adt_loader_result_invalid` | Fix adapter |
| Chunk absent | Chunk lookup | Unavailable sample | `adt_chunk_missing` | Validate ADT dictionary |
| Height array shorter than 145 | Size check | Unavailable sample | `adt_chunk_heights_invalid` | Fix parser/source |
| Non-finite interpolated height | Sample invariant | Unavailable sample | `terrain_height_not_finite` | Inspect source values |
| Null query injection | Scene guard | Existing query retained | Player composition error | Pass valid query |
There are no asynchronous operations to cancel. Replacing/releasing the query
is the deterministic cache-reset and recovery path.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Extracted directory | Player `extracted_dir` | Current sandbox | Captured at creation | ADT source root |
| Map directory name | Player `map_name` | Current sandbox | Captured at creation | ADT filename/path |
| Loader override | Invalid callable | Test/custom data source | Constructor only | Bypasses production ClassDB/file loading |
| Tile cache | Enabled | All current uses | Cleared by replacing query | Avoids repeated parse/failure work |
| Height policy | 9x9 outer-grid bilinear | Current sandbox | Fixed | Preserves pre-extraction behavior |
## Persistence, cache and migration
The cache is in-memory and unversioned because it holds native loader output for
one query lifetime. No disk cache, schema or migration changes are introduced.
Existing ADT/native/cache format versions remain unchanged.
## Diagnostics and observability
- Logs: dedicated verifier emits `TERRAIN_QUERY PASS` or named failures.
- Structured status: every unavailable sample has a stable `failure_code`.
- Metrics: synthetic verifier counts loader calls to prove one load per tile.
- Debug views: none.
- Correlation IDs: not applicable to synchronous local query.
## Verification
- Contract: available/unavailable/base/non-finite result invariants.
- Pure synthetic adapter: typed coordinate mapping, exact chunk selection,
145-height layout and bilinear interpolation within `0.002` units (Vector3
source precision), plus cached second query.
- Failures: empty tile and invalid height array produce stable codes.
- Integration: asset-free player scene accepts injected flat query and preserves
spawn offset `0.05` plus physics ground snap.
- Boundary: source regression rejects ADT loader/cache/chunk helpers in player.
- Fidelity evidence: existing interpolation and snap behavior only; no
original-client or collision parity claim.
- Performance budget: one dictionary cache lookup and scalar interpolation per
cached query; native parse only on first tile access.
## Extension points
- Streaming renderer or physics backend may implement `TerrainQuery` without changing player/movement.
- Future result may grow separate slope, surface, liquid or collision contracts;
incompatible additions require a versioned contract rather than Dictionary fields.
- Test/authoring data sources may use constructor loader override.
- Async streaming requires a separate availability/update model.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Typed replaceable ground-height query | Implemented | Contract and injected player regression | Integrate target checklist after review |
| Existing ADT height interpolation | Implemented | Synthetic fractional-grid fixture | Compare terrain contact with build 12340 |
| Per-tile success/failure cache | Implemented | Loader call-count regression | Add explicit invalidation only with authoring/hot reload |
| Renderer/streaming cache backend | Planned | Boundary permits replacement | M03 renderer facade/public terrain backend |
| Holes/slopes/collision | Planned | Outside height-only contract | Later movement/physics package |
| Liquid/swim query | Planned | Outside contract | M09/M12 world gameplay |
## Known gaps and risks
- Bilinear outer-grid height is the inherited sandbox approximation; exact ADT
triangle choice, holes and collision are not represented.
- Query is synchronous; first access may parse an ADT on the main thread exactly
as before extraction. A streaming/backend replacement is future work.
- Unavailable results remain cached until query replacement.
- Player still owns ground offset/snap policy; only data access is extracted.
- Existing native dictionaries remain dynamic inside the adapter.
## Source map
| Path | Responsibility |
|---|---|
| `src/gameplay/terrain/terrain_ground_sample.gd` | Immutable available/unavailable result |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable typed query boundary |
| `src/gameplay/terrain/adt_terrain_query.gd` | Native ADT adapter, cache, chunk selection and interpolation |
| `src/scenes/player/third_person_wow_controller.gd` | Query composition and existing ground-snap consumer |
| `src/tools/verify_terrain_query.gd` | Synthetic contract/cache/failure/injection regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free player composition fixture |
## Related decisions and references
- ADR: none; no coordinate, format or authority decision changed.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+253
View File
@@ -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 | `8c1cf9b`, 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,115 @@
class_name LocalPlayerMovementController
extends RefCounted
## Scene-free state and velocity integrator for the current local sandbox player.
## Direction inputs use a Godot-world movement basis selected by the scene
## adapter. This class does not discover Nodes, query terrain or mutate transforms.
var godot_world_velocity_units_per_second: Vector3:
get:
return _godot_world_velocity_units_per_second
var is_flight_enabled: bool:
get:
return _is_flight_enabled
var movement_profile_id: StringName:
get:
return _movement_capabilities.profile_id
var _run_speed_units_per_second: float
var _backward_speed_units_per_second: float
var _strafe_speed_units_per_second: float
var _flight_vertical_speed_units_per_second: float
var _acceleration_units_per_second_squared: float
var _sandbox_sprint_multiplier: float
var _movement_capabilities: PlayerMovementCapabilities
var _godot_world_velocity_units_per_second := Vector3.ZERO
var _is_flight_enabled := false
## Creates a movement state with explicit Godot-unit speeds, acceleration and capabilities.
## The current compatibility renderer treats one Godot unit as one WoW yard.
## A null capability input fails closed to Blizzlike335 debug permissions.
func _init(
run_speed_units_per_second: float,
backward_speed_units_per_second: float,
strafe_speed_units_per_second: float,
flight_vertical_speed_units_per_second: float,
acceleration_units_per_second_squared: float,
sandbox_sprint_multiplier: float,
movement_capabilities: PlayerMovementCapabilities = null
) -> void:
_run_speed_units_per_second = run_speed_units_per_second
_backward_speed_units_per_second = backward_speed_units_per_second
_strafe_speed_units_per_second = strafe_speed_units_per_second
_flight_vertical_speed_units_per_second = flight_vertical_speed_units_per_second
_acceleration_units_per_second_squared = acceleration_units_per_second_squared
_sandbox_sprint_multiplier = sandbox_sprint_multiplier
_movement_capabilities = (
movement_capabilities
if movement_capabilities != null
else PlayerMovementCapabilities.blizzlike_335()
)
## Toggles sandbox free flight and clears velocity when the profile permits it.
## Returns the resulting state; rejected compatibility-profile requests return false.
func toggle_sandbox_flight() -> bool:
if not _movement_capabilities.allows_debug_free_flight:
return false
_is_flight_enabled = not _is_flight_enabled
_godot_world_velocity_units_per_second = Vector3.ZERO
return _is_flight_enabled
## Advances velocity by one physics step and returns local-world displacement.
## `godot_world_movement_basis` is the player basis on ground and camera-pivot basis in flight.
## Negative delta values are treated as zero and do not mutate velocity.
func advance(
move_intent: MoveIntent,
godot_world_movement_basis: Basis,
delta_seconds: float
) -> Vector3:
var safe_delta_seconds := maxf(delta_seconds, 0.0)
if is_zero_approx(safe_delta_seconds):
return Vector3.ZERO
var target_velocity := _calculate_target_velocity(move_intent, godot_world_movement_basis)
_godot_world_velocity_units_per_second = _godot_world_velocity_units_per_second.move_toward(
target_velocity,
_acceleration_units_per_second_squared * safe_delta_seconds
)
return _godot_world_velocity_units_per_second * safe_delta_seconds
func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_basis: Basis) -> Vector3:
var forward_direction := -godot_world_movement_basis.z
var right_direction := godot_world_movement_basis.x
if not _is_flight_enabled:
forward_direction.y = 0.0
right_direction.y = 0.0
forward_direction = forward_direction.normalized()
right_direction = right_direction.normalized()
var forward_speed := (
_run_speed_units_per_second
if move_intent.forward_axis > 0.0
else _backward_speed_units_per_second
)
var speed_multiplier := (
_sandbox_sprint_multiplier
if move_intent.is_sprint_requested and _movement_capabilities.allows_debug_sprint
else 1.0
)
var target_velocity := (
forward_direction * move_intent.forward_axis * forward_speed
+ right_direction * move_intent.strafe_axis * _strafe_speed_units_per_second
) * speed_multiplier
if _is_flight_enabled:
target_velocity.y += (
move_intent.vertical_axis
* _flight_vertical_speed_units_per_second
* speed_multiplier
)
return target_velocity
@@ -0,0 +1 @@
uid://dxlx5kloh7p5c
@@ -0,0 +1,53 @@
class_name PlayerMovementCapabilities
extends RefCounted
## Immutable local-player movement capability selection.
## Unknown or missing profile identifiers collapse to the safe Blizzlike335
## capability set; only RenderSandbox enables debug sprint and free flight.
const BLIZZLIKE_335_PROFILE_ID: StringName = &"Blizzlike335"
const RENDER_SANDBOX_PROFILE_ID: StringName = &"RenderSandbox"
var profile_id: StringName:
get:
return _profile_id
var allows_debug_sprint: bool:
get:
return _allows_debug_sprint
var allows_debug_free_flight: bool:
get:
return _allows_debug_free_flight
var _profile_id: StringName
var _allows_debug_sprint: bool
var _allows_debug_free_flight: bool
func _init(requested_profile_id: StringName = BLIZZLIKE_335_PROFILE_ID) -> void:
if requested_profile_id == RENDER_SANDBOX_PROFILE_ID:
_profile_id = RENDER_SANDBOX_PROFILE_ID
_allows_debug_sprint = true
_allows_debug_free_flight = true
return
_profile_id = BLIZZLIKE_335_PROFILE_ID
_allows_debug_sprint = false
_allows_debug_free_flight = false
## Returns the debug-enabled capability set used by current renderer scenes.
static func render_sandbox() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(RENDER_SANDBOX_PROFILE_ID)
## Returns the compatibility-safe capability set with debug movement disabled.
## This name identifies the intended compatibility profile, not proven 1:1 movement.
static func blizzlike_335() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(BLIZZLIKE_335_PROFILE_ID)
## Maps scene/application profile configuration to a known capability set.
## Unknown identifiers fail closed to Blizzlike335.
static func for_profile_id(requested_profile_id: StringName) -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(requested_profile_id)
@@ -0,0 +1 @@
uid://cdnulu22drbdw
+166
View File
@@ -0,0 +1,166 @@
class_name AdtTerrainQuery
extends TerrainQuery
## TerrainQuery adapter for existing native ADT dictionaries.
## Parsed tiles, including unavailable results, are cached for this query's
## lifetime. The adapter is main-thread owned and creates no scene resources.
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const CHUNK_SIZE_UNITS := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const OUTER_GRID_UNIT_SIZE := CHUNK_SIZE_UNITS / 8.0
var _extracted_directory: String
var _map_directory_name: String
var _adt_tile_loader_override: Callable
var _adt_tile_cache: Dictionary = {}
var _failure_code_by_tile_key: Dictionary = {}
## Creates an ADT query for one map. `adt_tile_loader_override` is an optional
## test/data-source seam receiving the absolute ADT path and returning a Dictionary.
func _init(
extracted_directory: String,
map_directory_name: String,
adt_tile_loader_override: Callable = Callable()
) -> void:
_extracted_directory = extracted_directory
_map_directory_name = map_directory_name
_adt_tile_loader_override = adt_tile_loader_override
## Samples the existing 9x9 outer-height grid with bilinear interpolation.
func sample_ground_height(godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(godot_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(
tile_coordinate,
tile_local_position
)
var tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y)
var adt_tile_data := _load_adt_tile(tile_coordinate.tile_x, tile_coordinate.tile_y)
if adt_tile_data.is_empty():
return TerrainGroundSample.unavailable(
_failure_code_by_tile_key.get(tile_key, &"adt_tile_unavailable")
)
var tile_origin := _find_tile_origin(adt_tile_data)
var chunk := _find_chunk(
adt_tile_data,
clampi(chunk_coordinate.chunk_x, 0, 15),
clampi(chunk_coordinate.chunk_y, 0, 15),
tile_origin
)
if chunk.is_empty():
return TerrainGroundSample.unavailable(&"adt_chunk_missing")
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return TerrainGroundSample.unavailable(&"adt_chunk_heights_invalid")
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local_x := godot_world_position.x_units - chunk_origin.x
var chunk_local_z := godot_world_position.z_units - chunk_origin.z
var grid_x := clampf(chunk_local_x / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var grid_z := clampf(chunk_local_z / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(grid_x)), 0, 8)
var z0 := clampi(int(floor(grid_z)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fraction_x := grid_x - float(x0)
var fraction_z := grid_z - float(z0)
var height_x0 := lerpf(
heights[_outer_height_index(z0, x0)],
heights[_outer_height_index(z0, x1)],
fraction_x
)
var height_x1 := lerpf(
heights[_outer_height_index(z1, x0)],
heights[_outer_height_index(z1, x1)],
fraction_x
)
return TerrainGroundSample.available(
chunk_origin.y + lerpf(height_x0, height_x1, fraction_z)
)
func _load_adt_tile(tile_x: int, tile_y: int) -> Dictionary:
var tile_key := _tile_key(tile_x, tile_y)
if _adt_tile_cache.has(tile_key):
return _adt_tile_cache[tile_key]
var resource_path := _extracted_directory.path_join(
"World/Maps/%s/%s_%d_%d.adt" % [_map_directory_name, _map_directory_name, tile_x, tile_y]
)
var absolute_path := ProjectSettings.globalize_path(resource_path)
var adt_tile_data: Dictionary = {}
var failure_code: StringName = &""
if _adt_tile_loader_override.is_valid():
var override_result = _adt_tile_loader_override.call(absolute_path)
if override_result is Dictionary:
adt_tile_data = override_result
else:
failure_code = &"adt_loader_result_invalid"
elif not ClassDB.class_exists("ADTLoader"):
failure_code = &"adt_loader_unavailable"
elif not FileAccess.file_exists(absolute_path):
failure_code = &"adt_source_missing"
else:
var loader = ClassDB.instantiate("ADTLoader")
if loader != null:
var load_result = loader.call("load_adt", absolute_path)
if load_result is Dictionary:
adt_tile_data = load_result
if adt_tile_data.is_empty():
failure_code = &"adt_load_failed"
if adt_tile_data.is_empty() and failure_code.is_empty():
failure_code = &"adt_tile_unavailable"
_adt_tile_cache[tile_key] = adt_tile_data
_failure_code_by_tile_key[tile_key] = failure_code
return adt_tile_data
func _find_chunk(
adt_tile_data: Dictionary,
chunk_x: int,
chunk_y: int,
tile_origin: Vector3
) -> Dictionary:
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local_origin := chunk_origin - tile_origin
var candidate_chunk_x := clampi(int(round(local_origin.x / CHUNK_SIZE_UNITS)), 0, 15)
var candidate_chunk_y := clampi(int(round(local_origin.z / CHUNK_SIZE_UNITS)), 0, 15)
if candidate_chunk_x == chunk_x and candidate_chunk_y == chunk_y:
return chunk
return {}
func _find_tile_origin(adt_tile_data: Dictionary) -> Vector3:
var has_origin := false
var minimum_x := 0.0
var minimum_z := 0.0
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not has_origin:
minimum_x = chunk_origin.x
minimum_z = chunk_origin.z
has_origin = true
else:
minimum_x = minf(minimum_x, chunk_origin.x)
minimum_z = minf(minimum_z, chunk_origin.z)
return Vector3(minimum_x, 0.0, minimum_z) if has_origin else Vector3.ZERO
func _tile_key(tile_x: int, tile_y: int) -> String:
return "%d_%d" % [tile_x, tile_y]
func _outer_height_index(row: int, column: int) -> int:
return row * 17 + column
@@ -0,0 +1 @@
uid://cq7scvxqw6ffm
@@ -0,0 +1,49 @@
class_name TerrainGroundSample
extends RefCounted
## Immutable result of a terrain ground-height query in Godot world units.
## An unavailable sample carries a stable failure code instead of overloading a
## numeric height with `NAN` at the public boundary.
var is_available: bool:
get:
return _is_available
var height_units: float:
get:
return _height_units
var failure_code: StringName:
get:
return _failure_code
var _is_available: bool
var _height_units: float
var _failure_code: StringName
## Creates an available ground sample in Godot world units.
static func available(height_units_value: float) -> TerrainGroundSample:
return TerrainGroundSample.new(true, height_units_value, &"")
## Creates an unavailable sample with a stable diagnostic code.
static func unavailable(failure_code_value: StringName) -> TerrainGroundSample:
return TerrainGroundSample.new(false, 0.0, failure_code_value)
## Normalizes factory inputs so available samples are finite and unavailable
## samples always carry a non-empty failure code.
func _init(
is_available_value: bool,
height_units_value: float,
failure_code_value: StringName
) -> void:
_is_available = is_available_value and is_finite(height_units_value)
_height_units = height_units_value if _is_available else 0.0
if _is_available:
_failure_code = &""
elif not is_finite(height_units_value):
_failure_code = &"terrain_height_not_finite"
else:
_failure_code = failure_code_value if not failure_code_value.is_empty() else &"terrain_unavailable"
@@ -0,0 +1 @@
uid://boe5qkip6ffku
+12
View File
@@ -0,0 +1,12 @@
class_name TerrainQuery
extends RefCounted
## Replaceable scene-free boundary for gameplay terrain height sampling.
## Implementations receive a typed Godot world position and must return an
## explicit available/unavailable result without mutating the caller.
## Samples terrain ground height at a typed Godot world position.
## The base implementation reports `terrain_query_not_implemented`.
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.unavailable(&"terrain_query_not_implemented")
@@ -0,0 +1 @@
uid://d3f2hetuo342q
@@ -0,0 +1,95 @@
class_name CharacterAnimationPresenter
extends Node
## Selects and plays the current sandbox idle or moving animation.
## Animation import, movement simulation and model composition remain external.
const MOVING_ANIMATION_CANDIDATES: Array[String] = ["Run", "Walk"]
const STATIONARY_ANIMATION_CANDIDATES: Array[String] = ["Stand", "Idle"]
## Cross-fade time retained from the current sandbox controller.
@export var animation_blend_time_seconds: float = 0.15
var active_animation_name: String:
get:
return _active_animation_name
var has_animation_player: bool:
get:
return _animation_player != null and is_instance_valid(_animation_player)
var _animation_player: AnimationPlayer
var _active_animation_name := ""
## Finds the first AnimationPlayer below a newly composed character root.
## Binding null or a root without animations clears the current presentation.
func bind_character_root(character_root: Node) -> bool:
_animation_player = (
_find_animation_player(character_root)
if character_root != null and is_instance_valid(character_root)
else null
)
_active_animation_name = ""
if _animation_player == null:
return false
_prepare_animation_player(_animation_player)
present_locomotion(false)
return true
## Presents the current moving/stationary locomotion state.
## Returns true only when a different animation starts playing.
func present_locomotion(is_moving: bool) -> bool:
if _animation_player == null or not is_instance_valid(_animation_player):
_animation_player = null
_active_animation_name = ""
return false
var candidates := MOVING_ANIMATION_CANDIDATES if is_moving else STATIONARY_ANIMATION_CANDIDATES
var selected_animation := _choose_animation(candidates)
if selected_animation.is_empty() or selected_animation == _active_animation_name:
return false
_active_animation_name = selected_animation
_animation_player.play(selected_animation, animation_blend_time_seconds)
return true
func _prepare_animation_player(animation_player: AnimationPlayer) -> void:
animation_player.playback_default_blend_time = animation_blend_time_seconds
for animation_name in animation_player.get_animation_list():
var lowercase_name := String(animation_name).to_lower()
if (
lowercase_name == "stand"
or lowercase_name == "idle"
or lowercase_name == "run"
or lowercase_name == "walk"
or lowercase_name.contains("stand")
or lowercase_name.contains("run")
or lowercase_name.contains("walk")
):
var animation := animation_player.get_animation(animation_name)
if animation != null:
animation.loop_mode = Animation.LOOP_LINEAR
func _choose_animation(candidates: Array[String]) -> String:
for candidate in candidates:
if _animation_player.has_animation(candidate):
return candidate
var animation_names := _animation_player.get_animation_list()
for candidate in candidates:
var lowercase_candidate := candidate.to_lower()
for animation_name in animation_names:
if String(animation_name).to_lower().contains(lowercase_candidate):
return String(animation_name)
return ""
func _find_animation_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root
for child in root.get_children():
var animation_player := _find_animation_player(child)
if animation_player != null:
return animation_player
return null
@@ -0,0 +1 @@
uid://c2jnn0e3mmymc
@@ -0,0 +1,150 @@
class_name CharacterAppearancePresenter
extends Node3D
## Composes one sandbox character model, texture compositor and starter outfit.
## The presenter owns only nodes below the existing Visual scene boundary.
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
signal character_appearance_ready(character_root: Node3D)
## Packed character scene used by the current render sandbox.
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
## Character class used when resolving the current starter outfit.
@export var character_class_id: int = 1
## Uniform scale applied to the composed character root.
@export var character_scale: float = 1.0
## Model-space yaw correction retained from the current sandbox.
@export var character_yaw_offset_degrees: float = 90.0
## Root containing extracted DBC and item texture inputs.
@export var extracted_data_directory: String = "res://data/extracted"
var character_root: Node3D:
get:
return _character_root
var _character_root: Node3D
## Loads and composes the configured character model below this Visual node.
## A non-empty directory argument keeps the player's terrain/content root aligned.
## Returns false immediately when no model is configured or the resource is invalid.
func load_character_appearance(content_root_override: String = "") -> bool:
if not content_root_override.is_empty():
extracted_data_directory = content_root_override
_clear_character_root()
if character_model_path.is_empty():
return false
var character_scene := load(character_model_path) as PackedScene
if character_scene == null:
push_warning("CharacterAppearancePresenter: cannot load character model: %s" % character_model_path)
return false
if is_class("MeshInstance3D"):
set("mesh", null)
position = Vector3.ZERO
_character_root = Node3D.new()
_character_root.name = "CharacterModel"
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
_character_root.rotation_degrees.y = character_yaw_offset_degrees
var character_instance := character_scene.instantiate()
character_instance.name = character_model_path.get_file().get_basename()
_character_root.add_child(character_instance)
var texture_compositor := Node.new()
texture_compositor.name = "CharacterTextureCompositor"
texture_compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
texture_compositor.set("textures_dir", _character_textures_directory(character_model_path))
texture_compositor.set("extracted_dir", extracted_data_directory)
_character_root.add_child(texture_compositor)
add_child(_character_root)
_complete_character_composition.call_deferred(_character_root, texture_compositor)
return true
func _complete_character_composition(expected_character_root: Node3D, texture_compositor: Node) -> void:
if expected_character_root != _character_root or not is_instance_valid(expected_character_root):
return
_fit_character_to_ground(expected_character_root)
_apply_character_starter_outfit(expected_character_root, texture_compositor)
character_appearance_ready.emit(expected_character_root)
func _clear_character_root() -> void:
if _character_root == null or not is_instance_valid(_character_root):
_character_root = null
return
remove_child(_character_root)
_character_root.queue_free()
_character_root = null
func _fit_character_to_ground(root: Node3D) -> void:
var character_bounds := _calculate_global_bounds(root)
if character_bounds.size == Vector3.ZERO:
return
var local_minimum_y := character_bounds.position.y - root.global_position.y
root.position.y -= local_minimum_y
func _apply_character_starter_outfit(root: Node3D, texture_compositor: Node) -> void:
if texture_compositor == null:
return
var outfit_resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
if not outfit_resolver.call("load_dbcs", extracted_data_directory):
return
var inferred_identity: Dictionary = outfit_resolver.call(
"infer_race_gender_from_model_path",
character_model_path
)
var race_name := String(inferred_identity.get("race", "Human"))
var gender_name := String(inferred_identity.get("gender", "Male"))
var starter_outfit: Dictionary = outfit_resolver.call(
"resolve_start_outfit",
race_name,
gender_name,
character_class_id
)
if starter_outfit.is_empty():
return
if texture_compositor.has_method("set_equipment_components"):
texture_compositor.call(
"set_equipment_components",
starter_outfit.get("textures", PackedStringArray())
)
if root.has_method("apply_outfit_defaults"):
root.call("apply_outfit_defaults", starter_outfit)
func _calculate_global_bounds(root: Node) -> AABB:
var combined_bounds := AABB()
var has_bounds := false
for mesh_instance in _mesh_instances_below(root):
if mesh_instance.mesh == null:
continue
var global_bounds := mesh_instance.global_transform * mesh_instance.mesh.get_aabb()
combined_bounds = global_bounds if not has_bounds else combined_bounds.merge(global_bounds)
has_bounds = true
return combined_bounds if has_bounds else AABB()
func _mesh_instances_below(root: Node) -> Array[MeshInstance3D]:
var mesh_instances: Array[MeshInstance3D] = []
_collect_mesh_instances(root, mesh_instances)
return mesh_instances
func _collect_mesh_instances(node: Node, mesh_instances: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
mesh_instances.append(node)
for child in node.get_children():
_collect_mesh_instances(child, mesh_instances)
func _character_textures_directory(model_path: String) -> String:
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
@@ -0,0 +1 @@
uid://bhqbo1ftrt3a3
@@ -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
+78 -360
View File
@@ -1,17 +1,14 @@
extends CharacterBody3D
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const UNIT_SIZE := CHUNK_SIZE / 8.0
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const PLAYER_MOVEMENT_CAPABILITIES_SCRIPT := preload("res://src/gameplay/movement/player_movement_capabilities.gd")
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
@export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth"
@@ -25,389 +22,110 @@ const UNIT_SIZE := CHUNK_SIZE / 8.0
@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
## Local composition profile. RenderSandbox preserves debug sprint/free flight;
## Blizzlike335 rejects them but does not yet claim complete movement parity.
@export_enum("RenderSandbox", "Blizzlike335") var movement_profile_id: String = "RenderSandbox"
@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
@export var character_scale: float = 1.0
@export var character_yaw_offset_degrees: float = 90.0
@export var animation_blend_time: float = 0.15
@export var animation_presenter_path: NodePath = NodePath("Visual/AnimationPresenter")
var _camera_pivot: Node3D
var _camera: Camera3D
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 _horizontal_velocity := Vector3.ZERO
var _flight_enabled := false
var _adt_cache: Dictionary = {}
var _camera_rig: ThirdPersonCameraRig
var _appearance_presenter: CharacterAppearancePresenter
var _animation_presenter: CharacterAnimationPresenter
var _player_input_source: PlayerInputSource
var _local_movement_controller: LocalPlayerMovementController
var _terrain_query: TerrainQuery
## Replaces the ground-height backend without changing player movement or presentation.
## Passing null is a composition error and leaves the current query unchanged.
func set_terrain_query(terrain_query: TerrainQuery) -> void:
if terrain_query == null:
push_error("ThirdPersonWowController: terrain query cannot be null")
return
_terrain_query = terrain_query
func _ready() -> void:
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
_camera = get_node_or_null(camera_path) as Camera3D
_visual = get_node_or_null(visual_path) as Node3D
if _camera:
_camera.current = true
_camera.far = 50000.0
if _terrain_query == null:
_terrain_query = ADT_TERRAIN_QUERY_SCRIPT.new(extracted_dir, map_name)
_local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
run_speed,
backward_speed,
strafe_speed,
flight_vertical_speed,
acceleration,
sprint_multiplier,
PLAYER_MOVEMENT_CAPABILITIES_SCRIPT.for_profile_id(StringName(movement_profile_id))
)
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
_appearance_presenter = get_node_or_null(visual_path) as CharacterAppearancePresenter
_animation_presenter = get_node_or_null(animation_presenter_path) as CharacterAnimationPresenter
if _camera_rig == null:
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
else:
_camera_rig.initialize_for_character(self)
if _appearance_presenter == null:
push_error("ThirdPersonWowController: CharacterAppearancePresenter not found at %s" % visual_path)
else:
_appearance_presenter.character_appearance_ready.connect(_on_character_appearance_ready)
_appearance_presenter.load_character_appearance(extracted_dir)
if _animation_presenter == null:
push_error("ThirdPersonWowController: CharacterAnimationPresenter not found at %s" % animation_presenter_path)
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
var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
_load_character_visual()
var ground := _sample_ground_height(global_position)
if is_finite(ground):
global_position.y = ground + ground_offset
_yaw = rotation.y
_apply_camera_transform()
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
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():
_flight_enabled = not _flight_enabled
_horizontal_velocity = Vector3.ZERO
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()
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()
func _physics_process(delta: float) -> void:
var move_intent := _player_input_source.sample_move_intent()
var target_velocity := Vector3.ZERO
if move_intent.has_translation():
target_velocity = _movement_vector(move_intent)
if _flight_enabled:
target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent)
var godot_world_movement_basis := 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)
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
global_position += _horizontal_velocity * delta
if not _flight_enabled:
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
if not _local_movement_controller.is_flight_enabled:
var ground_sample := _sample_ground_height(global_position)
if ground_sample.is_available:
var target_y := ground_sample.height_units + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z)
if _visual and horizontal_motion.length_squared() > 0.01:
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
if _appearance_presenter != null and horizontal_motion.length_squared() > 0.01:
_appearance_presenter.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
_update_character_animation(horizontal_motion.length_squared() > 0.04)
_apply_camera_transform()
if _animation_presenter != null:
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
func _movement_vector(move_intent: MoveIntent) -> Vector3:
var forward := -global_basis.z
var right := global_basis.x
if _flight_enabled and _camera_pivot:
forward = -_camera_pivot.global_basis.z
right = _camera_pivot.global_basis.x
else:
forward.y = 0.0
right.y = 0.0
forward = forward.normalized()
right = right.normalized()
var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed
var speed_x := strafe_speed
return (
forward * move_intent.forward_axis * speed_z
+ right * move_intent.strafe_axis * speed_x
) * _debug_speed_multiplier(move_intent)
func _on_character_appearance_ready(character_root: Node3D) -> void:
if _animation_presenter != null:
_animation_presenter.bind_character_root(character_root)
func _debug_speed_multiplier(move_intent: MoveIntent) -> float:
return sprint_multiplier if move_intent.is_sprint_requested else 1.0
func _load_character_visual() -> void:
if character_model_path.is_empty() or _visual == null:
return
if _visual is MeshInstance3D:
(_visual as MeshInstance3D).mesh = null
_visual.position = Vector3.ZERO
if _character_root and is_instance_valid(_character_root):
_character_root.queue_free()
_character_root = null
_animation_player = null
_active_animation = ""
var scene: PackedScene = load(character_model_path)
if scene == null:
push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path)
return
_character_root = Node3D.new()
_character_root.name = "CharacterModel"
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
_character_root.rotation_degrees.y = character_yaw_offset_degrees
var instance := scene.instantiate()
instance.name = character_model_path.get_file().get_basename()
_character_root.add_child(instance)
var compositor := Node.new()
compositor.name = "CharacterTextureCompositor"
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
compositor.set("textures_dir", _character_textures_dir(character_model_path))
compositor.set("extracted_dir", extracted_dir)
_character_root.add_child(compositor)
_visual.add_child(_character_root)
await get_tree().process_frame
_fit_character_to_ground()
_apply_character_starter_outfit(compositor)
_animation_player = _find_animation_player(_character_root)
if _animation_player:
_prepare_animation_player(_animation_player)
_update_character_animation(false)
func _fit_character_to_ground() -> void:
if _character_root == null:
return
var aabb := _calculate_aabb(_character_root)
if aabb.size == Vector3.ZERO:
return
var local_min_y := aabb.position.y - _character_root.global_position.y
_character_root.position.y -= local_min_y
func _apply_character_starter_outfit(compositor: Node) -> void:
if _character_root == null or compositor == null:
return
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
if not resolver.call("load_dbcs", extracted_dir):
return
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
var race := String(inferred.get("race", "Human"))
var gender := String(inferred.get("gender", "Male"))
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
if outfit.is_empty():
return
if compositor.has_method("set_equipment_components"):
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
if _character_root.has_method("apply_outfit_defaults"):
_character_root.call("apply_outfit_defaults", outfit)
func _update_character_animation(is_moving: bool) -> void:
if _animation_player == null:
return
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
if wanted.is_empty() or wanted == _active_animation:
return
_active_animation = wanted
_animation_player.play(wanted, animation_blend_time)
func _prepare_animation_player(player: AnimationPlayer) -> void:
player.playback_default_blend_time = animation_blend_time
for animation_name in player.get_animation_list():
var lower := String(animation_name).to_lower()
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
var animation := player.get_animation(animation_name)
if animation:
animation.loop_mode = Animation.LOOP_LINEAR
func _choose_animation(candidates: Array[String]) -> String:
if _animation_player == null:
return ""
for candidate in candidates:
if _animation_player.has_animation(candidate):
return candidate
var list := _animation_player.get_animation_list()
for candidate in candidates:
var lower := candidate.to_lower()
for animation_name in list:
if String(animation_name).to_lower().contains(lower):
return String(animation_name)
return ""
func _find_animation_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root
for child in root.get_children():
var found := _find_animation_player(child)
if found:
return found
return null
func _calculate_aabb(root: Node) -> AABB:
var result := AABB()
var has_aabb := false
for mesh_inst in _iter_mesh_instances(root):
if mesh_inst.mesh == null:
continue
var local_aabb := mesh_inst.mesh.get_aabb()
var global_aabb := mesh_inst.global_transform * local_aabb
if not has_aabb:
result = global_aabb
has_aabb = true
else:
result = result.merge(global_aabb)
return result if has_aabb else AABB()
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_mesh_instances(root, result)
return result
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)
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(world_pos: Vector3) -> float:
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y)
if data.is_empty():
return NAN
var tile_origin := _get_tile_origin(data)
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty():
return NAN
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local := world_pos - origin
var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0)
var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(gx)), 0, 8)
var z0 := clampi(int(floor(gz)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fx := gx - float(x0)
var fz := gz - float(z0)
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return NAN
var h00 := heights[_outer(z0, x0)]
var h10 := heights[_outer(z0, x1)]
var h01 := heights[_outer(z1, x0)]
var h11 := heights[_outer(z1, x1)]
var hx0 := lerpf(h00, h10, fx)
var hx1 := lerpf(h01, h11, fx)
return origin.y + lerpf(hx0, hx1, fz)
func _load_adt(tx: int, ty: int) -> Dictionary:
var key := "%d_%d" % [tx, ty]
if _adt_cache.has(key):
return _adt_cache[key]
if not ClassDB.class_exists("ADTLoader"):
_adt_cache[key] = {}
return {}
var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty])
var abs_path := ProjectSettings.globalize_path(res_path)
if not FileAccess.file_exists(abs_path):
_adt_cache[key] = {}
return {}
var loader = ClassDB.instantiate("ADTLoader")
var data: Dictionary = loader.call("load_adt", abs_path) if loader else {}
_adt_cache[key] = data
return data
func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary:
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local := origin - tile_origin
var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15)
var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)
if cx == chunk_x and cy == chunk_y:
return chunk
return {}
func _get_tile_origin(data: Dictionary) -> Vector3:
var found := false
var min_x := 0.0
var min_z := 0.0
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not found:
min_x = origin.x
min_z = origin.z
found = true
else:
min_x = minf(min_x, origin.x)
min_z = minf(min_z, origin.z)
return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO
func _outer(row: int, col: int) -> int:
return row * 17 + col
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
return _terrain_query.sample_ground_height(
GODOT_WORLD_POSITION_SCRIPT.new(
godot_world_position.x,
godot_world_position.y,
godot_world_position.z
)
)
@@ -3,6 +3,9 @@
[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"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -104,9 +107,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1")
script = ExtResource("5_appearance")
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
script = ExtResource("6_animation")
[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)
@@ -3,6 +3,9 @@
[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"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -105,9 +108,14 @@ shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1")
script = ExtResource("5_appearance")
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
script = ExtResource("6_animation")
[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)
@@ -0,0 +1,10 @@
[gd_scene load_steps=2 format=3]
[sub_resource type="BoxMesh" id="BoxMesh_character"]
size = Vector3(1, 2, 1)
[node name="SyntheticCharacter" type="Node3D"]
[node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 2, 0)
mesh = SubResource("BoxMesh_character")
+10 -2
View File
@@ -1,14 +1,22 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=5 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"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="3_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="4_animation"]
[node name="PlayerInputRegression" type="CharacterBody3D"]
script = ExtResource("1_player")
spawn_at_tile_center = false
character_model_path = ""
[node name="Visual" type="Node3D" parent="."]
script = ExtResource("3_appearance")
character_model_path = ""
[node name="AnimationPresenter" type="Node" parent="Visual"]
script = ExtResource("4_animation")
[node name="CameraPivot" type="Node3D" parent="."]
script = ExtResource("2_camera_rig")
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
+164
View File
@@ -0,0 +1,164 @@
extends SceneTree
## Asset-free M02 appearance, animation and player-boundary regression.
const MODEL_FIXTURE_PATH := "res://src/tests/scenes/character_presentation_model_fixture.tscn"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const APPEARANCE_PRESENTER_PATH := "res://src/scenes/character/character_appearance_presenter.gd"
const ANIMATION_PRESENTER_PATH := "res://src/scenes/character/character_animation_presenter.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",
]
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
await _verify_appearance_presenter(failures)
_verify_animation_presenter(failures)
_verify_source_boundaries(failures)
_verify_scene_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("CHARACTER_PRESENTATION: %s" % failure)
quit(1)
return
print("CHARACTER_PRESENTATION PASS appearance_cases=9 animation_cases=10 scenes=3 player_boundary=1")
quit(0)
func _verify_appearance_presenter(failures: Array[String]) -> void:
var presenter := CharacterAppearancePresenter.new()
presenter.character_model_path = MODEL_FIXTURE_PATH
presenter.character_scale = 2.0
presenter.character_yaw_offset_degrees = 45.0
root.add_child(presenter)
var ready_roots: Array[Node3D] = []
presenter.character_appearance_ready.connect(func(character_root: Node3D) -> void: ready_roots.append(character_root))
_expect_true(presenter.load_character_appearance("res://missing-extracted-fixture"), "fixture load starts", failures)
await process_frame
var character_root := presenter.character_root
_expect_true(character_root != null, "character root composed", failures)
if character_root != null:
_expect_near(character_root.scale.x, 2.0, "uniform character scale", failures)
_expect_near(character_root.rotation_degrees.y, 45.0, "character yaw offset", failures)
_expect_near(character_root.position.y, -2.0, "character grounded after scale", failures)
_expect_true(character_root.has_node("CharacterTextureCompositor"), "texture compositor composed", failures)
_expect_true(ready_roots.size() == 1, "appearance ready emitted", failures)
_expect_true(presenter.extracted_data_directory == "res://missing-extracted-fixture", "content root override retained", failures)
var previous_root := character_root
_expect_true(presenter.load_character_appearance(), "replacement load starts", failures)
_expect_true(presenter.character_root != previous_root, "replacement swaps owned root", failures)
await process_frame
_expect_true(ready_roots.size() == 2, "replacement ready emitted", failures)
presenter.character_model_path = ""
_expect_true(not presenter.load_character_appearance(), "empty model rejected", failures)
_expect_true(presenter.character_root == null, "empty model clears old root", failures)
await process_frame
presenter.free()
func _verify_animation_presenter(failures: Array[String]) -> void:
var animation_root := Node.new()
var nested_node := Node.new()
var animation_player := AnimationPlayer.new()
animation_root.add_child(nested_node)
nested_node.add_child(animation_player)
var animation_library := AnimationLibrary.new()
for animation_name in ["Stand", "Run", "Emote"]:
animation_library.add_animation(animation_name, Animation.new())
animation_player.add_animation_library("", animation_library)
var presenter := CharacterAnimationPresenter.new()
presenter.animation_blend_time_seconds = 0.25
_expect_true(presenter.bind_character_root(animation_root), "nested animation player bound", failures)
_expect_true(presenter.has_animation_player, "animation player state exposed", failures)
_expect_true(presenter.active_animation_name == "Stand", "stationary animation selected", failures)
_expect_true(animation_player.current_animation == "Stand", "stationary animation playing", failures)
_expect_near(animation_player.playback_default_blend_time, 0.25, "default blend configured", failures)
_expect_true(animation_player.get_animation("Stand").loop_mode == Animation.LOOP_LINEAR, "stand loops", failures)
_expect_true(animation_player.get_animation("Run").loop_mode == Animation.LOOP_LINEAR, "run loops", failures)
_expect_true(animation_player.get_animation("Emote").loop_mode == Animation.LOOP_NONE, "unrelated animation unchanged", failures)
_expect_true(presenter.present_locomotion(true), "moving transition starts", failures)
_expect_true(presenter.active_animation_name == "Run", "moving animation selected", failures)
_expect_true(not presenter.present_locomotion(true), "duplicate moving state ignored", failures)
var fallback_root := Node.new()
var fallback_player := AnimationPlayer.new()
fallback_root.add_child(fallback_player)
var fallback_library := AnimationLibrary.new()
fallback_library.add_animation("CharacterStandLoop", Animation.new())
fallback_library.add_animation("FastRunCycle", Animation.new())
fallback_player.add_animation_library("", fallback_library)
_expect_true(presenter.bind_character_root(fallback_root), "fallback animation player bound", failures)
_expect_true(presenter.active_animation_name == "CharacterStandLoop", "substring stationary fallback", failures)
presenter.present_locomotion(true)
_expect_true(presenter.active_animation_name == "FastRunCycle", "substring moving fallback", failures)
fallback_root.free()
_expect_true(not presenter.present_locomotion(false), "freed animation root rejected", failures)
_expect_true(not presenter.has_animation_player, "freed animation binding cleared", failures)
var missing_animation_root := Node.new()
_expect_true(not presenter.bind_character_root(missing_animation_root), "missing animation player rejected", failures)
_expect_true(not presenter.has_animation_player, "missing binding clears state", failures)
presenter.free()
animation_root.free()
missing_animation_root.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in [
"GEOSET_CONTROLLER_SCRIPT",
"TEXTURE_COMPOSITOR_SCRIPT",
"OUTFIT_RESOLVER_SCRIPT",
"func _load_character_visual",
"func _find_animation_player",
"func _calculate_aabb",
"_animation_player",
]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("_appearance_presenter.load_character_appearance"), "player delegates appearance", failures)
_expect_true(player_source.contains("_animation_presenter.present_locomotion"), "player delegates animation", failures)
var appearance_source := _read_text(APPEARANCE_PRESENTER_PATH, failures)
for forbidden_text in ["MoveIntent", "TerrainQuery", "ThirdPersonCameraRig", "AnimationPlayer"]:
_expect_true(not appearance_source.contains(forbidden_text), "appearance omits %s" % forbidden_text, failures)
var animation_source := _read_text(ANIMATION_PRESENTER_PATH, failures)
for forbidden_text in ["load(character_model_path)", "TerrainQuery", "MoveIntent", "Camera3D"]:
_expect_true(not animation_source.contains(forbidden_text), "animation 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("character_appearance_presenter.gd"), "%s references appearance presenter" % scene_path, failures)
_expect_true(scene_source.contains("character_animation_presenter.gd"), "%s references animation presenter" % scene_path, failures)
_expect_true(scene_source.contains('name="AnimationPresenter"'), "%s composes animation presenter" % scene_path, failures)
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)
@@ -20,7 +20,8 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
const REQUIRED_MAPPER_CALLS := {
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"],
"res://src/scenes/player/third_person_wow_controller.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
"res://src/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"],
"res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
}
@@ -59,7 +60,7 @@ func _initialize() -> void:
quit(1)
return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=4 classifier_cases=6" % source_paths.size())
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size())
quit(0)
+145
View File
@@ -0,0 +1,145 @@
extends SceneTree
## Headless M02 unit and boundary regression for local player movement state.
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_directional_speeds(failures)
_verify_acceleration_and_deceleration(failures)
_verify_sandbox_sprint(failures)
_verify_flight_state_and_basis(failures)
_verify_invalid_delta(failures)
_verify_scene_boundary(failures)
if not failures.is_empty():
for failure in failures:
push_error("LOCAL_PLAYER_MOVEMENT: %s" % failure)
quit(1)
return
print("LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2 scene_boundary=1")
quit(0)
func _verify_directional_speeds(failures: Array[String]) -> void:
var forward_controller := _new_controller()
var forward_displacement := forward_controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_vector_near(forward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "forward velocity", failures)
_expect_vector_near(forward_displacement, Vector3(0.0, 0.0, -7.0), "forward displacement", failures)
var backward_controller := _new_controller()
backward_controller.advance(_intent(-1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_vector_near(backward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, 4.5), "backward velocity", failures)
var strafe_controller := _new_controller()
strafe_controller.advance(_intent(0.0, 1.0), Basis.IDENTITY, 1.0)
_expect_vector_near(strafe_controller.godot_world_velocity_units_per_second, Vector3(4.5, 0.0, 0.0), "strafe velocity", failures)
var rotated_controller := _new_controller()
var rotated_basis := Basis(Vector3.UP, deg_to_rad(90.0))
rotated_controller.advance(_intent(1.0, 0.0), rotated_basis, 1.0)
_expect_vector_near(
rotated_controller.godot_world_velocity_units_per_second,
(-rotated_basis.z).normalized() * 7.0,
"rotated character basis",
failures
)
func _verify_acceleration_and_deceleration(failures: Array[String]) -> void:
var controller := _new_controller()
var forward_intent := _intent(1.0, 0.0)
_expect_vector_near(controller.advance(forward_intent, Basis.IDENTITY, 0.1), Vector3(0.0, 0.0, -0.28), "first acceleration displacement", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "first acceleration velocity", failures)
controller.advance(forward_intent, Basis.IDENTITY, 0.1)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -5.6), "second acceleration velocity", failures)
controller.advance(_intent(0.0, 0.0), Basis.IDENTITY, 0.1)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "deceleration velocity", failures)
func _verify_sandbox_sprint(failures: Array[String]) -> void:
var controller := _new_controller()
controller.advance(_intent(1.0, 0.0, 0.0, true), Basis.IDENTITY, 2.0)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint velocity", failures)
func _verify_flight_state_and_basis(failures: Array[String]) -> void:
var controller := _new_controller()
controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
_expect_true(controller.toggle_sandbox_flight(), "flight toggles on", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "flight toggle clears velocity", failures)
var pitched_basis := Basis(Vector3.RIGHT, deg_to_rad(30.0))
controller.advance(_intent(1.0, 0.0, 1.0), pitched_basis, 1.0)
var expected_flight_velocity := (-pitched_basis.z).normalized() * 7.0 + Vector3.UP * 7.0
_expect_vector_near(controller.godot_world_velocity_units_per_second, expected_flight_velocity, "camera-relative flight velocity", failures)
_expect_true(not controller.toggle_sandbox_flight(), "flight toggles off", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "landing toggle clears velocity", failures)
func _verify_invalid_delta(failures: Array[String]) -> void:
var controller := _new_controller()
var displacement := controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, -1.0)
_expect_vector_near(displacement, Vector3.ZERO, "negative delta displacement", failures)
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "negative delta state", failures)
func _verify_scene_boundary(failures: Array[String]) -> void:
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
for forbidden_text in ["extends Node", "extends Resource", "Input.", "Camera3D", "ADTLoader", "global_position"]:
_expect_true(not movement_source.contains(forbidden_text), "movement controller omits %s" % forbidden_text, failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(player_source.contains("_local_movement_controller.advance"), "scene delegates movement update", failures)
_expect_true(not player_source.contains("move_toward(target_velocity"), "scene omits velocity integration", failures)
_expect_true(not player_source.contains("func _movement_vector"), "scene omits movement-vector calculation", failures)
_expect_true(not player_source.contains("_horizontal_velocity"), "scene omits movement velocity state", failures)
_expect_true(not player_source.contains("var _flight_enabled"), "scene omits flight state", failures)
func _new_controller() -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(
7.0,
4.5,
4.5,
7.0,
28.0,
6.0,
PlayerMovementCapabilities.render_sandbox()
)
func _intent(
forward_axis: float,
strafe_axis: float,
vertical_axis: float = 0.0,
sprint_requested: bool = false
) -> MoveIntent:
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested)
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_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [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)
+5 -3
View File
@@ -102,18 +102,20 @@ 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
flight_toggle_event.pressed = true
player.call("_unhandled_input", flight_toggle_event)
_expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures)
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
player.free()
@@ -0,0 +1,148 @@
extends SceneTree
## Headless M02 profile/capability gate regression for debug player movement.
const MOVEMENT_CAPABILITIES_PATH := "res://src/gameplay/movement/player_movement_capabilities.gd"
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_capability_profiles(failures)
_verify_movement_gate(failures)
_verify_real_scene_profiles(failures)
_verify_source_boundary(failures)
if not failures.is_empty():
for failure in failures:
push_error("PLAYER_MOVEMENT_CAPABILITIES: %s" % failure)
quit(1)
return
print("PLAYER_MOVEMENT_CAPABILITIES PASS profiles=3 sprint=2 flight=2 scenes=2 boundary=1")
quit(0)
func _verify_capability_profiles(failures: Array[String]) -> void:
var render_sandbox := PlayerMovementCapabilities.render_sandbox()
_expect_true(render_sandbox.profile_id == PlayerMovementCapabilities.RENDER_SANDBOX_PROFILE_ID, "sandbox profile identity", failures)
_expect_true(render_sandbox.allows_debug_sprint, "sandbox sprint capability", failures)
_expect_true(render_sandbox.allows_debug_free_flight, "sandbox flight capability", failures)
var blizzlike := PlayerMovementCapabilities.blizzlike_335()
_expect_true(blizzlike.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "blizzlike profile identity", failures)
_expect_true(not blizzlike.allows_debug_sprint, "blizzlike rejects sprint", failures)
_expect_true(not blizzlike.allows_debug_free_flight, "blizzlike rejects flight", failures)
var unknown_profile := PlayerMovementCapabilities.for_profile_id(&"UnknownProfile")
_expect_true(unknown_profile.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "unknown profile fails closed", failures)
_expect_true(not unknown_profile.allows_debug_sprint, "unknown profile rejects sprint", failures)
_expect_true(not unknown_profile.allows_debug_free_flight, "unknown profile rejects flight", failures)
func _verify_movement_gate(failures: Array[String]) -> void:
var sprint_intent := MoveIntent.new(1.0, 0.0, 0.0, true)
var sandbox_controller := _new_controller(PlayerMovementCapabilities.render_sandbox())
sandbox_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(sandbox_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint applied", failures)
_expect_true(sandbox_controller.toggle_sandbox_flight(), "sandbox flight enabled", failures)
var blizzlike_controller := _new_controller(PlayerMovementCapabilities.blizzlike_335())
blizzlike_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(blizzlike_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike sprint ignored", failures)
_expect_true(not blizzlike_controller.toggle_sandbox_flight(), "blizzlike flight rejected", failures)
_expect_true(not blizzlike_controller.is_flight_enabled, "blizzlike remains grounded", failures)
var safe_default_controller := LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
_expect_true(safe_default_controller.movement_profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "missing capability defaults safe", failures)
func _verify_real_scene_profiles(failures: Array[String]) -> void:
var sandbox_player := _instantiate_player("RenderSandbox", failures)
if sandbox_player != null:
_press_sprint_forward_for_two_seconds(sandbox_player)
var sandbox_movement: LocalPlayerMovementController = sandbox_player.get("_local_movement_controller")
_expect_vector_near(sandbox_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox scene sprint", failures)
sandbox_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(sandbox_movement.is_flight_enabled, "sandbox scene flight", failures)
sandbox_player.free()
var blizzlike_player := _instantiate_player("Blizzlike335", failures)
if blizzlike_player != null:
_press_sprint_forward_for_two_seconds(blizzlike_player)
var blizzlike_movement: LocalPlayerMovementController = blizzlike_player.get("_local_movement_controller")
_expect_vector_near(blizzlike_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike scene sprint rejected", failures)
blizzlike_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(not blizzlike_movement.is_flight_enabled, "blizzlike scene flight rejected", failures)
blizzlike_player.free()
func _instantiate_player(profile_id: String, failures: Array[String]) -> CharacterBody3D:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
if packed_scene == null:
failures.append("regression scene loads")
return null
var player := packed_scene.instantiate() as CharacterBody3D
if player == null:
failures.append("regression player instantiates")
return null
player.set("movement_profile_id", profile_id)
root.add_child(player)
player.set_physics_process(false)
return player
func _press_sprint_forward_for_two_seconds(player: CharacterBody3D) -> void:
Input.action_press(PlayerInputActions.MOVE_FORWARD)
Input.action_press(PlayerInputActions.DEBUG_SPRINT)
player.call("_physics_process", 2.0)
Input.action_release(PlayerInputActions.DEBUG_SPRINT)
Input.action_release(PlayerInputActions.MOVE_FORWARD)
func _action_event(action_name: StringName) -> InputEventAction:
var input_event := InputEventAction.new()
input_event.action = action_name
input_event.pressed = true
return input_event
func _verify_source_boundary(failures: Array[String]) -> void:
var capability_source := _read_text(MOVEMENT_CAPABILITIES_PATH, failures)
for forbidden_text in ["extends Node", "extends Resource", "Input.", "CharacterBody3D"]:
_expect_true(not capability_source.contains(forbidden_text), "capability omits %s" % forbidden_text, failures)
_expect_true(not capability_source.contains("\n\tset:"), "capability exposes no property setters", failures)
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
_expect_true(movement_source.contains("allows_debug_sprint"), "movement gates sprint capability", failures)
_expect_true(movement_source.contains("allows_debug_free_flight"), "movement gates flight capability", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(player_source.contains("for_profile_id(StringName(movement_profile_id))"), "scene maps profile once", failures)
_expect_true(not player_source.contains('movement_profile_id == "'), "scene omits profile branches", failures)
func _new_controller(capabilities: PlayerMovementCapabilities) -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0, capabilities)
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_vector_near(actual_value: Vector3, expected_value: Vector3, label: String, failures: Array[String]) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [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)
+198
View File
@@ -0,0 +1,198 @@
extends SceneTree
## Headless M02 contract, interpolation, cache and player-injection regression.
const TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/terrain_query.gd"
const ADT_TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/adt_terrain_query.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
var _synthetic_adt_tile_data: Dictionary = {}
var _synthetic_loader_call_count := 0
class FlatTerrainQuery extends TerrainQuery:
var _height_units: float
func _init(height_units: float) -> void:
_height_units = height_units
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.available(_height_units)
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_typed_result_contract(failures)
_verify_synthetic_adt_interpolation_and_cache(failures)
_verify_unavailable_tile(failures)
_verify_player_injection(failures)
_verify_source_boundaries(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_QUERY: %s" % failure)
quit(1)
return
print("TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1 failures=2 player_injection=1")
quit(0)
func _verify_typed_result_contract(failures: Array[String]) -> void:
var available_sample := TerrainGroundSample.available(123.5)
_expect_true(available_sample.is_available, "available sample state", failures)
_expect_near(available_sample.height_units, 123.5, "available sample height", failures)
_expect_true(available_sample.failure_code.is_empty(), "available sample has no failure", failures)
var unavailable_sample := TerrainGroundSample.unavailable(&"synthetic_failure")
_expect_true(not unavailable_sample.is_available, "unavailable sample state", failures)
_expect_equal(unavailable_sample.failure_code, &"synthetic_failure", "unavailable failure code", failures)
var base_sample := TerrainQuery.new().sample_ground_height(GodotWorldPosition.new(0.0, 0.0, 0.0))
_expect_equal(base_sample.failure_code, &"terrain_query_not_implemented", "base query failure", failures)
var non_finite_sample := TerrainGroundSample.available(NAN)
_expect_true(not non_finite_sample.is_available, "non-finite sample unavailable", failures)
_expect_equal(non_finite_sample.failure_code, &"terrain_height_not_finite", "non-finite failure code", failures)
func _verify_synthetic_adt_interpolation_and_cache(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 999.0, 26525.0)
_synthetic_adt_tile_data = _build_synthetic_adt_tile(query_position)
_synthetic_loader_call_count = 0
var terrain_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", _load_synthetic_adt_tile)
var first_sample := terrain_query.sample_ground_height(query_position)
_expect_true(first_sample.is_available, "synthetic ADT sample available", failures)
_expect_near_with_tolerance(first_sample.height_units, 137.0, 0.002, "synthetic bilinear height", failures)
var second_sample := terrain_query.sample_ground_height(query_position)
_expect_near_with_tolerance(second_sample.height_units, 137.0, 0.002, "cached synthetic height", failures)
_expect_equal(_synthetic_loader_call_count, 1, "ADT tile loaded once", failures)
func _verify_unavailable_tile(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 0.0, 26525.0)
var empty_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", func(_path: String) -> Dictionary: return {})
var empty_sample := empty_query.sample_ground_height(query_position)
_expect_true(not empty_sample.is_available, "empty ADT unavailable", failures)
_expect_equal(empty_sample.failure_code, &"adt_tile_unavailable", "empty ADT failure", failures)
var invalid_heights_data := _build_synthetic_adt_tile(query_position)
var invalid_chunks: Array = invalid_heights_data.get("chunks", [])
(invalid_chunks[invalid_chunks.size() - 1] as Dictionary)["heights"] = PackedFloat32Array()
var invalid_query := AdtTerrainQuery.new(
"res://synthetic",
"Azeroth",
func(_path: String) -> Dictionary: return invalid_heights_data
)
var invalid_sample := invalid_query.sample_ground_height(query_position)
_expect_equal(invalid_sample.failure_code, &"adt_chunk_heights_invalid", "invalid heights failure", failures)
func _verify_player_injection(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
player.set_terrain_query(FlatTerrainQuery.new(20.0))
root.add_child(player)
player.set_physics_process(false)
_expect_near(player.position.y, 20.05, "injected spawn ground height", failures)
player.position.y = 30.0
player.call("_physics_process", 1.0)
_expect_near(player.position.y, 20.05, "injected physics ground snap", failures)
player.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var terrain_contract_source := _read_text(TERRAIN_QUERY_PATH, failures)
_expect_true(not terrain_contract_source.contains("extends Node"), "TerrainQuery omits Node", failures)
_expect_true(not terrain_contract_source.contains("Vector3"), "TerrainQuery omits untyped Vector3 position", failures)
var adt_query_source := _read_text(ADT_TERRAIN_QUERY_PATH, failures)
_expect_true(adt_query_source.contains("ADTLoader"), "ADT adapter owns native loader", failures)
_expect_true(adt_query_source.contains("_adt_tile_cache"), "ADT adapter owns tile cache", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in ["ADTLoader", "load_adt", "_adt_cache", "_find_chunk", "_outer_height_index"]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("set_terrain_query"), "player exposes terrain query injection", failures)
_expect_true(player_source.contains("_terrain_query.sample_ground_height"), "player delegates ground sampling", failures)
func _build_synthetic_adt_tile(query_position: GodotWorldPosition) -> Dictionary:
var tile_coordinate := CoordinateMapper.godot_to_adt_tile(query_position)
var tile_local_position := CoordinateMapper.godot_to_adt_tile_local(query_position)
var chunk_coordinate := CoordinateMapper.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var chunk_size := CoordinateMapper.ADT_CHUNK_SIZE_YARDS
var outer_grid_unit_size := chunk_size / 8.0
var target_chunk_origin := Vector3(
query_position.x_units - outer_grid_unit_size * 4.5,
100.0,
query_position.z_units - outer_grid_unit_size * 3.25
)
var tile_origin := target_chunk_origin - Vector3(
chunk_coordinate.chunk_x * chunk_size,
0.0,
chunk_coordinate.chunk_y * chunk_size
)
var heights := PackedFloat32Array()
heights.resize(145)
for row in range(9):
for column in range(9):
heights[row * 17 + column] = float(row * 10 + column)
var target_chunk := {"origin": target_chunk_origin, "heights": heights}
if chunk_coordinate.chunk_x == 0 and chunk_coordinate.chunk_y == 0:
return {"chunks": [target_chunk]}
return {
"chunks": [
{"origin": tile_origin, "heights": PackedFloat32Array()},
target_chunk,
]
}
func _load_synthetic_adt_tile(_absolute_path: String) -> Dictionary:
_synthetic_loader_call_count += 1
return _synthetic_adt_tile_data
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_near_with_tolerance(
actual_value: float,
expected_value: float,
tolerance: float,
label: String,
failures: Array[String]
) -> void:
if absf(actual_value - expected_value) > tolerance:
failures.append("%s expected %.6f ± %.6f, got %.6f" % [label, expected_value, tolerance, actual_value])
func _expect_equal(actual_value: Variant, expected_value: Variant, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [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)
+174
View File
@@ -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)