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:
2026-07-14 22:01:21 +03:00
9 changed files with 635 additions and 56 deletions
+117
View File
@@ -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.
+1
View File
@@ -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) |
+242
View File
@@ -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`.
+19 -15
View File
@@ -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 | `435e1c9`, 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
+137
View File
@@ -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)
+2 -1
View File
@@ -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()