Compare commits

...

12 Commits

Author SHA1 Message Date
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
42 changed files with 2910 additions and 376 deletions
@@ -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.
+3
View File
@@ -11,6 +11,9 @@
| 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 |
+53 -18
View File
@@ -5,10 +5,10 @@
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-MOVEMENT-001 |
| 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 | `435e1c9`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
| Last verified | `742c415`, 2026-07-14 |
| Profiles/capabilities | `RenderSandbox` debug movement; `Blizzlike335` rejects debug sprint/flight |
## Purpose
@@ -32,13 +32,15 @@ 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[Ground snap adapter]
Velocity --> Presentation[Visual facing and animation adapter]
Scene --> Terrain[TerrainQuery and ground snap adapter]
Velocity --> Presentation[Visual facing and CharacterAnimationPresenter adapter]
```
Allowed dependencies:
@@ -57,10 +59,14 @@ Forbidden dependencies:
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration and sandbox sprint multiplier | Created by composition root; retained for player scene lifetime | Caller owns configuration validation |
| `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 |
| `toggle_sandbox_flight()` | Command | Toggles flight and clears velocity | Owning physics/input thread | Returns new flight state |
| `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:
@@ -79,11 +85,12 @@ Constructor units:
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
| Input | Godot-world `Basis` | Player scene adapter | Movement controller | Value copy | One physics tick |
| Input | `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/animation adapter | Read-only value copy | Until next update |
| 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:
@@ -100,6 +107,9 @@ flowchart LR
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]
@@ -112,7 +122,8 @@ flowchart LR
```mermaid
stateDiagram-v2
[*] --> GroundedSandbox
GroundedSandbox --> FlyingSandbox: toggle / clear velocity
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
@@ -128,8 +139,11 @@ sequenceDiagram
participant Input as PlayerInputSource
participant Scene as ThirdPersonWowController
participant Move as LocalPlayerMovementController
participant Present as Facing/Animation adapter
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
@@ -140,6 +154,8 @@ sequenceDiagram
## 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.
@@ -153,7 +169,9 @@ sequenceDiagram
| 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 |
| Missing terrain data | Outside module | Scene skips existing ground snap | Existing terrain diagnostics | TerrainQuery extraction will own policy |
| 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.
@@ -169,6 +187,9 @@ controller resets velocity and flight state deterministically.
| 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
@@ -188,6 +209,8 @@ separate versioned movement snapshot contract.
- 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
@@ -202,7 +225,8 @@ separate versioned movement snapshot contract.
- 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.
- Sandbox debug capability gating can reject sprint/flight before commands reach this controller.
- A future application profile can map to this narrow capability value without
coupling the movement controller to the application shell.
## Capability status
@@ -210,8 +234,10 @@ separate versioned movement snapshot contract.
|---|---|---|---|
| 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 | Pitched-basis and toggle/reset cases | Restrict to sandbox profile |
| Terrain/collision movement policy | Planned | Outside module by design | Extract `TerrainQuery` next |
| 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 |
@@ -220,8 +246,12 @@ separate versioned movement snapshot contract.
- 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 until `TerrainQuery` extraction.
- Sprint and free flight still lack a typed sandbox capability gate.
- 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.
@@ -230,8 +260,13 @@ separate versioned movement snapshot contract.
| Path | Responsibility |
|---|---|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
| `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 |
+30 -18
View File
@@ -5,10 +5,10 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 consumer update |
| Target/work package | M02 / input, movement, camera and sandbox-capability consumer updates |
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
| Last verified | `435e1c9`, 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
@@ -32,8 +32,9 @@ flowchart LR
InputMap --> Source[PlayerInputSource]
Source --> Intent[MoveIntent]
Intent --> Movement[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Movement
Movement --> Controller[ThirdPersonWowController adapter]
Controller --> Camera[Current camera 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 |
| Input | Mouse action events | Godot `_unhandled_input` dispatch | `ThirdPersonCameraRig` through player dispatch | Engine-owned event | Current input dispatch only |
| Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick |
| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime |
| Output | 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,8 +83,10 @@ flowchart LR
Project[project.godot action defaults] --> Map[InputMap]
Remap[User or editor remapping] --> Map
Map -->|movement strengths| Source[PlayerInputSource]
Map -->|camera events| Camera[ThirdPersonCameraRig]
Source -->|clamp cancel normalize| Intent[MoveIntent]
Intent --> Consumer[LocalPlayerMovementController]
Profile[PlayerMovementCapabilities] --> Consumer
Consumer --> Adapter[ThirdPersonWowController]
```
@@ -91,7 +95,9 @@ flowchart LR
The adapter is stateless. The scene composition root creates one
`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.
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
@@ -126,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.
@@ -137,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
@@ -161,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
@@ -184,15 +193,15 @@ provide a settings migration once user settings are persisted.
| 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 | 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
@@ -204,9 +213,12 @@ provide a settings migration once user settings are persisted.
| `src/gameplay/input/player_input_actions.gd` | Stable project action names |
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
| `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
| `src/scenes/player/third_person_wow_controller.gd` | Composition and camera/input-event adapter |
| `src/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`.
@@ -13,25 +13,32 @@ 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 and acceleration.
## 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
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
@@ -39,11 +46,18 @@ func _init(
_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 exactly like the pre-M02 controller.
## Returns the new flight state.
## 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
@@ -83,7 +97,11 @@ func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_ba
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 else 1.0
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
@@ -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
+63 -324
View File
@@ -1,8 +1,5 @@
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")
@@ -10,9 +7,8 @@ const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/ad
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 LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const UNIT_SIZE := CHUNK_SIZE / 8.0
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"
@@ -26,367 +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 _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()
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
sprint_multiplier,
PLAYER_MOVEMENT_CAPABILITIES_SCRIPT.for_profile_id(StringName(movement_profile_id))
)
_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
_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():
if _camera_rig != null:
_camera_rig.handle_camera_input(event)
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
_local_movement_controller.toggle_sandbox_flight()
if _captured and event is InputEventMouseMotion:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max)
rotation.y = _yaw
_apply_camera_transform()
func _physics_process(delta: float) -> void:
var move_intent := _player_input_source.sample_move_intent()
var godot_world_movement_basis := global_basis
if _local_movement_controller.is_flight_enabled and _camera_pivot:
godot_world_movement_basis = _camera_pivot.global_basis
if _local_movement_controller.is_flight_enabled and _camera_rig != null:
godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
if not _local_movement_controller.is_flight_enabled:
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
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 movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
if _visual and horizontal_motion.length_squared() > 0.01:
_visual.global_rotation.y = atan2(-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()
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)
if _animation_presenter != null:
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
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 _on_character_appearance_ready(character_root: Node3D) -> void:
if _animation_presenter != null:
_animation_presenter.bind_character_root(character_root)
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)
+9 -1
View File
@@ -102,7 +102,15 @@ func _verify_scene_boundary(failures: Array[String]) -> void:
func _new_controller() -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
return LocalPlayerMovementController.new(
7.0,
4.5,
4.5,
7.0,
28.0,
6.0,
PlayerMovementCapabilities.render_sandbox()
)
func _intent(
+3 -2
View File
@@ -102,12 +102,13 @@ func _verify_regression_scene(failures: Array[String]) -> void:
Input.action_release(PlayerInputActions.MOVE_FORWARD)
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
var camera_distance_before: float = player.get("camera_distance")
var camera_rig := player.get_node("CameraPivot") as ThirdPersonCameraRig
var camera_distance_before := camera_rig.requested_distance_units
var zoom_event := InputEventMouseButton.new()
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
zoom_event.pressed = true
player.call("_unhandled_input", zoom_event)
_expect_near(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures)
_expect_near(camera_rig.requested_distance_units, camera_distance_before - 1.0, "zoom action updates camera distance", failures)
var flight_toggle_event := InputEventAction.new()
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
@@ -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)