diff --git a/coordination/claims/M02-GMP-MOVEMENT-001.md b/coordination/claims/M02-GMP-MOVEMENT-001.md new file mode 100644 index 0000000..be2b967 --- /dev/null +++ b/coordination/claims/M02-GMP-MOVEMENT-001.md @@ -0,0 +1,83 @@ +# M02-GMP-MOVEMENT-001 — Local movement state/controller + + + +## 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: + diff --git a/docs/modules/README.md b/docs/modules/README.md index 265d95a..c551e4d 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -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) | diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md new file mode 100644 index 0000000..e0590b9 --- /dev/null +++ b/docs/modules/local-player-movement.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`. diff --git a/docs/modules/player-input.md b/docs/modules/player-input.md index 1ebe510..e2e2c43 100644 --- a/docs/modules/player-input.md +++ b/docs/modules/player-input.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 | diff --git a/src/gameplay/movement/local_player_movement_controller.gd b/src/gameplay/movement/local_player_movement_controller.gd new file mode 100644 index 0000000..730ceef --- /dev/null +++ b/src/gameplay/movement/local_player_movement_controller.gd @@ -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 diff --git a/src/gameplay/movement/local_player_movement_controller.gd.uid b/src/gameplay/movement/local_player_movement_controller.gd.uid new file mode 100644 index 0000000..4685eed --- /dev/null +++ b/src/gameplay/movement/local_player_movement_controller.gd.uid @@ -0,0 +1 @@ +uid://dxlx5kloh7p5c diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index 3576a61..9100f4a 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -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 diff --git a/src/tools/verify_local_player_movement.gd b/src/tools/verify_local_player_movement.gd new file mode 100644 index 0000000..4d9ba3f --- /dev/null +++ b/src/tools/verify_local_player_movement.gd @@ -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) diff --git a/src/tools/verify_player_input.gd b/src/tools/verify_player_input.gd index 7acee68..f3098d5 100644 --- a/src/tools/verify_player_input.gd +++ b/src/tools/verify_player_input.gd @@ -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()