diff --git a/coordination/claims/M02-GMP-INPUT-001.md b/coordination/claims/M02-GMP-INPUT-001.md new file mode 100644 index 0000000..16e6686 --- /dev/null +++ b/coordination/claims/M02-GMP-INPUT-001.md @@ -0,0 +1,78 @@ +# M02-GMP-INPUT-001 — Player input intent seam + + + +## Ownership + +- Target: M02 +- Program: GMP/FND +- Owner/Agent ID: sindo-main-codex +- Branch: `work/sindo-main-codex/m02-player-input` +- Lease expires UTC: 2026-07-16 +- Integrator: M02 milestone integrator + +## Outcome + +Replace direct keyboard polling in the runtime player with remappable Input Map +actions and an immutable `PlayerInputSource → MoveIntent` boundary while +preserving the current render-sandbox movement behavior. + +## Non-goals + +- Extract local movement, terrain query, camera rig or character presenters. +- Claim exact build-12340 movement/keybinding parity before original-client fixtures. +- Change movement speeds, camera geometry, terrain sampling or renderer behavior. +- Mark M02 complete or edit its checklist/Evidence. + +## Paths + +- Exclusive: `src/domain/input/`, `src/gameplay/input/`, + `src/tests/scenes/player_input_regression.tscn`, + `src/tools/verify_player_input.gd`, `docs/modules/player-input.md`, this claim +- Shared/hotspots: `project.godot`, + `src/scenes/player/third_person_wow_controller.gd`, `docs/modules/README.md` +- Generated/ignored: Godot import metadata, `.uid` files and local renderer corpus + +## Contracts and data + +- Public API: immutable `MoveIntent`; `PlayerInputSource.sample_move_intent` +- Configuration: additive `openwc_player_*` Input Map actions +- Persistence/schema/cache: unchanged +- Consumers: current sandbox player controller; later local movement controller + +## Dependencies + +- Requires: merged M01 coordinate/streaming-focus contracts on master `62eece9` +- Blocks: later M02 movement-controller extraction +- External state: none; tests use synthetic Input Map events + +## Verification + +- Commands: dedicated player-input verifier, headless project load, coordinate + boundary gate, coordination/documentation gates and diff hygiene +- Fixtures: synthetic action strengths and configured default bindings +- Fidelity evidence: current W/S/A/D, Shift, E/Q and Space sandbox behavior remains + mapped through actions; exact 3.3.5a semantics remain unverified +- Performance budget: one immutable intent allocation per physics tick; no I/O + +## Documentation deliverables + +- Inline API documentation for `MoveIntent`, `PlayerInputActions` and + `PlayerInputSource` +- Player-input module specification with inputs/outputs, data-flow and sequence diagrams +- Module registry/source map and honest capability status + +## Simplicity and naming + +- Important names: `MoveIntent`, `PlayerInputSource`, `PlayerInputActions` +- Simplest approach: immutable value, one input adapter and one controller consumer +- Rejected complexity: input provider hierarchy, event bus and generic command framework +- Unavoidable complexity: none +- Measured optimization evidence: not applicable + +## Status + +- State: active +- Done: claim and isolated worktree +- Next: implement contract, adapter, integration, tests and documentation +- Blocked by: diff --git a/docs/modules/README.md b/docs/modules/README.md index ae72be8..265d95a 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -9,6 +9,7 @@ | Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) | | Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) | | Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) | +| Player input | Partial | [`player-input.md`](player-input.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/player-input.md b/docs/modules/player-input.md new file mode 100644 index 0000000..d72fce4 --- /dev/null +++ b/docs/modules/player-input.md @@ -0,0 +1,211 @@ +# Player Input + +## Metadata + +| Field | Value | +|---|---| +| Status | Partial | +| Target/work package | M02 / M02-GMP-INPUT-001 | +| Owners | Gameplay input boundary; `sindo-main-codex` for current package | +| Last verified | `working tree`, 2026-07-14 | +| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified | + +## Purpose + +Convert remappable Godot Input Map actions into an immutable, scene-free +`MoveIntent` that can be consumed by the current player controller and a later +independently testable local movement controller. + +## Non-goals + +- Define authoritative server movement, prediction or reconciliation. +- Own character transform, terrain queries, camera state or animation state. +- Claim that the current W/S/A/D sandbox mapping exactly matches build 12340. +- Make sprint or free flight available to the production compatibility profile; + their profile gate remains a later M02 package. + +## Context and boundaries + +```mermaid +flowchart LR + Devices[Keyboard and mouse] --> InputMap[Godot Input Map] + InputMap --> Source[PlayerInputSource] + Source --> Intent[MoveIntent] + Intent --> Controller[ThirdPersonWowController] + Controller --> Movement[Current sandbox movement] + Controller --> Camera[Current camera adapter] +``` + +Allowed dependencies: + +- `PlayerInputSource` may read Godot's process-wide `Input` adapter. +- The scene controller may consume `MoveIntent` and camera-specific action names. +- `MoveIntent` may contain scalar axes and request flags only. + +Forbidden dependencies: + +- `MoveIntent` must not read `Input`, inherit `Node`/`Resource`, or contain world coordinates. +- The input source must not mutate player transforms, camera nodes or animation state. +- Renderer, terrain parsers and network codecs must not read Input Map actions. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `MoveIntent` | Immutable value | Carries normalized forward, strafe and vertical axes plus the sandbox sprint request | Created and consumed on the main physics tick; caller retains reference | Constructor clamps axes; no I/O failure | +| `MoveIntent.has_translation()` | Query | Reports whether any translation axis is non-zero | Any thread while value remains immutable | None | +| `PlayerInputActions` | Constants | Provides stable action names shared by configuration and adapters | Process lifetime | Missing project actions are detected by verification | +| `PlayerInputSource.sample_move_intent()` | Adapter method | Samples configured actions for one physics tick | Main thread; returned intent is independent of adapter lifetime | Missing actions resolve to zero strength and are a configuration defect | +| `PlayerInputSource.compose_move_intent(...)` | Pure factory | Clamps strengths, cancels opposites and normalizes diagonal planar input | Any thread with scalar inputs | None | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick | +| Input | Mouse action events | Godot `_unhandled_input` dispatch | Current scene controller | Engine-owned event | Current input dispatch only | +| Output | `MoveIntent` | `PlayerInputSource` | Current controller; later local movement controller | Immutable caller-held value | One physics tick | +| Output | Camera action names | `PlayerInputActions` | Current scene controller | Static constants | Process lifetime | + +Side effects: + +- `PlayerInputSource` has no side effects beyond reading current engine input. +- The existing controller still mutates player/camera transforms and mouse mode; + those responsibilities are intentionally not moved by this package. +- No filesystem, cache, network, database or renderer-resource mutation occurs. + +## Data flow + +```mermaid +flowchart LR + Project[project.godot action defaults] --> Map[InputMap] + Remap[User or editor remapping] --> Map + Map -->|movement strengths| Source[PlayerInputSource] + Source -->|clamp cancel normalize| Intent[MoveIntent] + Intent --> Consumer[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. + +## Main sequence + +```mermaid +sequenceDiagram + participant Engine as Godot Input + participant Source as PlayerInputSource + participant Intent as MoveIntent + participant Player as ThirdPersonWowController + Engine->>Source: movement action strengths + Source->>Intent: compose normalized axes and requests + Source-->>Player: sample_move_intent() + Player->>Player: update current sandbox movement state +``` + +## Ownership, threading and resources + +- The controller owns the input-source reference for its scene lifetime. +- Each `MoveIntent` is immutable and retained only by its consumer. +- Sampling and controller mutation occur on the main physics thread. +- The module creates no Nodes, Resources, RIDs, workers or background jobs. +- Input Map definitions are project configuration; runtime remapping remains + engine-owned and does not mutate the domain value. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Required action missing | Headless contract verifier | Godot returns zero input for that action | `PLAYER_INPUT` failure naming action | Restore action or user mapping | +| Conflicting opposite actions | Pure composition | Strengths cancel deterministically | Covered by contract test | Release/remap one action | +| Strength outside range | Pure composition | Value clamps to `[0, 1]` before axis calculation | Covered by contract test | Input adapter may be corrected independently | +| Controller disabled/unloaded | Scene lifecycle | Sampling stops with physics processing | Existing scene lifecycle | Re-enable or recreate scene | + +There is no asynchronous operation to cancel and no persisted state to roll back. + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Forward/backward | Physical W/S | Current render sandbox | Yes, through Input Map | Produces signed forward axis | +| Strafe left/right | Physical A/D | Current render sandbox | Yes | Produces signed right axis | +| Camera rotate | Right mouse button | Current render sandbox | Yes | Captures/releases mouse for orbit | +| Camera zoom | Mouse wheel | Current render sandbox | Yes | Adjusts current camera distance | +| Debug sprint | Shift | Sandbox debug only by intent; profile gate pending | Yes | Requests existing speed multiplier | +| Debug flight up/down/toggle | E/Q/Space | Sandbox debug only by intent; profile gate pending | Yes | Requests existing free-flight controls | + +## Persistence, cache and migration + +The action names are additive project configuration. No saved binding schema, +cache or migration exists yet. Renaming an action is a contract change and must +provide a settings migration once user settings are persisted. + +## Diagnostics and observability + +- Logs: dedicated verifier emits `PLAYER_INPUT PASS` or one failure per invariant. +- Metrics: none; sampling cost is one small value allocation per physics tick. +- Debug views: none. +- Correlation IDs: not applicable to local synchronous input. + +## Verification + +- Unit/contract tests: `src/tools/verify_player_input.gd` validates all actions, + default bindings, axis cancellation/clamping/normalization and debug request flags. +- Integration/E2E: `src/tests/scenes/player_input_regression.tscn` is loaded by + the verifier, which drives forward, camera-zoom and immediate sandbox-flight + actions through the real scene controller and also rejects direct + physical-key polling. +- Fidelity evidence: current sandbox defaults retain W/S/A/D, E/Q, Shift, Space, + right mouse and wheel bindings. This is observable-regression coverage only. +- Performance budgets: no I/O, jobs or scene lookup per sample; one immutable + intent allocation per physics tick. +- Manual diagnostics: run the Eastern Kingdoms scene and remap one movement + action in Project Settings before checking movement. + +## Extension points + +- A gamepad/accessibility adapter may call `compose_move_intent` without changing + movement consumers. +- A later input-context owner may suppress gameplay actions while preserving the + same intent contract. +- The local movement controller may replace the current scene 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 | +| Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy | +| Production profile exclusion of sprint/flight | Partial | Debug actions are named explicitly | Add typed profile/capability gate in M02 | +| Jump/fall/swim/fly gameplay semantics | Planned | M02 target and gameplay roadmap | Implement after terrain/movement state extraction | + +## Known gaps and risks + +- The defaults preserve the pre-M02 sandbox behavior, not verified original-client semantics. +- Sprint and free flight are still executable by the sandbox controller until a + typed build-profile gate is introduced. +- Camera input is action-mapped but camera state/collision remains in the monolithic controller. +- There is no persisted user keybinding format or UI yet. + +## Source map + +| Path | Responsibility | +|---|---| +| `project.godot` | Additive default Input Map bindings | +| `src/domain/input/move_intent.gd` | Immutable locomotion request contract | +| `src/gameplay/input/player_input_actions.gd` | Stable project action names | +| `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition | +| `src/scenes/player/third_person_wow_controller.gd` | Current consumer and camera-event adapter | +| `src/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture | +| `src/tools/verify_player_input.gd` | Headless contract/integration regression | + +## Related decisions and references + +- ADR: none; this implements the existing architecture and M02 seam. +- Upstream/reference: `targets/02-player-decomposition.md`, + `targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`. diff --git a/project.godot b/project.godot index c41a0b3..6f00c0e 100644 --- a/project.godot +++ b/project.godot @@ -19,6 +19,69 @@ config/icon="res://icon.svg" enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg") +[input] + +openwc_player_move_forward={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) +] +} +openwc_player_move_backward={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] +} +openwc_player_strafe_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +] +} +openwc_player_strafe_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +] +} +openwc_player_debug_fly_up={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) +] +} +openwc_player_debug_fly_down={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null) +] +} +openwc_player_debug_sprint={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194325,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +openwc_player_debug_toggle_flight={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":32,"unicode":32,"location":0,"echo":false,"script":null) +] +} +openwc_player_camera_rotate={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +openwc_player_camera_zoom_in={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +openwc_player_camera_zoom_out={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +openwc_player_release_cursor={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + [physics] 3d/physics_engine="Jolt Physics" diff --git a/src/domain/input/move_intent.gd b/src/domain/input/move_intent.gd new file mode 100644 index 0000000..ad3660c --- /dev/null +++ b/src/domain/input/move_intent.gd @@ -0,0 +1,47 @@ +class_name MoveIntent +extends RefCounted + +## Immutable player locomotion request expressed in character-local axes. +## Axis values are normalized to `[-1, 1]`: forward/right/up are positive. +## The intent contains no world coordinates, engine input events or presentation state. + +var forward_axis: float: + get: + return _forward_axis + +var strafe_axis: float: + get: + return _strafe_axis + +var vertical_axis: float: + get: + return _vertical_axis + +var is_sprint_requested: bool: + get: + return _is_sprint_requested + +var _forward_axis: float +var _strafe_axis: float +var _vertical_axis: float +var _is_sprint_requested: bool + + +## Creates one frame's movement request without retaining mutable input state. +func _init( + forward_axis_value: float = 0.0, + strafe_axis_value: float = 0.0, + vertical_axis_value: float = 0.0, + sprint_requested: bool = false +) -> void: + _forward_axis = clampf(forward_axis_value, -1.0, 1.0) + _strafe_axis = clampf(strafe_axis_value, -1.0, 1.0) + _vertical_axis = clampf(vertical_axis_value, -1.0, 1.0) + _is_sprint_requested = sprint_requested + + +## Returns true when the intent requests planar or vertical translation. +func has_translation() -> bool: + return not is_zero_approx(_forward_axis) \ + or not is_zero_approx(_strafe_axis) \ + or not is_zero_approx(_vertical_axis) diff --git a/src/domain/input/move_intent.gd.uid b/src/domain/input/move_intent.gd.uid new file mode 100644 index 0000000..b014e8e --- /dev/null +++ b/src/domain/input/move_intent.gd.uid @@ -0,0 +1 @@ +uid://btk7brcavbeec diff --git a/src/gameplay/input/player_input_actions.gd b/src/gameplay/input/player_input_actions.gd new file mode 100644 index 0000000..3a9992a --- /dev/null +++ b/src/gameplay/input/player_input_actions.gd @@ -0,0 +1,34 @@ +class_name PlayerInputActions +extends RefCounted + +## Stable Input Map action names consumed by the runtime player input adapter. +## Defaults preserve the current render-sandbox controls; users may remap every +## action through Godot's Input Map without changing gameplay code. + +const MOVE_FORWARD := &"openwc_player_move_forward" +const MOVE_BACKWARD := &"openwc_player_move_backward" +const STRAFE_LEFT := &"openwc_player_strafe_left" +const STRAFE_RIGHT := &"openwc_player_strafe_right" +const DEBUG_FLY_UP := &"openwc_player_debug_fly_up" +const DEBUG_FLY_DOWN := &"openwc_player_debug_fly_down" +const DEBUG_SPRINT := &"openwc_player_debug_sprint" +const DEBUG_TOGGLE_FLIGHT := &"openwc_player_debug_toggle_flight" +const CAMERA_ROTATE := &"openwc_player_camera_rotate" +const CAMERA_ZOOM_IN := &"openwc_player_camera_zoom_in" +const CAMERA_ZOOM_OUT := &"openwc_player_camera_zoom_out" +const RELEASE_CURSOR := &"openwc_player_release_cursor" + +const REQUIRED_ACTIONS: Array[StringName] = [ + MOVE_FORWARD, + MOVE_BACKWARD, + STRAFE_LEFT, + STRAFE_RIGHT, + DEBUG_FLY_UP, + DEBUG_FLY_DOWN, + DEBUG_SPRINT, + DEBUG_TOGGLE_FLIGHT, + CAMERA_ROTATE, + CAMERA_ZOOM_IN, + CAMERA_ZOOM_OUT, + RELEASE_CURSOR, +] diff --git a/src/gameplay/input/player_input_actions.gd.uid b/src/gameplay/input/player_input_actions.gd.uid new file mode 100644 index 0000000..cdd5c07 --- /dev/null +++ b/src/gameplay/input/player_input_actions.gd.uid @@ -0,0 +1 @@ +uid://bbhtyxm428bkm diff --git a/src/gameplay/input/player_input_source.gd b/src/gameplay/input/player_input_source.gd new file mode 100644 index 0000000..9ceb1c0 --- /dev/null +++ b/src/gameplay/input/player_input_source.gd @@ -0,0 +1,45 @@ +class_name PlayerInputSource +extends RefCounted + +## Converts remappable Godot Input Map actions into immutable [MoveIntent] values. +## This adapter owns no movement state and may be replaced independently of the +## local movement controller. + + +## Samples the current Input singleton state for one physics tick. +func sample_move_intent() -> MoveIntent: + return compose_move_intent( + Input.get_action_strength(PlayerInputActions.MOVE_FORWARD), + Input.get_action_strength(PlayerInputActions.MOVE_BACKWARD), + Input.get_action_strength(PlayerInputActions.STRAFE_LEFT), + Input.get_action_strength(PlayerInputActions.STRAFE_RIGHT), + Input.get_action_strength(PlayerInputActions.DEBUG_FLY_UP), + Input.get_action_strength(PlayerInputActions.DEBUG_FLY_DOWN), + Input.is_action_pressed(PlayerInputActions.DEBUG_SPRINT) + ) + + +## Builds a normalized intent from action strengths for deterministic tests and +## alternative input adapters. Strengths outside `[0, 1]` are clamped. +static func compose_move_intent( + forward_strength: float, + backward_strength: float, + strafe_left_strength: float, + strafe_right_strength: float, + fly_up_strength: float, + fly_down_strength: float, + sprint_requested: bool +) -> MoveIntent: + var planar_axis := Vector2( + clampf(strafe_right_strength, 0.0, 1.0) - clampf(strafe_left_strength, 0.0, 1.0), + clampf(forward_strength, 0.0, 1.0) - clampf(backward_strength, 0.0, 1.0) + ) + if planar_axis.length_squared() > 1.0: + planar_axis = planar_axis.normalized() + var vertical_axis := clampf(fly_up_strength, 0.0, 1.0) - clampf(fly_down_strength, 0.0, 1.0) + return MoveIntent.new( + planar_axis.y, + planar_axis.x, + vertical_axis, + sprint_requested + ) diff --git a/src/gameplay/input/player_input_source.gd.uid b/src/gameplay/input/player_input_source.gd.uid new file mode 100644 index 0000000..384631b --- /dev/null +++ b/src/gameplay/input/player_input_source.gd.uid @@ -0,0 +1 @@ +uid://b050i2lnei8qq diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index c6b63bb..3576a61 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -7,6 +7,8 @@ const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordina const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd") const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd") +const 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 CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS const UNIT_SIZE := CHUNK_SIZE / 8.0 @@ -55,9 +57,11 @@ 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 func _ready() -> void: + _player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new() _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 @@ -79,19 +83,22 @@ func _ready() -> void: func _unhandled_input(event: InputEvent) -> void: - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: - _captured = event.pressed - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE - elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP: - camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance) - _apply_camera_transform() - elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN: - camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance) - _apply_camera_transform() - elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): + _captured = true + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): _captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE: + elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN): + camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance) + _apply_camera_transform() + elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT): + camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance) + _apply_camera_transform() + elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR): + _captured = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE + elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo(): _flight_enabled = not _flight_enabled _horizontal_velocity = Vector3.ZERO @@ -103,12 +110,12 @@ func _unhandled_input(event: InputEvent) -> void: func _physics_process(delta: float) -> void: - var input_dir := _get_input_dir() + var move_intent := _player_input_source.sample_move_intent() var target_velocity := Vector3.ZERO - if input_dir.length_squared() > 0.0: - target_velocity = _movement_vector(input_dir) + if move_intent.has_translation(): + target_velocity = _movement_vector(move_intent) if _flight_enabled: - target_velocity.y += _flight_vertical_velocity() + target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent) _horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta) global_position += _horizontal_velocity * delta @@ -128,20 +135,7 @@ func _physics_process(delta: float) -> void: _apply_camera_transform() -func _get_input_dir() -> Vector2: - var dir := Vector2.ZERO - if Input.is_key_pressed(KEY_W): - dir.y -= 1.0 - if Input.is_key_pressed(KEY_S): - dir.y += 1.0 - if Input.is_key_pressed(KEY_A): - dir.x -= 1.0 - if Input.is_key_pressed(KEY_D): - dir.x += 1.0 - return dir.normalized() - - -func _movement_vector(input_dir: Vector2) -> Vector3: +func _movement_vector(move_intent: MoveIntent) -> Vector3: var forward := -global_basis.z var right := global_basis.x if _flight_enabled and _camera_pivot: @@ -153,20 +147,16 @@ func _movement_vector(input_dir: Vector2) -> Vector3: forward = forward.normalized() right = right.normalized() - var speed_z := run_speed if input_dir.y < 0.0 else backward_speed + var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed var speed_x := strafe_speed - var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0 - return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint + return ( + forward * move_intent.forward_axis * speed_z + + right * move_intent.strafe_axis * speed_x + ) * _debug_speed_multiplier(move_intent) -func _flight_vertical_velocity() -> float: - var dir := 0.0 - if Input.is_key_pressed(KEY_E): - dir += 1.0 - if Input.is_key_pressed(KEY_Q): - dir -= 1.0 - var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0 - return dir * flight_vertical_speed * sprint +func _debug_speed_multiplier(move_intent: MoveIntent) -> float: + return sprint_multiplier if move_intent.is_sprint_requested else 1.0 func _load_character_visual() -> void: diff --git a/src/tests/scenes/player_input_regression.tscn b/src/tests/scenes/player_input_regression.tscn new file mode 100644 index 0000000..19474f1 --- /dev/null +++ b/src/tests/scenes/player_input_regression.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"] + +[node name="PlayerInputRegression" type="CharacterBody3D"] +script = ExtResource("1_player") +spawn_at_tile_center = false +character_model_path = "" + +[node name="Visual" type="Node3D" parent="."] + +[node name="CameraPivot" type="Node3D" parent="."] + +[node name="Camera3D" type="Camera3D" parent="CameraPivot"] diff --git a/src/tools/verify_player_input.gd b/src/tools/verify_player_input.gd new file mode 100644 index 0000000..7acee68 --- /dev/null +++ b/src/tools/verify_player_input.gd @@ -0,0 +1,161 @@ +extends SceneTree + +## Headless M02 contract regression for remappable actions and MoveIntent composition. + +const MOVE_INTENT_PATH := "res://src/domain/input/move_intent.gd" +const PLAYER_INPUT_SOURCE_PATH := "res://src/gameplay/input/player_input_source.gd" +const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd" +const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn" + + +func _initialize() -> void: + _run_verification.call_deferred() + + +func _run_verification() -> void: + var failures: Array[String] = [] + _verify_default_input_actions(failures) + _verify_move_intent_composition(failures) + _verify_controller_boundary(failures) + _verify_regression_scene(failures) + + if not failures.is_empty(): + for failure in failures: + push_error("PLAYER_INPUT: %s" % failure) + quit(1) + return + + print("PLAYER_INPUT PASS actions=%d intent_cases=6 controller=1 regression_scene=1" % PlayerInputActions.REQUIRED_ACTIONS.size()) + quit(0) + + +func _verify_default_input_actions(failures: Array[String]) -> void: + for action_name in PlayerInputActions.REQUIRED_ACTIONS: + _expect_true(InputMap.has_action(action_name), "Input Map contains %s" % action_name, failures) + + _expect_key_binding(PlayerInputActions.MOVE_FORWARD, KEY_W, true, failures) + _expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_S, true, failures) + _expect_key_binding(PlayerInputActions.STRAFE_LEFT, KEY_A, true, failures) + _expect_key_binding(PlayerInputActions.STRAFE_RIGHT, KEY_D, true, failures) + _expect_key_binding(PlayerInputActions.DEBUG_FLY_UP, KEY_E, true, failures) + _expect_key_binding(PlayerInputActions.DEBUG_FLY_DOWN, KEY_Q, true, failures) + _expect_key_binding(PlayerInputActions.DEBUG_SPRINT, KEY_SHIFT, false, failures) + _expect_key_binding(PlayerInputActions.DEBUG_TOGGLE_FLIGHT, KEY_SPACE, true, failures) + _expect_key_binding(PlayerInputActions.RELEASE_CURSOR, KEY_ESCAPE, false, failures) + _expect_mouse_binding(PlayerInputActions.CAMERA_ROTATE, MOUSE_BUTTON_RIGHT, failures) + _expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_IN, MOUSE_BUTTON_WHEEL_UP, failures) + _expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_OUT, MOUSE_BUTTON_WHEEL_DOWN, failures) + + +func _verify_move_intent_composition(failures: Array[String]) -> void: + var neutral := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false) + _expect_near(neutral.forward_axis, 0.0, "neutral forward", failures) + _expect_true(not neutral.has_translation(), "neutral has no translation", failures) + + var forward := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false) + _expect_near(forward.forward_axis, 1.0, "forward axis", failures) + + var canceled := PlayerInputSource.compose_move_intent(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, false) + _expect_true(not canceled.has_translation(), "opposite actions cancel", failures) + + var diagonal := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, false) + _expect_near(Vector2(diagonal.strafe_axis, diagonal.forward_axis).length(), 1.0, "diagonal normalized", failures) + + var vertical := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 1.5, -0.5, false) + _expect_near(vertical.vertical_axis, 1.0, "vertical strengths clamped", failures) + + var debug_requests := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true) + _expect_true(debug_requests.is_sprint_requested, "sprint request retained", failures) + + +func _verify_controller_boundary(failures: Array[String]) -> void: + var controller_source := _read_text(PLAYER_CONTROLLER_PATH, failures) + _expect_true(controller_source.contains("sample_move_intent()"), "controller samples PlayerInputSource", failures) + _expect_true(not controller_source.contains("Input.is_key_pressed"), "controller omits direct keyboard polling", failures) + _expect_true(not controller_source.contains("KEY_"), "controller omits hardcoded key constants", failures) + + var move_intent_source := _read_text(MOVE_INTENT_PATH, failures) + _expect_true(not move_intent_source.contains("extends Node"), "MoveIntent does not inherit Node", failures) + _expect_true(not move_intent_source.contains("extends Resource"), "MoveIntent does not inherit Resource", failures) + _expect_true(not move_intent_source.contains("Input."), "MoveIntent does not read engine input", failures) + + var input_source := _read_text(PLAYER_INPUT_SOURCE_PATH, failures) + _expect_true(input_source.contains("Input.get_action_strength"), "input source reads action strengths", failures) + _expect_true(not input_source.contains("is_key_pressed"), "input source omits physical-key polling", failures) + + +func _verify_regression_scene(failures: Array[String]) -> void: + var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene + _expect_true(packed_scene != null, "regression scene loads", failures) + if packed_scene == null: + return + + var player := packed_scene.instantiate() as CharacterBody3D + _expect_true(player != null, "regression player instantiates", failures) + if player == null: + return + root.add_child(player) + player.set_physics_process(false) + + Input.action_press(PlayerInputActions.MOVE_FORWARD) + player.call("_physics_process", 1.0) + Input.action_release(PlayerInputActions.MOVE_FORWARD) + _expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures) + + var camera_distance_before: float = player.get("camera_distance") + var zoom_event := InputEventMouseButton.new() + zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP + zoom_event.pressed = true + player.call("_unhandled_input", zoom_event) + _expect_near(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures) + + var flight_toggle_event := InputEventAction.new() + flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT + flight_toggle_event.pressed = true + player.call("_unhandled_input", flight_toggle_event) + _expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures) + player.free() + + +func _expect_key_binding( + action_name: StringName, + expected_keycode: Key, + uses_physical_keycode: bool, + failures: Array[String] +) -> void: + for input_event in InputMap.action_get_events(action_name): + if input_event is InputEventKey: + var key_event := input_event as InputEventKey + var configured_keycode := key_event.physical_keycode if uses_physical_keycode else key_event.keycode + if configured_keycode == expected_keycode: + return + failures.append("%s missing expected key binding %s" % [action_name, expected_keycode]) + + +func _expect_mouse_binding( + action_name: StringName, + expected_button: MouseButton, + failures: Array[String] +) -> void: + for input_event in InputMap.action_get_events(action_name): + if input_event is InputEventMouseButton and input_event.button_index == expected_button: + return + failures.append("%s missing expected mouse binding %s" % [action_name, expected_button]) + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void: + if absf(actual_value - expected_value) > 0.000001: + failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label)