gmp(M02): extract local player movement #22
@@ -0,0 +1,83 @@
|
||||
# M02-GMP-MOVEMENT-001 — Local movement state/controller
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-GMP-MOVEMENT-001:sindo-main-codex:2026-07-16 -->
|
||||
|
||||
## 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: active
|
||||
- Done: claim and isolated worktree from updated master
|
||||
- Next: implement controller, migrate scene consumer, test and document
|
||||
- Blocked by:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) |
|
||||
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
|
||||
| Player input | Partial | [`player-input.md`](player-input.md) |
|
||||
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
||||
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
|
||||
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
|
||||
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
|
||||
|
||||
@@ -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 | `working tree`, 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 |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| Last verified | `6bd2e84`, 2026-07-14 |
|
||||
| Last verified | `working tree`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
|
||||
|
||||
## Purpose
|
||||
@@ -31,8 +31,8 @@ flowchart LR
|
||||
Devices[Keyboard and mouse] --> InputMap[Godot Input Map]
|
||||
InputMap --> Source[PlayerInputSource]
|
||||
Source --> Intent[MoveIntent]
|
||||
Intent --> Controller[ThirdPersonWowController]
|
||||
Controller --> Movement[Current sandbox movement]
|
||||
Intent --> Movement[LocalPlayerMovementController]
|
||||
Movement --> Controller[ThirdPersonWowController 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 | 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 |
|
||||
|
||||
Side effects:
|
||||
@@ -82,15 +82,16 @@ flowchart LR
|
||||
Remap[User or editor remapping] --> Map
|
||||
Map -->|movement strengths| Source[PlayerInputSource]
|
||||
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
||||
Intent --> Consumer[ThirdPersonWowController]
|
||||
Intent --> Consumer[LocalPlayerMovementController]
|
||||
Consumer --> Adapter[ThirdPersonWowController]
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
|
||||
The adapter is stateless. The scene composition root creates one
|
||||
`PlayerInputSource` in `_ready`; each physics tick produces a new immutable
|
||||
intent. Flight enabled/disabled state remains owned by the current sandbox
|
||||
controller and is not hidden in the input adapter.
|
||||
`PlayerInputSource` and `LocalPlayerMovementController` in `_ready`; each physics
|
||||
tick produces a new immutable intent. Flight enabled/disabled state is owned by
|
||||
the movement controller and is not hidden in the input adapter.
|
||||
|
||||
## Main sequence
|
||||
|
||||
@@ -99,11 +100,13 @@ sequenceDiagram
|
||||
participant Engine as Godot Input
|
||||
participant Source as PlayerInputSource
|
||||
participant Intent as MoveIntent
|
||||
participant Player as ThirdPersonWowController
|
||||
participant Move as LocalPlayerMovementController
|
||||
participant Player as ThirdPersonWowController adapter
|
||||
Engine->>Source: movement action strengths
|
||||
Source->>Intent: compose normalized axes and requests
|
||||
Source-->>Player: sample_move_intent()
|
||||
Player->>Player: update current sandbox movement state
|
||||
Player->>Move: advance(intent, selected basis, delta)
|
||||
Move-->>Player: displacement and velocity state
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
@@ -171,15 +174,15 @@ provide a settings migration once user settings are persisted.
|
||||
movement consumers.
|
||||
- A later input-context owner may suppress gameplay actions while preserving the
|
||||
same intent contract.
|
||||
- The local movement controller may replace the current scene consumer without
|
||||
changing `PlayerInputSource`.
|
||||
- A future server-aware movement predictor may replace the current local
|
||||
movement consumer without changing `PlayerInputSource`.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later |
|
||||
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Extract local movement controller |
|
||||
| `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Local movement consumer is now extracted |
|
||||
| Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy |
|
||||
| Production profile exclusion of sprint/flight | Partial | Debug actions are named explicitly | Add typed profile/capability gate in M02 |
|
||||
| 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/gameplay/input/player_input_actions.gd` | Stable project action names |
|
||||
| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Current consumer and camera-event adapter |
|
||||
| `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
|
||||
| `src/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 |
|
||||
|
||||
|
||||
@@ -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 PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
|
||||
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
||||
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
|
||||
|
||||
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||
@@ -54,14 +55,21 @@ var _active_animation := ""
|
||||
var _captured := false
|
||||
var _yaw := 0.0
|
||||
var _pitch := deg_to_rad(-18.0)
|
||||
var _horizontal_velocity := Vector3.ZERO
|
||||
var _flight_enabled := false
|
||||
var _adt_cache: Dictionary = {}
|
||||
var _player_input_source: PlayerInputSource
|
||||
var _local_movement_controller: LocalPlayerMovementController
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_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 = get_node_or_null(camera_path) as Camera3D
|
||||
_visual = get_node_or_null(visual_path) as Node3D
|
||||
@@ -99,8 +107,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_captured = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
|
||||
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
||||
_flight_enabled = not _flight_enabled
|
||||
_horizontal_velocity = Vector3.ZERO
|
||||
_local_movement_controller.toggle_sandbox_flight()
|
||||
|
||||
if _captured and event is InputEventMouseMotion:
|
||||
_yaw -= event.relative.x * mouse_sensitivity
|
||||
@@ -111,54 +118,26 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
var move_intent := _player_input_source.sample_move_intent()
|
||||
var target_velocity := Vector3.ZERO
|
||||
if move_intent.has_translation():
|
||||
target_velocity = _movement_vector(move_intent)
|
||||
if _flight_enabled:
|
||||
target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent)
|
||||
var godot_world_movement_basis := global_basis
|
||||
if _local_movement_controller.is_flight_enabled and _camera_pivot:
|
||||
godot_world_movement_basis = _camera_pivot.global_basis
|
||||
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
||||
|
||||
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
|
||||
global_position += _horizontal_velocity * delta
|
||||
|
||||
if not _flight_enabled:
|
||||
if not _local_movement_controller.is_flight_enabled:
|
||||
var ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
var target_y := ground + ground_offset
|
||||
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:
|
||||
_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)
|
||||
|
||||
_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:
|
||||
if character_model_path.is_empty() or _visual == null:
|
||||
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.pressed = true
|
||||
player.call("_unhandled_input", flight_toggle_event)
|
||||
_expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures)
|
||||
var movement_controller: LocalPlayerMovementController = player.get("_local_movement_controller")
|
||||
_expect_true(movement_controller.is_flight_enabled, "flight action preserves immediate sandbox toggle", failures)
|
||||
player.free()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user