Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5bb64e6c7 | |||
| 0ce3cae208 | |||
| a45d521567 | |||
| 5206c42cf0 | |||
| d2d6df3655 | |||
| 435e1c95d2 | |||
| 14dead194b | |||
| e794764269 | |||
| 6bd2e84048 | |||
| 62eece991c |
@@ -0,0 +1,112 @@
|
|||||||
|
# M02-GMP-INPUT-001 — Player input intent seam
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M02-GMP-INPUT-001:sindo-main-codex:2026-07-16 -->
|
||||||
|
<!-- OPENWC_HANDOFF:READY:M02-GMP-INPUT-001:6bd2e84 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M02
|
||||||
|
- Program: GMP/FND
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m02-player-input`
|
||||||
|
- Lease expires UTC: 2026-07-16
|
||||||
|
- Integrator: M02 milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Replace direct keyboard polling in the runtime player with remappable Input Map
|
||||||
|
actions and an immutable `PlayerInputSource → MoveIntent` boundary while
|
||||||
|
preserving the current render-sandbox movement behavior.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Extract local movement, terrain query, camera rig or character presenters.
|
||||||
|
- Claim exact build-12340 movement/keybinding parity before original-client fixtures.
|
||||||
|
- Change movement speeds, camera geometry, terrain sampling or renderer behavior.
|
||||||
|
- Mark M02 complete or edit its checklist/Evidence.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/domain/input/`, `src/gameplay/input/`,
|
||||||
|
`src/tests/scenes/player_input_regression.tscn`,
|
||||||
|
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`, this claim
|
||||||
|
- Shared/hotspots: `project.godot`,
|
||||||
|
`src/scenes/player/third_person_wow_controller.gd`, `docs/modules/README.md`
|
||||||
|
- Generated/ignored: Godot import metadata, `.uid` files and local renderer corpus
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API: immutable `MoveIntent`; `PlayerInputSource.sample_move_intent`
|
||||||
|
- Configuration: additive `openwc_player_*` Input Map actions
|
||||||
|
- Persistence/schema/cache: unchanged
|
||||||
|
- Consumers: current sandbox player controller; later local movement controller
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: merged M01 coordinate/streaming-focus contracts on master `62eece9`
|
||||||
|
- Blocks: later M02 movement-controller extraction
|
||||||
|
- External state: none; tests use synthetic Input Map events
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: dedicated player-input verifier, headless project load, coordinate
|
||||||
|
boundary gate, coordination/documentation gates and diff hygiene
|
||||||
|
- Fixtures: synthetic action strengths and configured default bindings
|
||||||
|
- Fidelity evidence: current W/S/A/D, Shift, E/Q and Space sandbox behavior remains
|
||||||
|
mapped through actions; exact 3.3.5a semantics remain unverified
|
||||||
|
- Performance budget: one immutable intent allocation per physics tick; no I/O
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline API documentation for `MoveIntent`, `PlayerInputActions` and
|
||||||
|
`PlayerInputSource`
|
||||||
|
- Player-input module specification with inputs/outputs, data-flow and sequence diagrams
|
||||||
|
- Module registry/source map and honest capability status
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important names: `MoveIntent`, `PlayerInputSource`, `PlayerInputActions`
|
||||||
|
- Simplest approach: immutable value, one input adapter and one controller consumer
|
||||||
|
- Rejected complexity: input provider hierarchy, event bus and generic command framework
|
||||||
|
- Unavoidable complexity: none
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready-for-review
|
||||||
|
- Done: twelve remappable actions, immutable `MoveIntent`, stateless
|
||||||
|
`PlayerInputSource`, controller migration, asset-free regression scene,
|
||||||
|
dedicated verifier and module specification
|
||||||
|
- Next: M02 integrator reviews and merges before local movement extraction
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: `6bd2e84`
|
||||||
|
- Branch: `work/sindo-main-codex/m02-player-input`
|
||||||
|
- Outcome: the runtime player no longer polls physical keyboard keys. Movement
|
||||||
|
action strengths cross an immutable `MoveIntent` seam; camera and immediate
|
||||||
|
sandbox-flight events use the same remappable action catalog.
|
||||||
|
- Public contract: additive `MoveIntent`, `PlayerInputActions` and
|
||||||
|
`PlayerInputSource`; no schema, cache, coordinate or renderer contract change.
|
||||||
|
- Verification: `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
|
||||||
|
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
|
||||||
|
(`88` files) and StreamingFocus gates passed; unified renderer dry-run passed
|
||||||
|
all eight pre-capture gates and produced seven dry-run checkpoint results;
|
||||||
|
coordination/documentation/diff gates passed.
|
||||||
|
- Fidelity: W/S/A/D, Shift, E/Q, Space, right mouse, wheel and Escape remain the
|
||||||
|
default sandbox bindings. Regression drives forward movement, camera zoom and
|
||||||
|
immediate flight toggle through the real controller. This does not establish
|
||||||
|
exact build-12340 default bindings, turn/strafe policy or gameplay movement parity.
|
||||||
|
- Local verification inputs: the isolated worktree reused the existing ignored
|
||||||
|
native DLL and generated ADT resource scripts. Proprietary extracted data and
|
||||||
|
the character GLB were absent, so the renderer dry-run logged the expected
|
||||||
|
missing-corpus diagnostics while all contract steps returned success.
|
||||||
|
- Rebuild/migration: none. Action names are additive; persisted user-binding
|
||||||
|
migration does not exist yet and is documented as a gap.
|
||||||
|
- Remaining risks: production profile gating for sprint/free flight, exact
|
||||||
|
3.3.5a bindings, local movement/terrain/camera/presentation extraction and
|
||||||
|
broader movement/camera state tests remain M02 work.
|
||||||
|
- Documentation: inline API comments plus `docs/modules/player-input.md` with
|
||||||
|
inputs/outputs, data-flow and sequence diagrams, ownership, recovery,
|
||||||
|
capability status, known gaps and source map.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# M02-GMP-MOVEMENT-001 — Local movement state/controller
|
||||||
|
|
||||||
|
<!-- OPENWC_CLAIM:M02-GMP-MOVEMENT-001:sindo-main-codex:2026-07-16 -->
|
||||||
|
<!-- OPENWC_HANDOFF:READY:M02-GMP-MOVEMENT-001:435e1c9 -->
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Target: M02
|
||||||
|
- Program: GMP
|
||||||
|
- Owner/Agent ID: sindo-main-codex
|
||||||
|
- Branch: `work/sindo-main-codex/m02-local-movement`
|
||||||
|
- Lease expires UTC: 2026-07-16
|
||||||
|
- Integrator: M02 milestone integrator
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Move local velocity, sandbox flight state, acceleration and directional speed
|
||||||
|
calculation from `ThirdPersonWowController` into a scene-free,
|
||||||
|
independently-testable local movement controller without changing current
|
||||||
|
sandbox motion.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Extract terrain queries, camera rig, character presentation or animation.
|
||||||
|
- Add jump/fall/swim, server prediction or reconciliation.
|
||||||
|
- Change current movement speeds, acceleration, camera-relative flight or ground snap.
|
||||||
|
- Gate debug movement by build profile; that remains a separate M02 package.
|
||||||
|
- Mark M02 complete or edit its checklist/Evidence.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
- Exclusive: `src/gameplay/movement/`,
|
||||||
|
`src/tools/verify_local_player_movement.gd`,
|
||||||
|
`docs/modules/local-player-movement.md`, this claim
|
||||||
|
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
|
||||||
|
`src/tools/verify_player_input.gd`, `docs/modules/player-input.md`,
|
||||||
|
`docs/modules/README.md`
|
||||||
|
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
|
||||||
|
|
||||||
|
## Contracts and data
|
||||||
|
|
||||||
|
- Public API: stateful `LocalPlayerMovementController`
|
||||||
|
- Inputs: immutable `MoveIntent`, local Godot movement basis and physics delta seconds
|
||||||
|
- Outputs: local Godot displacement and read-only velocity/flight state
|
||||||
|
- Schema/cache/coordinate contracts: unchanged
|
||||||
|
- Consumer: current sandbox player scene; later gameplay composition root
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Requires: merged input seam on master `14dead1`
|
||||||
|
- Blocks: terrain query and camera/presentation extraction from the player hotspot
|
||||||
|
- External state: none; unit tests use synthetic intents and bases
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Commands: dedicated pure movement verifier, player-input regression, asset-free
|
||||||
|
scene smoke, coordinate/StreamingFocus gates, renderer dry-run and repository gates
|
||||||
|
- Fixtures: identity/rotated/pitched bases, acceleration steps, sprint and flight transitions
|
||||||
|
- Fidelity evidence: exact current 7.0/4.5/4.5 speeds, 28.0 acceleration,
|
||||||
|
6x sandbox sprint and camera-relative free flight are regression-locked only
|
||||||
|
- Performance budget: one small state update per physics tick; no allocation, I/O or scene lookup
|
||||||
|
|
||||||
|
## Documentation deliverables
|
||||||
|
|
||||||
|
- Inline public API docs for movement state/configuration and update methods
|
||||||
|
- Local movement module specification with data-flow, state and sequence diagrams
|
||||||
|
- Updated player-input consumer diagram/status and module registry
|
||||||
|
|
||||||
|
## Simplicity and naming
|
||||||
|
|
||||||
|
- Important name: `LocalPlayerMovementController`
|
||||||
|
- Simplest approach: one stateful RefCounted owning velocity and flight toggle
|
||||||
|
- Rejected complexity: generic locomotion framework, ECS component hierarchy and event bus
|
||||||
|
- Unavoidable complexity: movement basis remains an explicit local-space input
|
||||||
|
so the pure controller does not discover player/camera Nodes
|
||||||
|
- Measured optimization evidence: not applicable
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- State: ready-for-review
|
||||||
|
- Done: scene-free movement state/controller, player scene migration, twelve
|
||||||
|
numeric cases, two flight transitions, integrated regression and module documentation
|
||||||
|
- Next: M02 integrator reviews and merges before TerrainQuery extraction
|
||||||
|
- Blocked by:
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
- Commit: `435e1c9`
|
||||||
|
- Branch: `work/sindo-main-codex/m02-local-movement`
|
||||||
|
- Outcome: `ThirdPersonWowController` no longer owns velocity, flight state,
|
||||||
|
acceleration or directional speed calculation. It selects an explicit
|
||||||
|
Godot-world basis, applies returned displacement, and retains terrain and
|
||||||
|
presentation adapters for later M02 packages.
|
||||||
|
- Public contract: additive `LocalPlayerMovementController` with read-only
|
||||||
|
Godot-world velocity/flight state, `toggle_sandbox_flight` and `advance`.
|
||||||
|
Input, coordinate, cache and renderer contracts remain unchanged.
|
||||||
|
- Verification: `LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2
|
||||||
|
scene_boundary=1`; `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
|
||||||
|
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
|
||||||
|
and StreamingFocus passed; renderer dry-run passed all eight pre-capture gates
|
||||||
|
and seven checkpoint plans; coordination/documentation/diff gates passed.
|
||||||
|
- Fidelity: exact existing defaults are regression-locked—forward `7.0`,
|
||||||
|
backward/strafe `4.5`, acceleration `28.0`, sandbox sprint `6x`, vertical
|
||||||
|
flight `7.0`, velocity reset on toggle and camera-relative pitched flight.
|
||||||
|
This preserves the sandbox baseline but is not original-client evidence.
|
||||||
|
- Local verification inputs: the isolated worktree reused the existing ignored
|
||||||
|
native DLL and generated ADT resource scripts. Proprietary extracted data and
|
||||||
|
character GLB were absent, so renderer dry-run logged expected missing-corpus
|
||||||
|
diagnostics while every contract step returned success.
|
||||||
|
- Rebuild/migration: none; transient movement state is not persisted.
|
||||||
|
- Remaining risks: exported speed changes after `_ready` do not reconfigure the
|
||||||
|
controller; exact build-12340 movement semantics, terrain/collision state,
|
||||||
|
jump/fall/swim, server prediction and debug profile gating remain open.
|
||||||
|
- Documentation: inline public API plus `docs/modules/local-player-movement.md`
|
||||||
|
with inputs/outputs, data-flow, state and sequence diagrams, ownership,
|
||||||
|
recovery, capability status, risks and source map; player-input consumer
|
||||||
|
diagrams/status were updated.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
| Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) |
|
| Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) |
|
||||||
| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) |
|
| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) |
|
||||||
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
|
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
|
||||||
|
| Player input | Partial | [`player-input.md`](player-input.md) |
|
||||||
|
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
||||||
|
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
|
||||||
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
|
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
|
||||||
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
|
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
|
||||||
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
|
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status | Implemented |
|
| Status | Implemented |
|
||||||
| Target/work package | `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 |
|
| 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 |
|
| Profiles/capabilities | All profiles; contract version 2 |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -86,7 +86,7 @@ Forbidden dependencies:
|
|||||||
| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
|
| 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 |
|
| 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 | `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 |
|
| 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 |
|
| 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]
|
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition]
|
||||||
Godot -->|typed direct adapter| Tile
|
Godot -->|typed direct adapter| Tile
|
||||||
Godot -->|typed direct adapter| Local
|
Godot -->|typed direct adapter| Local
|
||||||
|
Godot --> TerrainQuery[AdtTerrainQuery consumer]
|
||||||
|
TerrainQuery --> Tile
|
||||||
|
TerrainQuery --> Local
|
||||||
|
TerrainQuery --> Chunk
|
||||||
Tile -->|tile plus local| Godot
|
Tile -->|tile plus local| Godot
|
||||||
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
|
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
|
||||||
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
|
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`.
|
- Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
|
||||||
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
|
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
|
||||||
validates the dedicated manifest against the same pinned fixture before GUI capture.
|
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
|
`StreamingFocus` provides typed renderer focus, and the pinned AzerothCore
|
||||||
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
|
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
|
||||||
`(32,48)`, chunk `(3,12)`.
|
`(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/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_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/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/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 |
|
| `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 |
|
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Local Player Movement
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Status | Implemented |
|
||||||
|
| Target/work package | M02 / M02-GMP-MOVEMENT-001 |
|
||||||
|
| Owners | Gameplay local movement state; scene composition owns the instance |
|
||||||
|
| Last verified | `a45d521`, 2026-07-14 |
|
||||||
|
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Own current local velocity, acceleration and sandbox flight state independently
|
||||||
|
of the player scene. Convert an immutable `MoveIntent` plus an explicit
|
||||||
|
Godot-world movement basis into deterministic per-tick displacement without
|
||||||
|
querying terrain, cameras, input devices or visual assets.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Apply world transforms or own a `CharacterBody3D`.
|
||||||
|
- Query terrain height, collision, water or slopes.
|
||||||
|
- Implement jump, fall, swim, server prediction or reconciliation.
|
||||||
|
- Select the player basis versus camera-pivot basis; the scene adapter owns that decision.
|
||||||
|
- Claim build-12340 movement fidelity from sandbox regression alone.
|
||||||
|
|
||||||
|
## Context and boundaries
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Input[MoveIntent] --> Movement[LocalPlayerMovementController]
|
||||||
|
Basis[Explicit local movement Basis] --> Movement
|
||||||
|
Delta[Physics delta seconds] --> Movement
|
||||||
|
Movement --> Displacement[Godot-world displacement]
|
||||||
|
Movement --> Velocity[Read-only velocity state]
|
||||||
|
Scene[ThirdPersonWowController] --> Basis
|
||||||
|
Displacement --> Scene
|
||||||
|
Scene --> Transform[Player world transform]
|
||||||
|
Scene --> Terrain[TerrainQuery and ground snap adapter]
|
||||||
|
Velocity --> Presentation[Visual facing and animation adapter]
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed dependencies:
|
||||||
|
|
||||||
|
- `MoveIntent` from the gameplay input boundary.
|
||||||
|
- Godot value types `Basis` and `Vector3` for renderer world-space direction math.
|
||||||
|
- Main-thread scene adapter for basis selection and displacement application.
|
||||||
|
|
||||||
|
Forbidden dependencies:
|
||||||
|
|
||||||
|
- `Node`, `Resource`, `Input`, `Camera3D`, `ADTLoader` or scene lookup.
|
||||||
|
- World-coordinate conversion or manual ADT/tile formulas.
|
||||||
|
- Character assets, animation players, materials, packets or server state.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration and sandbox sprint multiplier | Created by composition root; retained for player scene lifetime | Caller owns configuration validation |
|
||||||
|
| `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 |
|
||||||
|
| `advance(move_intent, godot_world_movement_basis, delta_seconds)` | Stateful update | Accelerates toward requested velocity and returns displacement for the tick | Owning physics thread; one call per tick | Negative delta is treated as zero |
|
||||||
|
|
||||||
|
Constructor units:
|
||||||
|
|
||||||
|
| Parameter | Unit/meaning |
|
||||||
|
|---|---|
|
||||||
|
| `run_speed_units_per_second` | Forward Godot units per second |
|
||||||
|
| `backward_speed_units_per_second` | Backward Godot units per second |
|
||||||
|
| `strafe_speed_units_per_second` | Lateral Godot units per second |
|
||||||
|
| `flight_vertical_speed_units_per_second` | Sandbox vertical Godot units per second |
|
||||||
|
| `acceleration_units_per_second_squared` | Velocity change per second |
|
||||||
|
| `sandbox_sprint_multiplier` | Dimensionless debug multiplier |
|
||||||
|
|
||||||
|
## Inputs and outputs
|
||||||
|
|
||||||
|
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
|
||||||
|
| Input | Godot-world `Basis` | Player scene 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 | Flight state | Movement controller | Basis/terrain scene adapter | Read-only boolean | Until next toggle |
|
||||||
|
|
||||||
|
Side effects:
|
||||||
|
|
||||||
|
- Mutates only controller-owned velocity and flight state.
|
||||||
|
- Does not mutate scene tree, transforms, resources, filesystem, network or cache.
|
||||||
|
- The scene adapter remains responsible for transform mutation and ground snap.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Intent[MoveIntent axes] --> Target[Calculate target velocity]
|
||||||
|
Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions]
|
||||||
|
Directions --> Target
|
||||||
|
Config[Speeds and sprint multiplier] --> Target
|
||||||
|
Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta]
|
||||||
|
Target --> Integrate
|
||||||
|
Integrate --> Current[Updated velocity]
|
||||||
|
Current --> Step[velocity × delta]
|
||||||
|
Step --> Displacement[Scene-applied displacement]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle/state
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> GroundedSandbox
|
||||||
|
GroundedSandbox --> FlyingSandbox: toggle / clear velocity
|
||||||
|
FlyingSandbox --> GroundedSandbox: toggle / clear velocity
|
||||||
|
GroundedSandbox --> GroundedSandbox: advance
|
||||||
|
FlyingSandbox --> FlyingSandbox: advance
|
||||||
|
```
|
||||||
|
|
||||||
|
The names describe the existing sandbox mode only. They are not authoritative
|
||||||
|
gameplay movement states and do not imply terrain contact or server permission.
|
||||||
|
|
||||||
|
## Main sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Input as PlayerInputSource
|
||||||
|
participant Scene as ThirdPersonWowController
|
||||||
|
participant Move as LocalPlayerMovementController
|
||||||
|
participant Present as Facing/Animation adapter
|
||||||
|
Input-->>Scene: MoveIntent
|
||||||
|
Scene->>Move: advance(intent, selected basis, delta)
|
||||||
|
Move-->>Scene: displacement
|
||||||
|
Scene->>Scene: apply global position and ground snap
|
||||||
|
Scene->>Move: godot_world_velocity_units_per_second
|
||||||
|
Scene->>Present: horizontal motion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ownership, threading and resources
|
||||||
|
|
||||||
|
- The player scene creates and exclusively owns one movement controller.
|
||||||
|
- Velocity and flight state have the same lifetime as that controller.
|
||||||
|
- All mutations currently occur on the main input/physics thread.
|
||||||
|
- Read-only value returns are copies; consumers cannot mutate owned state.
|
||||||
|
- No Nodes, Resources, RIDs, files, jobs or worker threads are created.
|
||||||
|
- The supplied `Basis` is a Godot-world direction basis, not a typed world-position contract or coordinate conversion.
|
||||||
|
|
||||||
|
## Errors, cancellation and recovery
|
||||||
|
|
||||||
|
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta |
|
||||||
|
| Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency |
|
||||||
|
| Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity |
|
||||||
|
| Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend |
|
||||||
|
|
||||||
|
There is no asynchronous cancellation, retry or rollback. Recreating the
|
||||||
|
controller resets velocity and flight state deterministically.
|
||||||
|
|
||||||
|
## Configuration and capabilities
|
||||||
|
|
||||||
|
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Forward speed | `7.0` units/s | Current sandbox | Fixed for controller lifetime | Target forward velocity |
|
||||||
|
| Backward speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target backward velocity |
|
||||||
|
| Strafe speed | `4.5` units/s | Current sandbox | Fixed for controller lifetime | Target lateral velocity |
|
||||||
|
| Acceleration | `28.0` units/s² | Current sandbox | Fixed for controller lifetime | Acceleration and deceleration |
|
||||||
|
| Flight vertical speed | `7.0` units/s | Sandbox debug | Fixed for controller lifetime | Vertical free-flight target |
|
||||||
|
| Sprint multiplier | `6.0` | Sandbox debug | Fixed for controller lifetime | Multiplies planar and vertical targets |
|
||||||
|
| Camera-relative basis | Selected only while flight is enabled | Sandbox debug | Per tick | Preserves existing pitched flight direction |
|
||||||
|
|
||||||
|
## Persistence, cache and migration
|
||||||
|
|
||||||
|
Movement state is transient and not serialized. No schema, cache, migration or
|
||||||
|
saved settings change is introduced. Future reconnect/replay state requires a
|
||||||
|
separate versioned movement snapshot contract.
|
||||||
|
|
||||||
|
## Diagnostics and observability
|
||||||
|
|
||||||
|
- Logs: verifier emits `LOCAL_PLAYER_MOVEMENT PASS` or named invariant failures.
|
||||||
|
- Metrics: none yet; update is constant-time vector math.
|
||||||
|
- Debug views: current character facing and animation remain observable scene outputs.
|
||||||
|
- Correlation IDs: not applicable to synchronous local sandbox state.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Unit/contract: `src/tools/verify_local_player_movement.gd` covers forward,
|
||||||
|
backward, strafe, rotated basis, acceleration, deceleration, sprint, flight,
|
||||||
|
pitched camera basis, state reset and invalid delta.
|
||||||
|
- Boundary: verifier rejects Node/Input/Camera/ADT dependencies and movement
|
||||||
|
integration/state remaining in the player scene.
|
||||||
|
- Scene integration: `verify_player_input.gd` drives the asset-free player
|
||||||
|
regression scene through forward motion and immediate flight toggle.
|
||||||
|
- Fidelity evidence: default values and formulas are regression-locked against
|
||||||
|
the pre-extraction sandbox. No original-client comparison is claimed.
|
||||||
|
- Performance budget: constant-time vector math, no per-tick I/O or scene lookup;
|
||||||
|
`MoveIntent` allocation remains owned by the input package.
|
||||||
|
|
||||||
|
## Extension points
|
||||||
|
|
||||||
|
- Terrain/collision policy can consume displacement without changing input or velocity ownership.
|
||||||
|
- A future server-aware predictor may replace this controller behind the scene composition boundary.
|
||||||
|
- A typed movement snapshot may expose deterministic replay state when M08/M09 require it.
|
||||||
|
- Sandbox debug capability gating can reject sprint/flight before commands reach this controller.
|
||||||
|
|
||||||
|
## Capability status
|
||||||
|
|
||||||
|
| Capability | Status | Evidence | Gap/next step |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review |
|
||||||
|
| Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim |
|
||||||
|
| Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile |
|
||||||
|
| Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned |
|
||||||
|
| Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later |
|
||||||
|
| Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts |
|
||||||
|
| Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract |
|
||||||
|
|
||||||
|
## Known gaps and risks
|
||||||
|
|
||||||
|
- Numeric defaults preserve the existing sandbox but are not original-client evidence.
|
||||||
|
- Configuration is captured at scene `_ready`; runtime mutation of exported speed
|
||||||
|
properties does not reconfigure an existing controller.
|
||||||
|
- Ground snap remains in the player scene, but its ADT data access is now behind
|
||||||
|
a replaceable `TerrainQuery`.
|
||||||
|
- Sprint and free flight still lack a typed sandbox capability gate.
|
||||||
|
- The module uses Godot math value types; a future engine-independent gameplay
|
||||||
|
predictor may require scalar/domain movement contracts.
|
||||||
|
|
||||||
|
## Source map
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
|
||||||
|
| `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary |
|
||||||
|
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
|
||||||
|
| `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression |
|
||||||
|
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
|
||||||
|
| `src/tools/verify_player_input.gd` | Input-to-movement scene regression |
|
||||||
|
|
||||||
|
## Related decisions and references
|
||||||
|
|
||||||
|
- ADR: none; this follows the existing M02 decomposition boundary.
|
||||||
|
- Upstream/reference: `targets/02-player-decomposition.md`,
|
||||||
|
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Player Input
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Status | Partial |
|
||||||
|
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 consumer update |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Convert remappable Godot Input Map actions into an immutable, scene-free
|
||||||
|
`MoveIntent` that can be consumed by the current player controller and a later
|
||||||
|
independently testable local movement controller.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Context and boundaries
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
|
||||||
|
InputMap --> Source[PlayerInputSource]
|
||||||
|
Source --> Intent[MoveIntent]
|
||||||
|
Intent --> Movement[LocalPlayerMovementController]
|
||||||
|
Movement --> Controller[ThirdPersonWowController adapter]
|
||||||
|
Controller --> Camera[Current camera adapter]
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed dependencies:
|
||||||
|
|
||||||
|
- `PlayerInputSource` may read Godot's process-wide `Input` adapter.
|
||||||
|
- The scene controller may consume `MoveIntent` and camera-specific action names.
|
||||||
|
- `MoveIntent` may contain scalar axes and request flags only.
|
||||||
|
|
||||||
|
Forbidden dependencies:
|
||||||
|
|
||||||
|
- `MoveIntent` must not read `Input`, inherit `Node`/`Resource`, or contain world coordinates.
|
||||||
|
- The input source must not mutate player transforms, camera nodes or animation state.
|
||||||
|
- Renderer, terrain parsers and network codecs must not read Input Map actions.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MoveIntent` | Immutable value | Carries normalized forward, strafe and vertical axes plus the sandbox sprint request | Created and consumed on the main physics tick; caller retains reference | Constructor clamps axes; no I/O failure |
|
||||||
|
| `MoveIntent.has_translation()` | Query | Reports whether any translation axis is non-zero | Any thread while value remains immutable | None |
|
||||||
|
| `PlayerInputActions` | Constants | Provides stable action names shared by configuration and adapters | Process lifetime | Missing project actions are detected by verification |
|
||||||
|
| `PlayerInputSource.sample_move_intent()` | Adapter method | Samples configured actions for one physics tick | Main thread; returned intent is independent of adapter lifetime | Missing actions resolve to zero strength and are a configuration defect |
|
||||||
|
| `PlayerInputSource.compose_move_intent(...)` | Pure factory | Clamps strengths, cancels opposites and normalizes diagonal planar input | Any thread with scalar inputs | None |
|
||||||
|
|
||||||
|
## Inputs and outputs
|
||||||
|
|
||||||
|
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick |
|
||||||
|
| Input | Mouse action events | Godot `_unhandled_input` dispatch | Current scene controller | Engine-owned event | Current input dispatch only |
|
||||||
|
| Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick |
|
||||||
|
| Output | Camera action names | `PlayerInputActions` | Current scene controller | 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.
|
||||||
|
- No filesystem, cache, network, database or renderer-resource mutation occurs.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Project[project.godot action defaults] --> Map[InputMap]
|
||||||
|
Remap[User or editor remapping] --> Map
|
||||||
|
Map -->|movement strengths| Source[PlayerInputSource]
|
||||||
|
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
||||||
|
Intent --> Consumer[LocalPlayerMovementController]
|
||||||
|
Consumer --> Adapter[ThirdPersonWowController]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle/state
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Main sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Engine as Godot Input
|
||||||
|
participant Source as PlayerInputSource
|
||||||
|
participant Intent as MoveIntent
|
||||||
|
participant Move as LocalPlayerMovementController
|
||||||
|
participant Player as ThirdPersonWowController adapter
|
||||||
|
Engine->>Source: movement action strengths
|
||||||
|
Source->>Intent: compose normalized axes and requests
|
||||||
|
Source-->>Player: sample_move_intent()
|
||||||
|
Player->>Move: advance(intent, selected basis, delta)
|
||||||
|
Move-->>Player: displacement and velocity state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ownership, threading and resources
|
||||||
|
|
||||||
|
- The controller owns the input-source reference for its scene lifetime.
|
||||||
|
- Each `MoveIntent` is immutable and retained only by its consumer.
|
||||||
|
- Sampling and controller mutation occur on the main physics thread.
|
||||||
|
- The module creates no Nodes, Resources, RIDs, workers or background jobs.
|
||||||
|
- Input Map definitions are project configuration; runtime remapping remains
|
||||||
|
engine-owned and does not mutate the domain value.
|
||||||
|
|
||||||
|
## Errors, cancellation and recovery
|
||||||
|
|
||||||
|
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Required action missing | Headless contract verifier | Godot returns zero input for that action | `PLAYER_INPUT` failure naming action | Restore action or user mapping |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
There is no asynchronous operation to cancel and no persisted state to roll back.
|
||||||
|
|
||||||
|
## Configuration and capabilities
|
||||||
|
|
||||||
|
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Forward/backward | Physical W/S | Current render sandbox | Yes, through Input Map | Produces signed forward axis |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Persistence, cache and migration
|
||||||
|
|
||||||
|
The action names are additive project configuration. No saved binding schema,
|
||||||
|
cache or migration exists yet. Renaming an action is a contract change and must
|
||||||
|
provide a settings migration once user settings are persisted.
|
||||||
|
|
||||||
|
## Diagnostics and observability
|
||||||
|
|
||||||
|
- Logs: dedicated verifier emits `PLAYER_INPUT PASS` or one failure per invariant.
|
||||||
|
- Metrics: none; sampling cost is one small value allocation per physics tick.
|
||||||
|
- Debug views: none.
|
||||||
|
- Correlation IDs: not applicable to local synchronous input.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Unit/contract tests: `src/tools/verify_player_input.gd` validates all actions,
|
||||||
|
default bindings, axis cancellation/clamping/normalization and debug request flags.
|
||||||
|
- Integration/E2E: `src/tests/scenes/player_input_regression.tscn` is loaded by
|
||||||
|
the verifier, which drives forward, camera-zoom and immediate sandbox-flight
|
||||||
|
actions through the real scene controller and also rejects direct
|
||||||
|
physical-key polling.
|
||||||
|
- 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
|
||||||
|
intent allocation per physics tick.
|
||||||
|
- Manual diagnostics: run the Eastern Kingdoms scene and remap one movement
|
||||||
|
action in Project Settings before checking movement.
|
||||||
|
|
||||||
|
## Extension points
|
||||||
|
|
||||||
|
- A gamepad/accessibility adapter may call `compose_move_intent` without changing
|
||||||
|
movement consumers.
|
||||||
|
- A later input-context owner may suppress gameplay actions while preserving the
|
||||||
|
same intent contract.
|
||||||
|
- A future server-aware movement predictor may replace the current local
|
||||||
|
movement consumer without changing `PlayerInputSource`.
|
||||||
|
|
||||||
|
## Capability status
|
||||||
|
|
||||||
|
| Capability | Status | Evidence | Gap/next step |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
|
||||||
|
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | 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 |
|
||||||
|
| 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.
|
||||||
|
- There is no persisted user keybinding format or UI yet.
|
||||||
|
|
||||||
|
## Source map
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `project.godot` | Additive default Input Map bindings |
|
||||||
|
| `src/domain/input/move_intent.gd` | Immutable locomotion request contract |
|
||||||
|
| `src/gameplay/input/player_input_actions.gd` | Stable project action names |
|
||||||
|
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
|
||||||
|
| `src/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/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture |
|
||||||
|
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
||||||
|
|
||||||
|
## Related decisions and references
|
||||||
|
|
||||||
|
- ADR: none; this implements the existing architecture and M02 seam.
|
||||||
|
- Upstream/reference: `targets/02-player-decomposition.md`,
|
||||||
|
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
|
||||||
@@ -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`.
|
||||||
@@ -19,6 +19,69 @@ config/icon="res://icon.svg"
|
|||||||
|
|
||||||
enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg")
|
enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg")
|
||||||
|
|
||||||
|
[input]
|
||||||
|
|
||||||
|
openwc_player_move_forward={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_move_backward={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_strafe_left={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_strafe_right={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_debug_fly_up={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_debug_fly_down={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_debug_sprint={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194325,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_debug_toggle_flight={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":32,"unicode":32,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_camera_rotate={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_camera_zoom_in={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_camera_zoom_out={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
openwc_player_release_cursor={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class_name MoveIntent
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Immutable player locomotion request expressed in character-local axes.
|
||||||
|
## Axis values are normalized to `[-1, 1]`: forward/right/up are positive.
|
||||||
|
## The intent contains no world coordinates, engine input events or presentation state.
|
||||||
|
|
||||||
|
var forward_axis: float:
|
||||||
|
get:
|
||||||
|
return _forward_axis
|
||||||
|
|
||||||
|
var strafe_axis: float:
|
||||||
|
get:
|
||||||
|
return _strafe_axis
|
||||||
|
|
||||||
|
var vertical_axis: float:
|
||||||
|
get:
|
||||||
|
return _vertical_axis
|
||||||
|
|
||||||
|
var is_sprint_requested: bool:
|
||||||
|
get:
|
||||||
|
return _is_sprint_requested
|
||||||
|
|
||||||
|
var _forward_axis: float
|
||||||
|
var _strafe_axis: float
|
||||||
|
var _vertical_axis: float
|
||||||
|
var _is_sprint_requested: bool
|
||||||
|
|
||||||
|
|
||||||
|
## Creates one frame's movement request without retaining mutable input state.
|
||||||
|
func _init(
|
||||||
|
forward_axis_value: float = 0.0,
|
||||||
|
strafe_axis_value: float = 0.0,
|
||||||
|
vertical_axis_value: float = 0.0,
|
||||||
|
sprint_requested: bool = false
|
||||||
|
) -> void:
|
||||||
|
_forward_axis = clampf(forward_axis_value, -1.0, 1.0)
|
||||||
|
_strafe_axis = clampf(strafe_axis_value, -1.0, 1.0)
|
||||||
|
_vertical_axis = clampf(vertical_axis_value, -1.0, 1.0)
|
||||||
|
_is_sprint_requested = sprint_requested
|
||||||
|
|
||||||
|
|
||||||
|
## Returns true when the intent requests planar or vertical translation.
|
||||||
|
func has_translation() -> bool:
|
||||||
|
return not is_zero_approx(_forward_axis) \
|
||||||
|
or not is_zero_approx(_strafe_axis) \
|
||||||
|
or not is_zero_approx(_vertical_axis)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://btk7brcavbeec
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
class_name PlayerInputActions
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Stable Input Map action names consumed by the runtime player input adapter.
|
||||||
|
## Defaults preserve the current render-sandbox controls; users may remap every
|
||||||
|
## action through Godot's Input Map without changing gameplay code.
|
||||||
|
|
||||||
|
const MOVE_FORWARD := &"openwc_player_move_forward"
|
||||||
|
const MOVE_BACKWARD := &"openwc_player_move_backward"
|
||||||
|
const STRAFE_LEFT := &"openwc_player_strafe_left"
|
||||||
|
const STRAFE_RIGHT := &"openwc_player_strafe_right"
|
||||||
|
const DEBUG_FLY_UP := &"openwc_player_debug_fly_up"
|
||||||
|
const DEBUG_FLY_DOWN := &"openwc_player_debug_fly_down"
|
||||||
|
const DEBUG_SPRINT := &"openwc_player_debug_sprint"
|
||||||
|
const DEBUG_TOGGLE_FLIGHT := &"openwc_player_debug_toggle_flight"
|
||||||
|
const CAMERA_ROTATE := &"openwc_player_camera_rotate"
|
||||||
|
const CAMERA_ZOOM_IN := &"openwc_player_camera_zoom_in"
|
||||||
|
const CAMERA_ZOOM_OUT := &"openwc_player_camera_zoom_out"
|
||||||
|
const RELEASE_CURSOR := &"openwc_player_release_cursor"
|
||||||
|
|
||||||
|
const REQUIRED_ACTIONS: Array[StringName] = [
|
||||||
|
MOVE_FORWARD,
|
||||||
|
MOVE_BACKWARD,
|
||||||
|
STRAFE_LEFT,
|
||||||
|
STRAFE_RIGHT,
|
||||||
|
DEBUG_FLY_UP,
|
||||||
|
DEBUG_FLY_DOWN,
|
||||||
|
DEBUG_SPRINT,
|
||||||
|
DEBUG_TOGGLE_FLIGHT,
|
||||||
|
CAMERA_ROTATE,
|
||||||
|
CAMERA_ZOOM_IN,
|
||||||
|
CAMERA_ZOOM_OUT,
|
||||||
|
RELEASE_CURSOR,
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bbhtyxm428bkm
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
class_name PlayerInputSource
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Converts remappable Godot Input Map actions into immutable [MoveIntent] values.
|
||||||
|
## This adapter owns no movement state and may be replaced independently of the
|
||||||
|
## local movement controller.
|
||||||
|
|
||||||
|
|
||||||
|
## Samples the current Input singleton state for one physics tick.
|
||||||
|
func sample_move_intent() -> MoveIntent:
|
||||||
|
return compose_move_intent(
|
||||||
|
Input.get_action_strength(PlayerInputActions.MOVE_FORWARD),
|
||||||
|
Input.get_action_strength(PlayerInputActions.MOVE_BACKWARD),
|
||||||
|
Input.get_action_strength(PlayerInputActions.STRAFE_LEFT),
|
||||||
|
Input.get_action_strength(PlayerInputActions.STRAFE_RIGHT),
|
||||||
|
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_UP),
|
||||||
|
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_DOWN),
|
||||||
|
Input.is_action_pressed(PlayerInputActions.DEBUG_SPRINT)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Builds a normalized intent from action strengths for deterministic tests and
|
||||||
|
## alternative input adapters. Strengths outside `[0, 1]` are clamped.
|
||||||
|
static func compose_move_intent(
|
||||||
|
forward_strength: float,
|
||||||
|
backward_strength: float,
|
||||||
|
strafe_left_strength: float,
|
||||||
|
strafe_right_strength: float,
|
||||||
|
fly_up_strength: float,
|
||||||
|
fly_down_strength: float,
|
||||||
|
sprint_requested: bool
|
||||||
|
) -> MoveIntent:
|
||||||
|
var planar_axis := Vector2(
|
||||||
|
clampf(strafe_right_strength, 0.0, 1.0) - clampf(strafe_left_strength, 0.0, 1.0),
|
||||||
|
clampf(forward_strength, 0.0, 1.0) - clampf(backward_strength, 0.0, 1.0)
|
||||||
|
)
|
||||||
|
if planar_axis.length_squared() > 1.0:
|
||||||
|
planar_axis = planar_axis.normalized()
|
||||||
|
var vertical_axis := clampf(fly_up_strength, 0.0, 1.0) - clampf(fly_down_strength, 0.0, 1.0)
|
||||||
|
return MoveIntent.new(
|
||||||
|
planar_axis.y,
|
||||||
|
planar_axis.x,
|
||||||
|
vertical_axis,
|
||||||
|
sprint_requested
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b050i2lnei8qq
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
class_name LocalPlayerMovementController
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Scene-free state and velocity integrator for the current local sandbox player.
|
||||||
|
## Direction inputs use a Godot-world movement basis selected by the scene
|
||||||
|
## adapter. This class does not discover Nodes, query terrain or mutate transforms.
|
||||||
|
|
||||||
|
var godot_world_velocity_units_per_second: Vector3:
|
||||||
|
get:
|
||||||
|
return _godot_world_velocity_units_per_second
|
||||||
|
|
||||||
|
var is_flight_enabled: bool:
|
||||||
|
get:
|
||||||
|
return _is_flight_enabled
|
||||||
|
|
||||||
|
var _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 _godot_world_velocity_units_per_second := Vector3.ZERO
|
||||||
|
var _is_flight_enabled := false
|
||||||
|
|
||||||
|
|
||||||
|
## Creates a movement state with explicit Godot-unit speeds and acceleration.
|
||||||
|
## The current compatibility renderer treats one Godot unit as one WoW yard.
|
||||||
|
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
|
||||||
|
) -> void:
|
||||||
|
_run_speed_units_per_second = run_speed_units_per_second
|
||||||
|
_backward_speed_units_per_second = backward_speed_units_per_second
|
||||||
|
_strafe_speed_units_per_second = strafe_speed_units_per_second
|
||||||
|
_flight_vertical_speed_units_per_second = flight_vertical_speed_units_per_second
|
||||||
|
_acceleration_units_per_second_squared = acceleration_units_per_second_squared
|
||||||
|
_sandbox_sprint_multiplier = sandbox_sprint_multiplier
|
||||||
|
|
||||||
|
|
||||||
|
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
|
||||||
|
## Returns the new flight state.
|
||||||
|
func toggle_sandbox_flight() -> bool:
|
||||||
|
_is_flight_enabled = not _is_flight_enabled
|
||||||
|
_godot_world_velocity_units_per_second = Vector3.ZERO
|
||||||
|
return _is_flight_enabled
|
||||||
|
|
||||||
|
|
||||||
|
## Advances velocity by one physics step and returns local-world displacement.
|
||||||
|
## `godot_world_movement_basis` is the player basis on ground and camera-pivot basis in flight.
|
||||||
|
## Negative delta values are treated as zero and do not mutate velocity.
|
||||||
|
func advance(
|
||||||
|
move_intent: MoveIntent,
|
||||||
|
godot_world_movement_basis: Basis,
|
||||||
|
delta_seconds: float
|
||||||
|
) -> Vector3:
|
||||||
|
var safe_delta_seconds := maxf(delta_seconds, 0.0)
|
||||||
|
if is_zero_approx(safe_delta_seconds):
|
||||||
|
return Vector3.ZERO
|
||||||
|
|
||||||
|
var target_velocity := _calculate_target_velocity(move_intent, godot_world_movement_basis)
|
||||||
|
_godot_world_velocity_units_per_second = _godot_world_velocity_units_per_second.move_toward(
|
||||||
|
target_velocity,
|
||||||
|
_acceleration_units_per_second_squared * safe_delta_seconds
|
||||||
|
)
|
||||||
|
return _godot_world_velocity_units_per_second * safe_delta_seconds
|
||||||
|
|
||||||
|
|
||||||
|
func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_basis: Basis) -> Vector3:
|
||||||
|
var forward_direction := -godot_world_movement_basis.z
|
||||||
|
var right_direction := godot_world_movement_basis.x
|
||||||
|
if not _is_flight_enabled:
|
||||||
|
forward_direction.y = 0.0
|
||||||
|
right_direction.y = 0.0
|
||||||
|
forward_direction = forward_direction.normalized()
|
||||||
|
right_direction = right_direction.normalized()
|
||||||
|
|
||||||
|
var forward_speed := (
|
||||||
|
_run_speed_units_per_second
|
||||||
|
if move_intent.forward_axis > 0.0
|
||||||
|
else _backward_speed_units_per_second
|
||||||
|
)
|
||||||
|
var speed_multiplier := _sandbox_sprint_multiplier if move_intent.is_sprint_requested else 1.0
|
||||||
|
var target_velocity := (
|
||||||
|
forward_direction * move_intent.forward_axis * forward_speed
|
||||||
|
+ right_direction * move_intent.strafe_axis * _strafe_speed_units_per_second
|
||||||
|
) * speed_multiplier
|
||||||
|
if _is_flight_enabled:
|
||||||
|
target_velocity.y += (
|
||||||
|
move_intent.vertical_axis
|
||||||
|
* _flight_vertical_speed_units_per_second
|
||||||
|
* speed_multiplier
|
||||||
|
)
|
||||||
|
return target_velocity
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxlx5kloh7p5c
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -7,9 +7,10 @@ const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordina
|
|||||||
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||||
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
||||||
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
||||||
|
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
|
||||||
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
||||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.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 extracted_dir: String = "res://data/extracted"
|
||||||
@export var map_name: String = "Azeroth"
|
@export var map_name: String = "Azeroth"
|
||||||
@@ -52,12 +53,32 @@ var _active_animation := ""
|
|||||||
var _captured := false
|
var _captured := false
|
||||||
var _yaw := 0.0
|
var _yaw := 0.0
|
||||||
var _pitch := deg_to_rad(-18.0)
|
var _pitch := deg_to_rad(-18.0)
|
||||||
var _horizontal_velocity := Vector3.ZERO
|
var _player_input_source: PlayerInputSource
|
||||||
var _flight_enabled := false
|
var _local_movement_controller: LocalPlayerMovementController
|
||||||
var _adt_cache: Dictionary = {}
|
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:
|
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
|
||||||
|
)
|
||||||
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
|
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
|
||||||
_camera = get_node_or_null(camera_path) as Camera3D
|
_camera = get_node_or_null(camera_path) as Camera3D
|
||||||
_visual = get_node_or_null(visual_path) as Node3D
|
_visual = get_node_or_null(visual_path) as Node3D
|
||||||
@@ -71,29 +92,31 @@ func _ready() -> void:
|
|||||||
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
|
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)
|
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
|
||||||
_load_character_visual()
|
_load_character_visual()
|
||||||
var ground := _sample_ground_height(global_position)
|
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||||
if is_finite(ground):
|
if spawn_ground_sample.is_available:
|
||||||
global_position.y = ground + ground_offset
|
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||||
_yaw = rotation.y
|
_yaw = rotation.y
|
||||||
_apply_camera_transform()
|
_apply_camera_transform()
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
|
||||||
_captured = event.pressed
|
_captured = true
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
|
||||||
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
|
|
||||||
_apply_camera_transform()
|
|
||||||
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
|
||||||
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
|
|
||||||
_apply_camera_transform()
|
|
||||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
|
||||||
_captured = false
|
_captured = false
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE:
|
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN):
|
||||||
_flight_enabled = not _flight_enabled
|
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
|
||||||
_horizontal_velocity = Vector3.ZERO
|
_apply_camera_transform()
|
||||||
|
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT):
|
||||||
|
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
|
||||||
|
_apply_camera_transform()
|
||||||
|
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR):
|
||||||
|
_captured = false
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
||||||
|
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||||
|
_local_movement_controller.toggle_sandbox_flight()
|
||||||
|
|
||||||
if _captured and event is InputEventMouseMotion:
|
if _captured and event is InputEventMouseMotion:
|
||||||
_yaw -= event.relative.x * mouse_sensitivity
|
_yaw -= event.relative.x * mouse_sensitivity
|
||||||
@@ -103,72 +126,27 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
var input_dir := _get_input_dir()
|
var move_intent := _player_input_source.sample_move_intent()
|
||||||
var target_velocity := Vector3.ZERO
|
var godot_world_movement_basis := global_basis
|
||||||
if input_dir.length_squared() > 0.0:
|
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
||||||
target_velocity = _movement_vector(input_dir)
|
godot_world_movement_basis = _camera_pivot.global_basis
|
||||||
if _flight_enabled:
|
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
||||||
target_velocity.y += _flight_vertical_velocity()
|
|
||||||
|
|
||||||
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
|
if not _local_movement_controller.is_flight_enabled:
|
||||||
global_position += _horizontal_velocity * delta
|
var ground_sample := _sample_ground_height(global_position)
|
||||||
|
if ground_sample.is_available:
|
||||||
if not _flight_enabled:
|
var target_y := ground_sample.height_units + ground_offset
|
||||||
var ground := _sample_ground_height(global_position)
|
|
||||||
if is_finite(ground):
|
|
||||||
var target_y := ground + ground_offset
|
|
||||||
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
|
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
|
||||||
|
|
||||||
var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z)
|
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:
|
if _visual and horizontal_motion.length_squared() > 0.01:
|
||||||
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
|
_visual.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
|
||||||
|
|
||||||
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||||
|
|
||||||
_apply_camera_transform()
|
_apply_camera_transform()
|
||||||
|
|
||||||
|
|
||||||
func _get_input_dir() -> Vector2:
|
|
||||||
var dir := Vector2.ZERO
|
|
||||||
if Input.is_key_pressed(KEY_W):
|
|
||||||
dir.y -= 1.0
|
|
||||||
if Input.is_key_pressed(KEY_S):
|
|
||||||
dir.y += 1.0
|
|
||||||
if Input.is_key_pressed(KEY_A):
|
|
||||||
dir.x -= 1.0
|
|
||||||
if Input.is_key_pressed(KEY_D):
|
|
||||||
dir.x += 1.0
|
|
||||||
return dir.normalized()
|
|
||||||
|
|
||||||
|
|
||||||
func _movement_vector(input_dir: Vector2) -> Vector3:
|
|
||||||
var forward := -global_basis.z
|
|
||||||
var right := global_basis.x
|
|
||||||
if _flight_enabled and _camera_pivot:
|
|
||||||
forward = -_camera_pivot.global_basis.z
|
|
||||||
right = _camera_pivot.global_basis.x
|
|
||||||
else:
|
|
||||||
forward.y = 0.0
|
|
||||||
right.y = 0.0
|
|
||||||
forward = forward.normalized()
|
|
||||||
right = right.normalized()
|
|
||||||
|
|
||||||
var speed_z := run_speed if input_dir.y < 0.0 else backward_speed
|
|
||||||
var speed_x := strafe_speed
|
|
||||||
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
|
|
||||||
return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint
|
|
||||||
|
|
||||||
|
|
||||||
func _flight_vertical_velocity() -> float:
|
|
||||||
var dir := 0.0
|
|
||||||
if Input.is_key_pressed(KEY_E):
|
|
||||||
dir += 1.0
|
|
||||||
if Input.is_key_pressed(KEY_Q):
|
|
||||||
dir -= 1.0
|
|
||||||
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
|
|
||||||
return dir * flight_vertical_speed * sprint
|
|
||||||
|
|
||||||
|
|
||||||
func _load_character_visual() -> void:
|
func _load_character_visual() -> void:
|
||||||
if character_model_path.is_empty() or _visual == null:
|
if character_model_path.is_empty() or _visual == null:
|
||||||
return
|
return
|
||||||
@@ -329,95 +307,11 @@ func _apply_camera_transform() -> void:
|
|||||||
_camera.rotation = Vector3.ZERO
|
_camera.rotation = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
func _sample_ground_height(world_pos: Vector3) -> float:
|
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||||
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
|
return _terrain_query.sample_ground_height(
|
||||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
|
GODOT_WORLD_POSITION_SCRIPT.new(
|
||||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
|
godot_world_position.x,
|
||||||
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
|
godot_world_position.y,
|
||||||
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
godot_world_position.z
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"]
|
||||||
|
|
||||||
|
[node name="PlayerInputRegression" type="CharacterBody3D"]
|
||||||
|
script = ExtResource("1_player")
|
||||||
|
spawn_at_tile_center = false
|
||||||
|
character_model_path = ""
|
||||||
|
|
||||||
|
[node name="Visual" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="CameraPivot" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
|
||||||
@@ -20,7 +20,8 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
|
|||||||
const REQUIRED_MAPPER_CALLS := {
|
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/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/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"],
|
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ func _initialize() -> void:
|
|||||||
quit(1)
|
quit(1)
|
||||||
return
|
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)
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Headless M02 unit and boundary regression for local player movement state.
|
||||||
|
|
||||||
|
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
|
||||||
|
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var failures: Array[String] = []
|
||||||
|
_verify_directional_speeds(failures)
|
||||||
|
_verify_acceleration_and_deceleration(failures)
|
||||||
|
_verify_sandbox_sprint(failures)
|
||||||
|
_verify_flight_state_and_basis(failures)
|
||||||
|
_verify_invalid_delta(failures)
|
||||||
|
_verify_scene_boundary(failures)
|
||||||
|
|
||||||
|
if not failures.is_empty():
|
||||||
|
for failure in failures:
|
||||||
|
push_error("LOCAL_PLAYER_MOVEMENT: %s" % failure)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("LOCAL_PLAYER_MOVEMENT PASS cases=12 state_transitions=2 scene_boundary=1")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_directional_speeds(failures: Array[String]) -> void:
|
||||||
|
var forward_controller := _new_controller()
|
||||||
|
var forward_displacement := forward_controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||||
|
_expect_vector_near(forward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "forward velocity", failures)
|
||||||
|
_expect_vector_near(forward_displacement, Vector3(0.0, 0.0, -7.0), "forward displacement", failures)
|
||||||
|
|
||||||
|
var backward_controller := _new_controller()
|
||||||
|
backward_controller.advance(_intent(-1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||||
|
_expect_vector_near(backward_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, 4.5), "backward velocity", failures)
|
||||||
|
|
||||||
|
var strafe_controller := _new_controller()
|
||||||
|
strafe_controller.advance(_intent(0.0, 1.0), Basis.IDENTITY, 1.0)
|
||||||
|
_expect_vector_near(strafe_controller.godot_world_velocity_units_per_second, Vector3(4.5, 0.0, 0.0), "strafe velocity", failures)
|
||||||
|
|
||||||
|
var rotated_controller := _new_controller()
|
||||||
|
var rotated_basis := Basis(Vector3.UP, deg_to_rad(90.0))
|
||||||
|
rotated_controller.advance(_intent(1.0, 0.0), rotated_basis, 1.0)
|
||||||
|
_expect_vector_near(
|
||||||
|
rotated_controller.godot_world_velocity_units_per_second,
|
||||||
|
(-rotated_basis.z).normalized() * 7.0,
|
||||||
|
"rotated character basis",
|
||||||
|
failures
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_acceleration_and_deceleration(failures: Array[String]) -> void:
|
||||||
|
var controller := _new_controller()
|
||||||
|
var forward_intent := _intent(1.0, 0.0)
|
||||||
|
_expect_vector_near(controller.advance(forward_intent, Basis.IDENTITY, 0.1), Vector3(0.0, 0.0, -0.28), "first acceleration displacement", failures)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "first acceleration velocity", failures)
|
||||||
|
controller.advance(forward_intent, Basis.IDENTITY, 0.1)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -5.6), "second acceleration velocity", failures)
|
||||||
|
controller.advance(_intent(0.0, 0.0), Basis.IDENTITY, 0.1)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -2.8), "deceleration velocity", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_sandbox_sprint(failures: Array[String]) -> void:
|
||||||
|
var controller := _new_controller()
|
||||||
|
controller.advance(_intent(1.0, 0.0, 0.0, true), Basis.IDENTITY, 2.0)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint velocity", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_flight_state_and_basis(failures: Array[String]) -> void:
|
||||||
|
var controller := _new_controller()
|
||||||
|
controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, 1.0)
|
||||||
|
_expect_true(controller.toggle_sandbox_flight(), "flight toggles on", failures)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "flight toggle clears velocity", failures)
|
||||||
|
|
||||||
|
var pitched_basis := Basis(Vector3.RIGHT, deg_to_rad(30.0))
|
||||||
|
controller.advance(_intent(1.0, 0.0, 1.0), pitched_basis, 1.0)
|
||||||
|
var expected_flight_velocity := (-pitched_basis.z).normalized() * 7.0 + Vector3.UP * 7.0
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, expected_flight_velocity, "camera-relative flight velocity", failures)
|
||||||
|
_expect_true(not controller.toggle_sandbox_flight(), "flight toggles off", failures)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "landing toggle clears velocity", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_invalid_delta(failures: Array[String]) -> void:
|
||||||
|
var controller := _new_controller()
|
||||||
|
var displacement := controller.advance(_intent(1.0, 0.0), Basis.IDENTITY, -1.0)
|
||||||
|
_expect_vector_near(displacement, Vector3.ZERO, "negative delta displacement", failures)
|
||||||
|
_expect_vector_near(controller.godot_world_velocity_units_per_second, Vector3.ZERO, "negative delta state", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_scene_boundary(failures: Array[String]) -> void:
|
||||||
|
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
|
||||||
|
for forbidden_text in ["extends Node", "extends Resource", "Input.", "Camera3D", "ADTLoader", "global_position"]:
|
||||||
|
_expect_true(not movement_source.contains(forbidden_text), "movement controller omits %s" % forbidden_text, failures)
|
||||||
|
|
||||||
|
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||||
|
_expect_true(player_source.contains("_local_movement_controller.advance"), "scene delegates movement update", failures)
|
||||||
|
_expect_true(not player_source.contains("move_toward(target_velocity"), "scene omits velocity integration", failures)
|
||||||
|
_expect_true(not player_source.contains("func _movement_vector"), "scene omits movement-vector calculation", failures)
|
||||||
|
_expect_true(not player_source.contains("_horizontal_velocity"), "scene omits movement velocity state", failures)
|
||||||
|
_expect_true(not player_source.contains("var _flight_enabled"), "scene omits flight state", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _new_controller() -> LocalPlayerMovementController:
|
||||||
|
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _intent(
|
||||||
|
forward_axis: float,
|
||||||
|
strafe_axis: float,
|
||||||
|
vertical_axis: float = 0.0,
|
||||||
|
sprint_requested: bool = false
|
||||||
|
) -> MoveIntent:
|
||||||
|
return MoveIntent.new(forward_axis, strafe_axis, vertical_axis, sprint_requested)
|
||||||
|
|
||||||
|
|
||||||
|
func _read_text(path: String, failures: Array[String]) -> String:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
failures.append("cannot open %s" % path)
|
||||||
|
return ""
|
||||||
|
return file.get_as_text()
|
||||||
|
|
||||||
|
|
||||||
|
func _expect_vector_near(
|
||||||
|
actual_value: Vector3,
|
||||||
|
expected_value: Vector3,
|
||||||
|
label: String,
|
||||||
|
failures: Array[String]
|
||||||
|
) -> void:
|
||||||
|
if not actual_value.is_equal_approx(expected_value):
|
||||||
|
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||||
|
|
||||||
|
|
||||||
|
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||||
|
if not actual_value:
|
||||||
|
failures.append("%s expected true" % label)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Headless M02 contract regression for remappable actions and MoveIntent composition.
|
||||||
|
|
||||||
|
const MOVE_INTENT_PATH := "res://src/domain/input/move_intent.gd"
|
||||||
|
const PLAYER_INPUT_SOURCE_PATH := "res://src/gameplay/input/player_input_source.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_default_input_actions(failures)
|
||||||
|
_verify_move_intent_composition(failures)
|
||||||
|
_verify_controller_boundary(failures)
|
||||||
|
_verify_regression_scene(failures)
|
||||||
|
|
||||||
|
if not failures.is_empty():
|
||||||
|
for failure in failures:
|
||||||
|
push_error("PLAYER_INPUT: %s" % failure)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("PLAYER_INPUT PASS actions=%d intent_cases=6 controller=1 regression_scene=1" % PlayerInputActions.REQUIRED_ACTIONS.size())
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_default_input_actions(failures: Array[String]) -> void:
|
||||||
|
for action_name in PlayerInputActions.REQUIRED_ACTIONS:
|
||||||
|
_expect_true(InputMap.has_action(action_name), "Input Map contains %s" % action_name, failures)
|
||||||
|
|
||||||
|
_expect_key_binding(PlayerInputActions.MOVE_FORWARD, KEY_W, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_S, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.STRAFE_LEFT, KEY_A, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.STRAFE_RIGHT, KEY_D, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.DEBUG_FLY_UP, KEY_E, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.DEBUG_FLY_DOWN, KEY_Q, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.DEBUG_SPRINT, KEY_SHIFT, false, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.DEBUG_TOGGLE_FLIGHT, KEY_SPACE, true, failures)
|
||||||
|
_expect_key_binding(PlayerInputActions.RELEASE_CURSOR, KEY_ESCAPE, false, failures)
|
||||||
|
_expect_mouse_binding(PlayerInputActions.CAMERA_ROTATE, MOUSE_BUTTON_RIGHT, failures)
|
||||||
|
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_IN, MOUSE_BUTTON_WHEEL_UP, failures)
|
||||||
|
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_OUT, MOUSE_BUTTON_WHEEL_DOWN, failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_move_intent_composition(failures: Array[String]) -> void:
|
||||||
|
var neutral := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
|
||||||
|
_expect_near(neutral.forward_axis, 0.0, "neutral forward", failures)
|
||||||
|
_expect_true(not neutral.has_translation(), "neutral has no translation", failures)
|
||||||
|
|
||||||
|
var forward := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
|
||||||
|
_expect_near(forward.forward_axis, 1.0, "forward axis", failures)
|
||||||
|
|
||||||
|
var canceled := PlayerInputSource.compose_move_intent(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, false)
|
||||||
|
_expect_true(not canceled.has_translation(), "opposite actions cancel", failures)
|
||||||
|
|
||||||
|
var diagonal := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, false)
|
||||||
|
_expect_near(Vector2(diagonal.strafe_axis, diagonal.forward_axis).length(), 1.0, "diagonal normalized", failures)
|
||||||
|
|
||||||
|
var vertical := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 1.5, -0.5, false)
|
||||||
|
_expect_near(vertical.vertical_axis, 1.0, "vertical strengths clamped", failures)
|
||||||
|
|
||||||
|
var debug_requests := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true)
|
||||||
|
_expect_true(debug_requests.is_sprint_requested, "sprint request retained", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_controller_boundary(failures: Array[String]) -> void:
|
||||||
|
var controller_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||||
|
_expect_true(controller_source.contains("sample_move_intent()"), "controller samples PlayerInputSource", failures)
|
||||||
|
_expect_true(not controller_source.contains("Input.is_key_pressed"), "controller omits direct keyboard polling", failures)
|
||||||
|
_expect_true(not controller_source.contains("KEY_"), "controller omits hardcoded key constants", failures)
|
||||||
|
|
||||||
|
var move_intent_source := _read_text(MOVE_INTENT_PATH, failures)
|
||||||
|
_expect_true(not move_intent_source.contains("extends Node"), "MoveIntent does not inherit Node", failures)
|
||||||
|
_expect_true(not move_intent_source.contains("extends Resource"), "MoveIntent does not inherit Resource", failures)
|
||||||
|
_expect_true(not move_intent_source.contains("Input."), "MoveIntent does not read engine input", failures)
|
||||||
|
|
||||||
|
var input_source := _read_text(PLAYER_INPUT_SOURCE_PATH, failures)
|
||||||
|
_expect_true(input_source.contains("Input.get_action_strength"), "input source reads action strengths", failures)
|
||||||
|
_expect_true(not input_source.contains("is_key_pressed"), "input source omits physical-key polling", failures)
|
||||||
|
|
||||||
|
|
||||||
|
func _verify_regression_scene(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
|
||||||
|
_expect_true(player != null, "regression player instantiates", failures)
|
||||||
|
if player == null:
|
||||||
|
return
|
||||||
|
root.add_child(player)
|
||||||
|
player.set_physics_process(false)
|
||||||
|
|
||||||
|
Input.action_press(PlayerInputActions.MOVE_FORWARD)
|
||||||
|
player.call("_physics_process", 1.0)
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
var flight_toggle_event := InputEventAction.new()
|
||||||
|
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
||||||
|
flight_toggle_event.pressed = true
|
||||||
|
player.call("_unhandled_input", flight_toggle_event)
|
||||||
|
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
|
||||||
|
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
|
||||||
|
player.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _expect_key_binding(
|
||||||
|
action_name: StringName,
|
||||||
|
expected_keycode: Key,
|
||||||
|
uses_physical_keycode: bool,
|
||||||
|
failures: Array[String]
|
||||||
|
) -> void:
|
||||||
|
for input_event in InputMap.action_get_events(action_name):
|
||||||
|
if input_event is InputEventKey:
|
||||||
|
var key_event := input_event as InputEventKey
|
||||||
|
var configured_keycode := key_event.physical_keycode if uses_physical_keycode else key_event.keycode
|
||||||
|
if configured_keycode == expected_keycode:
|
||||||
|
return
|
||||||
|
failures.append("%s missing expected key binding %s" % [action_name, expected_keycode])
|
||||||
|
|
||||||
|
|
||||||
|
func _expect_mouse_binding(
|
||||||
|
action_name: StringName,
|
||||||
|
expected_button: MouseButton,
|
||||||
|
failures: Array[String]
|
||||||
|
) -> void:
|
||||||
|
for input_event in InputMap.action_get_events(action_name):
|
||||||
|
if input_event is InputEventMouseButton and input_event.button_index == expected_button:
|
||||||
|
return
|
||||||
|
failures.append("%s missing expected mouse binding %s" % [action_name, expected_button])
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user