Merge pull request 'gmp(M02): add player input intent seam' (#21) from work/sindo-main-codex/m02-player-input into master
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# M02-GMP-INPUT-001 — Player input intent seam
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-GMP-INPUT-001:sindo-main-codex:2026-07-16 -->
|
||||
<!-- OPENWC_HANDOFF:READY:M02-GMP-INPUT-001:6bd2e84 -->
|
||||
|
||||
## 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: ready-for-review
|
||||
- Done: twelve remappable actions, immutable `MoveIntent`, stateless
|
||||
`PlayerInputSource`, controller migration, asset-free regression scene,
|
||||
dedicated verifier and module specification
|
||||
- Next: M02 integrator reviews and merges before local movement extraction
|
||||
- Blocked by:
|
||||
|
||||
## Handoff
|
||||
|
||||
- Commit: `6bd2e84`
|
||||
- Branch: `work/sindo-main-codex/m02-player-input`
|
||||
- Outcome: the runtime player no longer polls physical keyboard keys. Movement
|
||||
action strengths cross an immutable `MoveIntent` seam; camera and immediate
|
||||
sandbox-flight events use the same remappable action catalog.
|
||||
- Public contract: additive `MoveIntent`, `PlayerInputActions` and
|
||||
`PlayerInputSource`; no schema, cache, coordinate or renderer contract change.
|
||||
- Verification: `PLAYER_INPUT PASS actions=12 intent_cases=6 controller=1
|
||||
regression_scene=1`; asset-free scene smoke exited `0`; coordinate boundary
|
||||
(`88` files) and StreamingFocus gates passed; unified renderer dry-run passed
|
||||
all eight pre-capture gates and produced seven dry-run checkpoint results;
|
||||
coordination/documentation/diff gates passed.
|
||||
- Fidelity: W/S/A/D, Shift, E/Q, Space, right mouse, wheel and Escape remain the
|
||||
default sandbox bindings. Regression drives forward movement, camera zoom and
|
||||
immediate flight toggle through the real controller. This does not establish
|
||||
exact build-12340 default bindings, turn/strafe policy or gameplay movement parity.
|
||||
- Local verification inputs: the isolated worktree reused the existing ignored
|
||||
native DLL and generated ADT resource scripts. Proprietary extracted data and
|
||||
the character GLB were absent, so the renderer dry-run logged the expected
|
||||
missing-corpus diagnostics while all contract steps returned success.
|
||||
- Rebuild/migration: none. Action names are additive; persisted user-binding
|
||||
migration does not exist yet and is documented as a gap.
|
||||
- Remaining risks: production profile gating for sprint/free flight, exact
|
||||
3.3.5a bindings, local movement/terrain/camera/presentation extraction and
|
||||
broader movement/camera state tests remain M02 work.
|
||||
- Documentation: inline API comments plus `docs/modules/player-input.md` with
|
||||
inputs/outputs, data-flow and sequence diagrams, ownership, recovery,
|
||||
capability status, known gaps and source map.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 | `6bd2e84`, 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`.
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://btk7brcavbeec
|
||||
@@ -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,
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbhtyxm428bkm
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b050i2lnei8qq
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user