Merge pull request 'gmp(M02): extract local player movement' (#22) from work/sindo-main-codex/m02-local-movement into master
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
@@ -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.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| 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) |
|
| Player input | Partial | [`player-input.md`](player-input.md) |
|
||||||
|
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.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) |
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 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 | `435e1c9`, 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[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 | Outside module | Scene skips existing ground snap | Existing terrain diagnostics | TerrainQuery extraction will own policy |
|
||||||
|
|
||||||
|
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/collision movement policy | Planned | Outside module by design | Extract `TerrainQuery` next |
|
||||||
|
| 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 until `TerrainQuery` extraction.
|
||||||
|
- 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/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`.
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status | Partial |
|
| Status | Partial |
|
||||||
| Target/work package | M02 / M02-GMP-INPUT-001 |
|
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 consumer update |
|
||||||
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
|
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
|
||||||
| Last verified | `6bd2e84`, 2026-07-14 |
|
| Last verified | `435e1c9`, 2026-07-14 |
|
||||||
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
|
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -31,8 +31,8 @@ flowchart LR
|
|||||||
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
|
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
|
||||||
InputMap --> Source[PlayerInputSource]
|
InputMap --> Source[PlayerInputSource]
|
||||||
Source --> Intent[MoveIntent]
|
Source --> Intent[MoveIntent]
|
||||||
Intent --> Controller[ThirdPersonWowController]
|
Intent --> Movement[LocalPlayerMovementController]
|
||||||
Controller --> Movement[Current sandbox movement]
|
Movement --> Controller[ThirdPersonWowController adapter]
|
||||||
Controller --> Camera[Current camera adapter]
|
Controller --> Camera[Current camera adapter]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Forbidden dependencies:
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick |
|
| Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick |
|
||||||
| Input | Mouse action events | Godot `_unhandled_input` dispatch | Current scene controller | Engine-owned event | Current input dispatch only |
|
| Input | Mouse action events | Godot `_unhandled_input` dispatch | Current scene controller | Engine-owned event | Current input dispatch only |
|
||||||
| Output | `MoveIntent` | `PlayerInputSource` | Current controller; later local movement controller | Immutable caller-held value | One physics tick |
|
| Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick |
|
||||||
| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime |
|
| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime |
|
||||||
|
|
||||||
Side effects:
|
Side effects:
|
||||||
@@ -82,15 +82,16 @@ flowchart LR
|
|||||||
Remap[User or editor remapping] --> Map
|
Remap[User or editor remapping] --> Map
|
||||||
Map -->|movement strengths| Source[PlayerInputSource]
|
Map -->|movement strengths| Source[PlayerInputSource]
|
||||||
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
||||||
Intent --> Consumer[ThirdPersonWowController]
|
Intent --> Consumer[LocalPlayerMovementController]
|
||||||
|
Consumer --> Adapter[ThirdPersonWowController]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lifecycle/state
|
## Lifecycle/state
|
||||||
|
|
||||||
The adapter is stateless. The scene composition root creates one
|
The adapter is stateless. The scene composition root creates one
|
||||||
`PlayerInputSource` in `_ready`; each physics tick produces a new immutable
|
`PlayerInputSource` and `LocalPlayerMovementController` in `_ready`; each physics
|
||||||
intent. Flight enabled/disabled state remains owned by the current sandbox
|
tick produces a new immutable intent. Flight enabled/disabled state is owned by
|
||||||
controller and is not hidden in the input adapter.
|
the movement controller and is not hidden in the input adapter.
|
||||||
|
|
||||||
## Main sequence
|
## Main sequence
|
||||||
|
|
||||||
@@ -99,11 +100,13 @@ sequenceDiagram
|
|||||||
participant Engine as Godot Input
|
participant Engine as Godot Input
|
||||||
participant Source as PlayerInputSource
|
participant Source as PlayerInputSource
|
||||||
participant Intent as MoveIntent
|
participant Intent as MoveIntent
|
||||||
participant Player as ThirdPersonWowController
|
participant Move as LocalPlayerMovementController
|
||||||
|
participant Player as ThirdPersonWowController adapter
|
||||||
Engine->>Source: movement action strengths
|
Engine->>Source: movement action strengths
|
||||||
Source->>Intent: compose normalized axes and requests
|
Source->>Intent: compose normalized axes and requests
|
||||||
Source-->>Player: sample_move_intent()
|
Source-->>Player: sample_move_intent()
|
||||||
Player->>Player: update current sandbox movement state
|
Player->>Move: advance(intent, selected basis, delta)
|
||||||
|
Move-->>Player: displacement and velocity state
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ownership, threading and resources
|
## Ownership, threading and resources
|
||||||
@@ -171,15 +174,15 @@ provide a settings migration once user settings are persisted.
|
|||||||
movement consumers.
|
movement consumers.
|
||||||
- A later input-context owner may suppress gameplay actions while preserving the
|
- A later input-context owner may suppress gameplay actions while preserving the
|
||||||
same intent contract.
|
same intent contract.
|
||||||
- The local movement controller may replace the current scene consumer without
|
- A future server-aware movement predictor may replace the current local
|
||||||
changing `PlayerInputSource`.
|
movement consumer without changing `PlayerInputSource`.
|
||||||
|
|
||||||
## Capability status
|
## Capability status
|
||||||
|
|
||||||
| Capability | Status | Evidence | Gap/next step |
|
| Capability | Status | Evidence | Gap/next step |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
|
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
|
||||||
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Extract local movement controller |
|
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Local movement consumer is now extracted |
|
||||||
| Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy |
|
| 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 |
|
| 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 |
|
| Jump/fall/swim/fly gameplay semantics | Planned | M02 target and gameplay roadmap | Implement after terrain/movement state extraction |
|
||||||
@@ -200,7 +203,8 @@ provide a settings migration once user settings are persisted.
|
|||||||
| `src/domain/input/move_intent.gd` | Immutable locomotion request contract |
|
| `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_actions.gd` | Stable project action names |
|
||||||
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
|
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
|
||||||
| `src/scenes/player/third_person_wow_controller.gd` | Current consumer and camera-event adapter |
|
| `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
|
||||||
|
| `src/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/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture |
|
||||||
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -9,6 +9,7 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
|
|||||||
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 PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
|
||||||
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
||||||
|
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
|
||||||
|
|
||||||
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||||
@@ -54,14 +55,21 @@ 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 _flight_enabled := false
|
|
||||||
var _adt_cache: Dictionary = {}
|
var _adt_cache: Dictionary = {}
|
||||||
var _player_input_source: PlayerInputSource
|
var _player_input_source: PlayerInputSource
|
||||||
|
var _local_movement_controller: LocalPlayerMovementController
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
|
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
|
||||||
|
_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
|
||||||
@@ -99,8 +107,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
_captured = false
|
_captured = false
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
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():
|
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||||
_flight_enabled = not _flight_enabled
|
_local_movement_controller.toggle_sandbox_flight()
|
||||||
_horizontal_velocity = Vector3.ZERO
|
|
||||||
|
|
||||||
if _captured and event is InputEventMouseMotion:
|
if _captured and event is InputEventMouseMotion:
|
||||||
_yaw -= event.relative.x * mouse_sensitivity
|
_yaw -= event.relative.x * mouse_sensitivity
|
||||||
@@ -111,54 +118,26 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
var move_intent := _player_input_source.sample_move_intent()
|
var move_intent := _player_input_source.sample_move_intent()
|
||||||
var target_velocity := Vector3.ZERO
|
var godot_world_movement_basis := global_basis
|
||||||
if move_intent.has_translation():
|
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
||||||
target_velocity = _movement_vector(move_intent)
|
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 += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent)
|
|
||||||
|
|
||||||
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
|
if not _local_movement_controller.is_flight_enabled:
|
||||||
global_position += _horizontal_velocity * delta
|
|
||||||
|
|
||||||
if not _flight_enabled:
|
|
||||||
var ground := _sample_ground_height(global_position)
|
var ground := _sample_ground_height(global_position)
|
||||||
if is_finite(ground):
|
if is_finite(ground):
|
||||||
var target_y := ground + ground_offset
|
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 _movement_vector(move_intent: MoveIntent) -> Vector3:
|
|
||||||
var forward := -global_basis.z
|
|
||||||
var right := global_basis.x
|
|
||||||
if _flight_enabled and _camera_pivot:
|
|
||||||
forward = -_camera_pivot.global_basis.z
|
|
||||||
right = _camera_pivot.global_basis.x
|
|
||||||
else:
|
|
||||||
forward.y = 0.0
|
|
||||||
right.y = 0.0
|
|
||||||
forward = forward.normalized()
|
|
||||||
right = right.normalized()
|
|
||||||
|
|
||||||
var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed
|
|
||||||
var speed_x := strafe_speed
|
|
||||||
return (
|
|
||||||
forward * move_intent.forward_axis * speed_z
|
|
||||||
+ right * move_intent.strafe_axis * speed_x
|
|
||||||
) * _debug_speed_multiplier(move_intent)
|
|
||||||
|
|
||||||
|
|
||||||
func _debug_speed_multiplier(move_intent: MoveIntent) -> float:
|
|
||||||
return sprint_multiplier if move_intent.is_sprint_requested else 1.0
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -113,7 +113,8 @@ func _verify_regression_scene(failures: Array[String]) -> void:
|
|||||||
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
|
||||||
flight_toggle_event.pressed = true
|
flight_toggle_event.pressed = true
|
||||||
player.call("_unhandled_input", flight_toggle_event)
|
player.call("_unhandled_input", flight_toggle_event)
|
||||||
_expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures)
|
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
|
||||||
|
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
|
||||||
player.free()
|
player.free()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user