Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5d13f702 | |||
| 742c415885 | |||
| 8c71e46872 | |||
| ad820699bc | |||
| 97bb53f6cd | |||
| 68e6f60d90 |
@@ -0,0 +1,126 @@
|
||||
# M02-GMP-SANDBOX-CAPABILITIES-001 — Debug movement capability gate
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-GMP-SANDBOX-CAPABILITIES-001:sindo-main-codex:2026-07-16 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M02
|
||||
- Program: GMP/FND
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m02-sandbox-capabilities`
|
||||
- Lease expires UTC: 2026-07-16
|
||||
- Integrator: M02 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Introduce an immutable local-player movement capability contract and require it
|
||||
at the movement boundary so debug sprint and free flight execute only in the
|
||||
explicit `RenderSandbox` profile and are rejected by `Blizzlike335`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Claim that current movement values or `Blizzlike335` locomotion are 1:1.
|
||||
- Build a global application/profile framework before the application shell exists.
|
||||
- Remove debug Input Map actions or change their current sandbox bindings.
|
||||
- Implement jump, fall, swim, collision, server authority or reconciliation.
|
||||
- Mark M02 complete or edit its checklist/Evidence.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/gameplay/movement/player_movement_capabilities.gd`,
|
||||
`src/tools/verify_player_movement_capabilities.gd`, this claim
|
||||
- Shared/hotspots: `src/gameplay/movement/local_player_movement_controller.gd`,
|
||||
`src/scenes/player/third_person_wow_controller.gd`, movement/input verifiers,
|
||||
player regression scene, `docs/modules/local-player-movement.md`,
|
||||
`docs/modules/player-input.md`
|
||||
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- `PlayerMovementCapabilities` owns immutable profile ID plus explicit debug
|
||||
sprint/flight booleans and safe named factories.
|
||||
- `LocalPlayerMovementController` requires the capability value; missing input
|
||||
falls back to `Blizzlike335`, never sandbox.
|
||||
- Player scene maps its exported profile selection once at the composition boundary.
|
||||
- Input actions and `MoveIntent` remain unchanged; rejected debug requests have no effect.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: merged character presentation package on master `8c71e46`.
|
||||
- Blocks: final M02 integration audit and milestone closeout.
|
||||
- External state: none; verification is asset-free.
|
||||
|
||||
## Verification
|
||||
|
||||
- Pure profile factory/default and immutability regression.
|
||||
- Sprint accepted in RenderSandbox and ignored in Blizzlike335.
|
||||
- Flight toggle/vertical basis accepted in RenderSandbox and rejected in Blizzlike335.
|
||||
- Real regression scene verifies both profile compositions.
|
||||
- Existing input/movement/terrain/camera/presentation and renderer gates remain required.
|
||||
- Fidelity evidence: negative proof that debug capabilities cannot affect the
|
||||
compatibility profile; no positive movement parity claim.
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline public API and profile semantics on the capability/controller boundary.
|
||||
- Updated movement and input specs: API, inputs/outputs, data flow, lifecycle,
|
||||
failure/recovery, capability matrix and source map.
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important name: `PlayerMovementCapabilities`.
|
||||
- Simplest approach: one immutable value injected into the existing controller.
|
||||
- Rejected complexity: autoload singleton, generic feature-flag service and global profile registry.
|
||||
- Unavoidable complexity: one profile-to-capability mapping at scene composition.
|
||||
- Measured optimization evidence: not applicable.
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: added immutable capability contract, gated sprint/free-flight, covered
|
||||
pure and real-scene profiles and updated movement/input specifications
|
||||
- Next: milestone integrator reviews and merges `742c415`
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M02-GMP-SANDBOX-CAPABILITIES-001:742c415 -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Branch/commit: `work/sindo-main-codex/m02-sandbox-capabilities` at `742c415`.
|
||||
- Outcome: debug sprint and free flight now require explicit
|
||||
`PlayerMovementCapabilities.render_sandbox()`; `Blizzlike335`, unknown and
|
||||
null capability inputs reject both effects.
|
||||
- Public contract: immutable profile ID plus `allows_debug_sprint` and
|
||||
`allows_debug_free_flight`; named factories map configuration once at the
|
||||
composition boundary. The movement controller exposes its effective profile ID.
|
||||
- Runtime composition: `ThirdPersonWowController.movement_profile_id` defaults
|
||||
to `RenderSandbox`, preserving renderer-scene behavior. Selecting
|
||||
`Blizzlike335` keeps ordinary movement while ignoring Shift/Space/E/Q effects.
|
||||
- Verification: capability `profiles=3 sprint=2 flight=2 scenes=2 boundary=1`,
|
||||
movement `cases=12 state_transitions=2`, input `actions=12 intent_cases=6`,
|
||||
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
|
||||
`state_cases=12 policy_cases=3`, presentation
|
||||
`appearance_cases=9 animation_cases=10`, coordinate boundaries
|
||||
`files=101 consumers=5`, and StreamingFocus `runtime_scenes=2 capture_tools=3` passed.
|
||||
- Renderer: dry-run passed project/material/dedupe/cache/manifest/calibration/
|
||||
coordinate/server-spawn gates and planned all seven checkpoints; the combined
|
||||
run reported coordinate `files=103`. Missing proprietary GLB/DBC/ADT inputs
|
||||
produced the expected diagnostics only.
|
||||
- Repository gates: documentation `module_specs=8`, coordination
|
||||
`fallback_claims=30`; five unrelated expired M00 claim warnings remain.
|
||||
- Fidelity: this package provides negative evidence that sandbox-only debug
|
||||
movement cannot alter the `Blizzlike335` profile. It does not prove ordinary
|
||||
movement, bindings, flight or timing match build 12340.
|
||||
- Migration: direct `LocalPlayerMovementController.new(...)` callers that need
|
||||
debug behavior must now pass `render_sandbox()`; omitted/null capability is
|
||||
intentionally compatibility-safe. Existing repository scenes require no edit.
|
||||
- Cache/persistence: no schema, cache, binding or stored-profile migration.
|
||||
- Local ignored inputs: native extension DLL, UID cache and generated ADT
|
||||
resource scripts supported renderer verification and are not committed.
|
||||
- Risks: runtime renderer scenes still default to `RenderSandbox`; the future
|
||||
application shell must explicitly choose its profile. `Blizzlike335` currently
|
||||
guarantees only debug exclusion, not complete movement fidelity.
|
||||
- Recommended merge order: merge after presentation (already on master), then
|
||||
perform the final M02 integration/evidence audit before any target closeout.
|
||||
- Documentation: updated movement/input API, inputs/outputs, data flow, state,
|
||||
sequence, ownership, failure/recovery, capabilities, verification and source maps.
|
||||
@@ -0,0 +1,124 @@
|
||||
# M02-RND-CHARACTER-PRESENTATION-001 — Character presentation boundary
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-RND-CHARACTER-PRESENTATION-001:sindo-main-codex:2026-07-16 -->
|
||||
|
||||
## Ownership
|
||||
|
||||
- Target: M02
|
||||
- Program: RND/GMP
|
||||
- Owner/Agent ID: sindo-main-codex
|
||||
- Branch: `work/sindo-main-codex/m02-character-presentation`
|
||||
- Lease expires UTC: 2026-07-16
|
||||
- Integrator: M02 milestone integrator
|
||||
|
||||
## Outcome
|
||||
|
||||
Move character GLB composition, geoset/texture/outfit setup and locomotion
|
||||
animation selection from `ThirdPersonWowController` into independently
|
||||
replaceable `CharacterAppearancePresenter` and `CharacterAnimationPresenter`
|
||||
components without changing the current sandbox presentation.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Add new model, equipment, animation or build-12340 behavior.
|
||||
- Change character asset formats, DBC parsing, texture composition or geoset rules.
|
||||
- Add network-driven appearance/movement snapshots.
|
||||
- Change input, movement, terrain, camera or renderer streaming behavior.
|
||||
- Mark M02 complete or edit its checklist/Evidence.
|
||||
|
||||
## Paths
|
||||
|
||||
- Exclusive: `src/scenes/character/character_appearance_presenter.gd`,
|
||||
`src/scenes/character/character_animation_presenter.gd`,
|
||||
`src/tools/verify_character_presentation.gd`,
|
||||
`docs/modules/character-presentation.md`, this claim
|
||||
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`, runtime
|
||||
streaming scenes, player regression scene, `src/tools/verify_player_input.gd`,
|
||||
`docs/modules/local-player-movement.md`, `docs/modules/README.md`
|
||||
- Reused unchanged: geoset controller, texture compositor and outfit resolver
|
||||
- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus
|
||||
|
||||
## Contracts and data
|
||||
|
||||
- Appearance API loads/replaces one character root under the existing `Visual` node.
|
||||
- Animation API binds an `AnimationPlayer` from that root and accepts a typed
|
||||
moving/stationary presentation state.
|
||||
- Scene paths, model defaults, DBC/cache/coordinate contracts remain unchanged.
|
||||
- Consumers: player scene composition and renderer viewport only.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Requires: merged camera package on master `68e6f60`.
|
||||
- Blocks: final M02 sandbox-profile gate and target integration audit.
|
||||
- External state: asset-free tests use a synthetic mesh and AnimationPlayer.
|
||||
|
||||
## Verification
|
||||
|
||||
- Dedicated appearance composition, replacement, grounding and failure regression.
|
||||
- Dedicated animation discovery, fallback, looping, blend and state regression.
|
||||
- Player source-boundary and three-scene wiring checks.
|
||||
- Existing input/movement/terrain/camera regressions, coordinate gates and renderer dry-run.
|
||||
- Fidelity evidence: preserve current sandbox model path, scale, yaw, outfit,
|
||||
Stand/Idle and Run/Walk selection only; no original-client parity claim.
|
||||
|
||||
## Documentation deliverables
|
||||
|
||||
- Inline public API/export documentation for both presenters.
|
||||
- Module specification with inputs/outputs, data-flow, lifecycle/state and sequence diagrams.
|
||||
- Updated movement consumer/source map and module registry.
|
||||
|
||||
## Simplicity and naming
|
||||
|
||||
- Important names: `CharacterAppearancePresenter`, `CharacterAnimationPresenter`.
|
||||
- Simplest approach: two small scene components on the existing `Visual` subtree.
|
||||
- Rejected complexity: generic presenter framework, async asset scheduler and duplicated character systems.
|
||||
- Unavoidable complexity: scene-tree composition and AnimationPlayer discovery run on the main thread.
|
||||
- Measured optimization evidence: not applicable.
|
||||
|
||||
## Status
|
||||
|
||||
- State: ready-for-review
|
||||
- Done: extracted both presenters, migrated all runtime/regression scenes,
|
||||
added asset-free regressions and completed required module documentation
|
||||
- Next: milestone integrator reviews and merges `97bb53f`
|
||||
- Blocked by:
|
||||
|
||||
<!-- OPENWC_HANDOFF:READY:M02-RND-CHARACTER-PRESENTATION-001:97bb53f -->
|
||||
|
||||
## Handoff
|
||||
|
||||
- Branch/commit: `work/sindo-main-codex/m02-character-presentation` at `97bb53f`.
|
||||
- Outcome: `CharacterAppearancePresenter` owns model/geoset/compositor/outfit
|
||||
composition and `CharacterAnimationPresenter` owns AnimationPlayer discovery,
|
||||
loop preparation and moving/stationary playback. The player is now only the
|
||||
composition and horizontal-velocity adapter.
|
||||
- Changed composition: both runtime scenes and the player regression scene keep
|
||||
`ThirdPersonPlayer/Visual`; they add `Visual/AnimationPresenter`.
|
||||
- Public contracts: appearance load/read-root/ready signal and animation
|
||||
bind/present/read-state APIs documented in the module specification.
|
||||
- Verification: presentation `appearance_cases=9 animation_cases=10 scenes=3`,
|
||||
input `actions=12 intent_cases=6`, movement `cases=12 state_transitions=2`,
|
||||
terrain `contract=4 interpolation=1 cache=1 failures=2`, camera
|
||||
`state_cases=12 policy_cases=3`, coordinate boundaries `files=99 consumers=5`,
|
||||
and StreamingFocus `runtime_scenes=2 capture_tools=3` passed.
|
||||
- Renderer: dry-run passed project/material/dedupe/cache/manifest/calibration/
|
||||
coordinate/server-spawn gates and planned all seven checkpoints. Missing
|
||||
proprietary HumanMale GLB, DBC and ADT corpus produced expected diagnostics.
|
||||
- Repository gates: documentation `module_specs=8`, coordination
|
||||
`fallback_claims=29`; five unrelated expired M00 claim warnings remain.
|
||||
- Fidelity: current model path, scale `1`, yaw `90 degrees`, class `1`, blend
|
||||
`0.15 seconds`, starter outfit and Stand/Idle/Run/Walk selection are preserved.
|
||||
No build-12340 appearance or animation parity claim is made.
|
||||
- Migration: repository scenes are migrated. External/custom scenes must move
|
||||
model/class/scale/yaw exports from the player node to the scripted `Visual`
|
||||
node and add `Visual/AnimationPresenter`. No cache/database rebuild is needed.
|
||||
- Local ignored inputs: native extension DLL, UID cache and generated ADT
|
||||
resource scripts supported the dry-run and are not committed.
|
||||
- Risks: synchronous model load may hitch; first descendant AnimationPlayer wins;
|
||||
moving state is a sandbox threshold boolean; complete equipment/animation
|
||||
fidelity remains open.
|
||||
- Recommended merge order: merge this package after camera (already on master),
|
||||
then implement the sandbox profile gate and perform the final M02 integration audit.
|
||||
- Documentation: added character-presentation API, inputs/outputs, data-flow,
|
||||
lifecycle/state, sequence, ownership/failure and source-map sections; updated
|
||||
the module registry and movement presentation consumer map.
|
||||
@@ -13,6 +13,7 @@
|
||||
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
|
||||
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
|
||||
| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) |
|
||||
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.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,273 @@
|
||||
# Character Presentation
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented boundary / Partial fidelity |
|
||||
| Target/work package | M02 / M02-RND-CHARACTER-PRESENTATION-001 |
|
||||
| Owners | Character appearance composition and locomotion animation presentation |
|
||||
| Last verified | `97bb53f`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current render sandbox character experiment |
|
||||
|
||||
## Purpose
|
||||
|
||||
Compose the current sandbox character GLB, geosets, skin textures and starter
|
||||
outfit below the existing `Visual` node, then independently present its
|
||||
stationary or moving animation. Keep visual asset loading and AnimationPlayer
|
||||
state out of gameplay movement and the player scene composition root.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Define authoritative character identity, equipment or movement state.
|
||||
- Change the existing GLB, geoset, texture-compositor, DBC or outfit formats.
|
||||
- Implement full build-12340 animation selection, blending or equipment visuals.
|
||||
- Load world assets, query terrain, process input or control the camera.
|
||||
- Introduce asynchronous streaming, background scene mutation or persistence.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scene[ThirdPersonWowController composition] --> Appearance[CharacterAppearancePresenter]
|
||||
Model[Configured PackedScene] --> Appearance
|
||||
DBC[Extracted DBC/item textures] --> Appearance
|
||||
Appearance --> Root[CharacterModel root]
|
||||
Root --> Geosets[Existing geoset controller]
|
||||
Root --> Compositor[Existing texture compositor]
|
||||
Appearance -->|ready root| Scene
|
||||
Scene --> Animation[CharacterAnimationPresenter]
|
||||
Velocity[Movement read model] -->|moving boolean| Animation
|
||||
Animation --> Player[AnimationPlayer below root]
|
||||
```
|
||||
|
||||
Allowed dependencies:
|
||||
|
||||
- configured Godot `PackedScene` character asset;
|
||||
- existing `CharacterGeosetController`, `CharacterTextureCompositor` and
|
||||
`WowCharacterOutfitResolver` presentation components;
|
||||
- `AnimationPlayer` and animation resources below the composed character root;
|
||||
- main-thread player scene composition and movement velocity read model.
|
||||
|
||||
Forbidden dependencies:
|
||||
|
||||
- `MoveIntent`, input devices or movement integration;
|
||||
- `TerrainQuery`, ADT parsing, coordinate conversion or streaming decisions;
|
||||
- cameras, network packets, server databases or gameplay reducers;
|
||||
- direct production database writes or cache-format ownership.
|
||||
|
||||
## Public API
|
||||
|
||||
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|
||||
|---|---|---|---|---|
|
||||
| `CharacterAppearancePresenter.load_character_appearance(content_root_override)` | Command | Replaces the owned character root and schedules final grounding/outfit composition | Main thread; may be called again during scene lifetime | Returns `false` for empty/invalid model; warning for invalid resource |
|
||||
| `CharacterAppearancePresenter.character_root` | Read-only state | Current owned model root or null | Stable until next load | None |
|
||||
| `character_appearance_ready(character_root)` | Signal | Publishes a fully attached, grounded root | Deferred main-thread completion | Not emitted for invalid/empty model |
|
||||
| `CharacterAnimationPresenter.bind_character_root(character_root)` | Command | Discovers and prepares the first descendant AnimationPlayer | Main thread; binding lifetime | Returns `false` and clears state when absent |
|
||||
| `CharacterAnimationPresenter.present_locomotion(is_moving)` | Command | Selects current stationary/moving candidate and starts it once | Main/physics thread | Returns `false` when unavailable, missing or unchanged |
|
||||
| `active_animation_name` | Read-only state | Selected animation name | Stable until bind/state transition | Empty when unbound |
|
||||
| `has_animation_player` | Read-only state | Whether the presenter has a valid bound player | Stable until bind | None |
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | Model resource path, class, scale and yaw | Runtime scene composition | Appearance presenter | Exported configuration | Captured at load |
|
||||
| Input | Extracted content root | Player scene configuration | Appearance presenter | Copied string | Captured at load |
|
||||
| Input | Packed character scene | Godot resource loader | Appearance presenter | Shared resource; presenter owns instance | Scene lifetime |
|
||||
| Output | Composed `CharacterModel` root | Appearance presenter | Scene/animation presenter | Appearance presenter owns node | Until replacement |
|
||||
| Output | Ready signal | Appearance presenter | Player composition | Synchronous signal after deferred completion | One per successful load |
|
||||
| Input | Moving/stationary boolean | Player adapter from horizontal velocity | Animation presenter | Value copy | One physics tick |
|
||||
| Output | AnimationPlayer playback mutation | Animation presenter | Rendered character | Bound root owns player/resources | Until next transition |
|
||||
|
||||
Side effects:
|
||||
|
||||
- loads and instantiates the configured model on the main thread;
|
||||
- adds/removes one owned `CharacterModel` subtree;
|
||||
- composes the existing texture/outfit components and reads their extracted inputs;
|
||||
- changes model transform for scale, yaw and ground alignment;
|
||||
- configures locomotion loop modes and starts AnimationPlayer playback;
|
||||
- does not write files, caches, databases or network state.
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Config[Model/class/scale/yaw/content root] --> Load[Load PackedScene]
|
||||
Load --> Replace[Replace owned CharacterModel]
|
||||
Replace --> Attach[Attach model and compositor]
|
||||
Attach --> Ground[Fit mesh bounds to visual ground]
|
||||
DBC[Existing outfit resolver] --> Outfit[Apply starter outfit]
|
||||
Ground --> Ready[Emit ready root]
|
||||
Outfit --> Ready
|
||||
Ready --> Bind[Discover AnimationPlayer]
|
||||
Velocity[Horizontal movement velocity] --> State[Moving or stationary]
|
||||
State --> Select[Run/Walk or Stand/Idle selection]
|
||||
Bind --> Select
|
||||
Select --> Playback[Loop and play with blend]
|
||||
```
|
||||
|
||||
## Lifecycle/state
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Empty
|
||||
Empty --> Loading: valid load command
|
||||
Loading --> Ready: attach + deferred composition
|
||||
Loading --> Empty: invalid resource
|
||||
Ready --> Loading: replacement load
|
||||
Ready --> Empty: empty model command
|
||||
|
||||
state Animation {
|
||||
[*] --> Unbound
|
||||
Unbound --> Stationary: bind root with animations
|
||||
Stationary --> Moving: moving=true
|
||||
Moving --> Stationary: moving=false
|
||||
Stationary --> Unbound: bind missing player
|
||||
Moving --> Unbound: bind missing player
|
||||
}
|
||||
```
|
||||
|
||||
Appearance replacement removes the old root from the scene immediately and
|
||||
queues it for deletion. Deferred completion checks root identity, so a stale
|
||||
completion cannot publish a replaced model.
|
||||
|
||||
## Main sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Scene as ThirdPersonWowController
|
||||
participant Appearance as CharacterAppearancePresenter
|
||||
participant Existing as Geoset/Compositor/Outfit
|
||||
participant Animation as CharacterAnimationPresenter
|
||||
participant Player as AnimationPlayer
|
||||
Scene->>Appearance: load_character_appearance(content root)
|
||||
Appearance->>Appearance: load, instantiate and attach root
|
||||
Appearance->>Existing: compose textures and starter outfit
|
||||
Appearance-->>Scene: character_appearance_ready(root)
|
||||
Scene->>Animation: bind_character_root(root)
|
||||
Animation->>Player: prepare loops and play Stand/Idle
|
||||
loop Physics ticks
|
||||
Scene->>Animation: present_locomotion(is moving)
|
||||
Animation->>Player: play only on state/name change
|
||||
end
|
||||
```
|
||||
|
||||
## Ownership, threading and resources
|
||||
|
||||
- The runtime scene owns one appearance presenter on `Visual` and one animation
|
||||
presenter below it.
|
||||
- The appearance presenter exclusively owns the generated `CharacterModel`
|
||||
subtree; imported shared resources remain Godot-owned.
|
||||
- The animation presenter borrows an AnimationPlayer reference until rebind or
|
||||
subtree replacement; the player scene orders rebind through the ready signal.
|
||||
- Loading, node mutation, bounds calculation, outfit setup and playback occur
|
||||
on the main thread. There are no workers, locks, RIDs or cancellation tokens.
|
||||
- The player controller owns neither model resources nor animation state.
|
||||
|
||||
## Errors, cancellation and recovery
|
||||
|
||||
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||
|---|---|---|---|---|
|
||||
| Empty model path | Load guard | Clears old root and returns `false` | Contract regression | Configure a path and retry |
|
||||
| Invalid model resource | Failed PackedScene cast | Remains empty | Named warning with path | Correct/extract asset and retry |
|
||||
| Replaced deferred completion | Root identity check | Stale completion ignored | Replacement regression | No action required |
|
||||
| Missing DBC/outfit inputs | Existing resolver returns false/empty | Base model remains visible | Existing resolver diagnostics | Restore extracted inputs and reload |
|
||||
| Missing AnimationPlayer | Recursive discovery returns null | Presenter becomes unbound | Contract return/read state | Bind a root with animations |
|
||||
| Missing candidate animation | Candidate selection returns empty | Current playback unchanged | Verifier/source diagnostics | Extend verified candidate policy later |
|
||||
|
||||
There is no background load cancellation. Replacement is the recovery path and
|
||||
invalid model state is explicit through the return value and null read model.
|
||||
|
||||
## Configuration and capabilities
|
||||
|
||||
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|
||||
|---|---|---|---|---|
|
||||
| Character model | Human male sandbox GLB | Current sandbox | Before reload | Model scene input |
|
||||
| Character class ID | `1` | Current sandbox | Before reload | Starter outfit lookup |
|
||||
| Uniform scale | `1.0` | Current sandbox | Before reload | Character root scale |
|
||||
| Yaw offset | `90` degrees | Current sandbox | Before reload | Imported model correction |
|
||||
| Extracted content root | `res://data/extracted` | Local content | Per load override | DBC/item texture input |
|
||||
| Animation blend | `0.15` seconds | Current sandbox | Before bind/transition | Playback cross-fade/default blend |
|
||||
| Stationary candidates | `Stand`, then `Idle` | Current sandbox | Fixed | Exact then substring selection |
|
||||
| Moving candidates | `Run`, then `Walk` | Current sandbox | Fixed | Exact then substring selection |
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
Presenter state is transient. No saved settings, user profile, database schema
|
||||
or cache format changes. Existing scene paths remain
|
||||
`ThirdPersonPlayer/Visual` and add only `Visual/AnimationPresenter`; model and
|
||||
extracted-data paths retain their defaults. Moving the model exports from the
|
||||
player node to `Visual` is a scene-authoring ownership change, not persisted
|
||||
runtime data migration.
|
||||
|
||||
## Diagnostics and observability
|
||||
|
||||
- Logs: invalid model path warns with the responsible presenter and path.
|
||||
- Read models: current character root, animation binding and active name.
|
||||
- Tests: dedicated verifier reports appearance, animation, scene and player boundaries.
|
||||
- Metrics/correlation IDs: not applicable to this synchronous sandbox presenter.
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit/contract: synthetic model fixture verifies load, scale, yaw, grounding,
|
||||
compositor composition, ready signal, replacement and empty-model clearing.
|
||||
- Animation: synthetic libraries verify nested discovery, exact and substring
|
||||
candidate selection, loop policy, blend configuration and duplicate suppression.
|
||||
- Boundary: player source contains no model loading, outfit, bounds or
|
||||
AnimationPlayer logic; appearance and animation dependencies remain separate.
|
||||
- Integration: Eastern Kingdoms, Kalimdor and player regression scenes compose
|
||||
both presenters; existing input/movement/terrain/camera tests remain required.
|
||||
- Fidelity evidence: only preservation of the current sandbox defaults and
|
||||
selection rules. No build-12340 appearance or animation parity is claimed.
|
||||
- Performance: one synchronous model composition per load and constant-time
|
||||
locomotion calls except on state transition; asset streaming remains future work.
|
||||
|
||||
## Extension points
|
||||
|
||||
- Replace starter outfit inputs with a versioned appearance/equipment snapshot.
|
||||
- Replace boolean locomotion input with a typed presentation state after
|
||||
movement/network contracts define walk, run, jump, fall, swim, mount and emotes.
|
||||
- Add asynchronous/preloaded model providers behind the appearance command
|
||||
without moving resource loading back into gameplay.
|
||||
- Add verified animation IDs/variation policy after build-12340 fixtures exist.
|
||||
|
||||
## Capability status
|
||||
|
||||
| Capability | Status | Evidence | Gap/next step |
|
||||
|---|---|---|---|
|
||||
| Independent appearance composition | Implemented | Synthetic fixture and three scene wirings | Integrator target update |
|
||||
| Independent locomotion animation | Implemented | Exact/fallback/loop/blend regression | Add typed presentation state later |
|
||||
| Current starter outfit | Partial | Existing resolver reused unchanged | Needs extracted DBC fixture and client comparison |
|
||||
| Current skin/geoset composition | Partial | Existing components reused | Full equipment/customization fidelity incomplete |
|
||||
| Build-12340 animation semantics | Planned | No original-client fixture | Capture animation IDs/transitions/timing |
|
||||
| Runtime equipment/network updates | Planned | No snapshot contract | M08/M09/M12 work |
|
||||
|
||||
## Known gaps and risks
|
||||
|
||||
- Model loading remains synchronous and can hitch when not preloaded.
|
||||
- Exact appearance, equipment, animation choice, playback speed and blending are unverified.
|
||||
- The moving input is a sandbox threshold boolean, not authoritative locomotion state.
|
||||
- The first descendant AnimationPlayer wins; multi-player imported scenes need a
|
||||
more explicit binding contract if they appear.
|
||||
- Existing geoset/compositor/outfit implementations retain their own fidelity gaps.
|
||||
|
||||
## Source map
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/scenes/character/character_appearance_presenter.gd` | Model-root ownership, composition, ground fit and outfit setup |
|
||||
| `src/scenes/character/character_animation_presenter.gd` | AnimationPlayer binding and locomotion playback state |
|
||||
| `src/scenes/character/character_geoset_controller.gd` | Existing geoset visibility and outfit defaults |
|
||||
| `src/scenes/character/character_texture_compositor.gd` | Existing skin/equipment texture composition |
|
||||
| `src/scenes/character/wow_character_outfit_resolver.gd` | Existing DBC starter outfit lookup |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Scene composition and moving-state adapter |
|
||||
| `src/tests/scenes/character_presentation_model_fixture.tscn` | Asset-free model/grounding fixture |
|
||||
| `src/tools/verify_character_presentation.gd` | Presenter, scene and dependency regression |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
- ADR: none; current behavior and resource formats are preserved.
|
||||
- Upstream/reference: `targets/02-player-decomposition.md`,
|
||||
`targets/roadmap/02-rendering-and-graphics.md`,
|
||||
`targets/roadmap/04-gameplay-systems.md`, `RENDER.md`.
|
||||
@@ -5,10 +5,10 @@
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-RND-CAMERA-001 basis producer update |
|
||||
| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-GMP-SANDBOX-CAPABILITIES-001 and presentation consumers |
|
||||
| Owners | Gameplay local movement state; scene composition owns the instance |
|
||||
| Last verified | `8c1cf9b`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
|
||||
| Last verified | `742c415`, 2026-07-14 |
|
||||
| Profiles/capabilities | `RenderSandbox` debug movement; `Blizzlike335` rejects debug sprint/flight |
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -32,13 +32,15 @@ flowchart LR
|
||||
Input[MoveIntent] --> Movement[LocalPlayerMovementController]
|
||||
Basis[Explicit local movement Basis] --> Movement
|
||||
Delta[Physics delta seconds] --> Movement
|
||||
Profile[Scene profile ID] --> Capabilities[PlayerMovementCapabilities]
|
||||
Capabilities --> Movement
|
||||
Movement --> Displacement[Godot-world displacement]
|
||||
Movement --> Velocity[Read-only velocity state]
|
||||
Scene[ThirdPersonWowController] --> Basis
|
||||
Displacement --> Scene
|
||||
Scene --> Transform[Player world transform]
|
||||
Scene --> Terrain[TerrainQuery and ground snap adapter]
|
||||
Velocity --> Presentation[Visual facing and animation adapter]
|
||||
Velocity --> Presentation[Visual facing and CharacterAnimationPresenter adapter]
|
||||
```
|
||||
|
||||
Allowed dependencies:
|
||||
@@ -57,10 +59,14 @@ Forbidden dependencies:
|
||||
|
||||
| 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 |
|
||||
| `PlayerMovementCapabilities.render_sandbox()` | Factory | Creates the only debug sprint/free-flight capability set | Immutable caller value | None |
|
||||
| `PlayerMovementCapabilities.blizzlike_335()` | Factory | Creates the compatibility-safe set with debug movement disabled | Immutable caller value | Name is intent, not a parity claim |
|
||||
| `PlayerMovementCapabilities.for_profile_id(...)` | Factory | Maps composition configuration once; unknown IDs fail closed | Immutable caller value | Unknown maps to `Blizzlike335` |
|
||||
| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration, sprint multiplier and movement capabilities | Created by composition root; retained for player scene lifetime | Null capability defaults to `Blizzlike335` |
|
||||
| `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 |
|
||||
| `movement_profile_id` | Read-only state | Effective capability profile ID | Controller lifetime | None |
|
||||
| `toggle_sandbox_flight()` | Command | Toggles flight only when explicitly permitted and clears velocity | Owning physics/input thread | Rejected request returns `false` with state unchanged |
|
||||
| `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:
|
||||
@@ -79,11 +85,12 @@ Constructor units:
|
||||
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick |
|
||||
| Input | `PlayerMovementCapabilities` | Application/player scene composition | Movement controller | Immutable caller-held value | Controller lifetime |
|
||||
| Input | Godot-world `Basis` | Player or `ThirdPersonCameraRig` 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 | Velocity `Vector3` | Movement controller | Facing and `CharacterAnimationPresenter` scene 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:
|
||||
@@ -100,6 +107,9 @@ flowchart LR
|
||||
Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions]
|
||||
Directions --> Target
|
||||
Config[Speeds and sprint multiplier] --> Target
|
||||
Profile[RenderSandbox or Blizzlike335] --> Capability[Immutable debug capabilities]
|
||||
Capability -->|allow or reject sprint| Target
|
||||
Capability -->|allow or reject flight toggle| Current
|
||||
Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta]
|
||||
Target --> Integrate
|
||||
Integrate --> Current[Updated velocity]
|
||||
@@ -112,7 +122,8 @@ flowchart LR
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> GroundedSandbox
|
||||
GroundedSandbox --> FlyingSandbox: toggle / clear velocity
|
||||
GroundedSandbox --> FlyingSandbox: toggle [debug flight allowed] / clear velocity
|
||||
GroundedSandbox --> GroundedSandbox: toggle [debug flight rejected]
|
||||
FlyingSandbox --> GroundedSandbox: toggle / clear velocity
|
||||
GroundedSandbox --> GroundedSandbox: advance
|
||||
FlyingSandbox --> FlyingSandbox: advance
|
||||
@@ -128,8 +139,11 @@ sequenceDiagram
|
||||
participant Input as PlayerInputSource
|
||||
participant Scene as ThirdPersonWowController
|
||||
participant Move as LocalPlayerMovementController
|
||||
participant Present as Facing/Animation adapter
|
||||
participant Cap as PlayerMovementCapabilities
|
||||
participant Present as CharacterAnimationPresenter adapter
|
||||
Input-->>Scene: MoveIntent
|
||||
Scene->>Cap: map configured profile once
|
||||
Scene->>Move: construct with immutable capabilities
|
||||
Scene->>Move: advance(intent, selected basis, delta)
|
||||
Move-->>Scene: displacement
|
||||
Scene->>Scene: apply global position and ground snap
|
||||
@@ -140,6 +154,8 @@ sequenceDiagram
|
||||
## Ownership, threading and resources
|
||||
|
||||
- The player scene creates and exclusively owns one movement controller.
|
||||
- The composition root creates one immutable capability value; the controller
|
||||
retains it and exposes only its effective profile ID.
|
||||
- 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.
|
||||
@@ -153,6 +169,8 @@ sequenceDiagram
|
||||
| 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 |
|
||||
| Flight/sprint request in `Blizzlike335` | Capability check | Request has no effect | Profile regression | Select explicit `RenderSandbox` only for tooling/debug scenes |
|
||||
| Unknown/null profile capability | Factory/constructor safe default | Uses `Blizzlike335`; debug movement disabled | Capability regression | Correct composition profile ID |
|
||||
| Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend |
|
||||
|
||||
There is no asynchronous cancellation, retry or rollback. Recreating the
|
||||
@@ -169,6 +187,9 @@ controller resets velocity and flight state deterministically.
|
||||
| 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 |
|
||||
| Debug sprint permission | Enabled | `RenderSandbox` only | Immutable | Allows sprint request multiplier |
|
||||
| Debug free-flight permission | Enabled | `RenderSandbox` only | Immutable | Allows toggle and vertical movement |
|
||||
| Compatibility-safe debug permissions | Disabled | `Blizzlike335` | Immutable | Ignores sprint/toggle requests |
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
@@ -188,6 +209,8 @@ separate versioned movement snapshot contract.
|
||||
- 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.
|
||||
- Capability: `verify_player_movement_capabilities.gd` covers three profile-ID
|
||||
cases, safe defaults, sprint/flight allow/reject behavior and two real scene compositions.
|
||||
- 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
|
||||
@@ -202,7 +225,8 @@ separate versioned movement snapshot contract.
|
||||
- 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.
|
||||
- A future application profile can map to this narrow capability value without
|
||||
coupling the movement controller to the application shell.
|
||||
|
||||
## Capability status
|
||||
|
||||
@@ -210,7 +234,8 @@ separate versioned movement snapshot contract.
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| Existing camera-relative free flight | Implemented in `RenderSandbox` | Pitched-basis, profile and toggle/reset cases | Original-client flight semantics remain out of scope |
|
||||
| Typed sprint/free-flight exclusion | Implemented | Pure and real-scene profile regressions | Application shell must select profile explicitly later |
|
||||
| Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned |
|
||||
| Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later |
|
||||
| Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts |
|
||||
@@ -223,7 +248,10 @@ separate versioned movement snapshot contract.
|
||||
properties does not reconfigure an existing controller.
|
||||
- Ground snap remains in the player scene, but its ADT data access is now behind
|
||||
a replaceable `TerrainQuery`.
|
||||
- Sprint and free flight still lack a typed sandbox capability gate.
|
||||
- Runtime renderer scenes intentionally default to `RenderSandbox`; a future
|
||||
game-client composition must explicitly select `Blizzlike335`.
|
||||
- `Blizzlike335` currently means only debug movement exclusion and must not be
|
||||
interpreted as evidence that remaining movement semantics are 1:1.
|
||||
- The module uses Godot math value types; a future engine-independent gameplay
|
||||
predictor may require scalar/domain movement contracts.
|
||||
|
||||
@@ -232,10 +260,13 @@ separate versioned movement snapshot contract.
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
|
||||
| `src/gameplay/movement/player_movement_capabilities.gd` | Immutable profile-to-debug-capability contract and safe defaults |
|
||||
| `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary |
|
||||
| `src/scenes/player/third_person_camera_rig.gd` | Camera-relative sandbox flight basis producer |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
|
||||
| `src/scenes/character/character_animation_presenter.gd` | Locomotion presentation consumer behind the scene adapter |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain and presentation-state adapters |
|
||||
| `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression |
|
||||
| `src/tools/verify_player_movement_capabilities.gd` | Profile factory, allow/reject and real-scene regression |
|
||||
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
|
||||
| `src/tools/verify_player_input.gd` | Input-to-movement scene regression |
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Partial |
|
||||
| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 and M02-RND-CAMERA-001 consumer updates |
|
||||
| Target/work package | M02 / input, movement, camera and sandbox-capability consumer updates |
|
||||
| Owners | Gameplay input boundary; `sindo-main-codex` for current package |
|
||||
| Last verified | `8c1cf9b`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified |
|
||||
| Last verified | `742c415`, 2026-07-14 |
|
||||
| Profiles/capabilities | Debug requests sampled in all profiles; execution allowed only by `RenderSandbox` movement capabilities |
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -21,8 +21,8 @@ independently testable local movement controller.
|
||||
- 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.
|
||||
- Suppress debug action sampling by physical device or UI context; execution is
|
||||
rejected at the typed movement capability boundary.
|
||||
|
||||
## Context and boundaries
|
||||
|
||||
@@ -32,6 +32,7 @@ flowchart LR
|
||||
InputMap --> Source[PlayerInputSource]
|
||||
Source --> Intent[MoveIntent]
|
||||
Intent --> Movement[LocalPlayerMovementController]
|
||||
Profile[PlayerMovementCapabilities] --> Movement
|
||||
Movement --> Controller[ThirdPersonWowController adapter]
|
||||
Controller --> Camera[ThirdPersonCameraRig]
|
||||
```
|
||||
@@ -65,6 +66,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 | `ThirdPersonCameraRig` through player dispatch | Engine-owned event | Current input dispatch only |
|
||||
| Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick |
|
||||
| Output | Debug sprint request/vertical axes | `PlayerInputSource` | Capability-gated movement controller | Immutable intent fields | One physics tick |
|
||||
| Output | Camera action names | `PlayerInputActions` | `ThirdPersonCameraRig` | Static constants | Process lifetime |
|
||||
|
||||
Side effects:
|
||||
@@ -84,6 +86,7 @@ flowchart LR
|
||||
Map -->|camera events| Camera[ThirdPersonCameraRig]
|
||||
Source -->|clamp cancel normalize| Intent[MoveIntent]
|
||||
Intent --> Consumer[LocalPlayerMovementController]
|
||||
Profile[PlayerMovementCapabilities] --> Consumer
|
||||
Consumer --> Adapter[ThirdPersonWowController]
|
||||
```
|
||||
|
||||
@@ -92,7 +95,9 @@ flowchart LR
|
||||
The adapter is stateless. The scene composition root creates one
|
||||
`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.
|
||||
the movement controller and is not hidden in the input adapter. Debug requests
|
||||
remain data in the intent; the immutable movement capability value decides
|
||||
whether they execute.
|
||||
|
||||
## Main sequence
|
||||
|
||||
@@ -127,6 +132,7 @@ sequenceDiagram
|
||||
| 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 |
|
||||
| Debug request in `Blizzlike335` | Movement capability check | Request is sampled but has no movement effect | Capability regression | Use `RenderSandbox` only when debug movement is intended |
|
||||
|
||||
There is no asynchronous operation to cancel and no persisted state to roll back.
|
||||
|
||||
@@ -138,8 +144,8 @@ There is no asynchronous operation to cancel and no persisted state to roll back
|
||||
| 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 |
|
||||
| Debug sprint | Shift | Executable only in `RenderSandbox` | Yes | Requests multiplier; `Blizzlike335` ignores it |
|
||||
| Debug flight up/down/toggle | E/Q/Space | Executable only in `RenderSandbox` | Yes | Requests free flight; `Blizzlike335` remains grounded |
|
||||
|
||||
## Persistence, cache and migration
|
||||
|
||||
@@ -162,6 +168,8 @@ provide a settings migration once user settings are persisted.
|
||||
the verifier, which drives forward, camera-zoom and immediate sandbox-flight
|
||||
actions through the real scene controller and also rejects direct
|
||||
physical-key polling.
|
||||
- Profile integration: `verify_player_movement_capabilities.gd` drives sprint
|
||||
and flight actions through real `RenderSandbox` and `Blizzlike335` scene instances.
|
||||
- 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
|
||||
@@ -185,14 +193,14 @@ provide a settings migration once user settings are persisted.
|
||||
| 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 | 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 |
|
||||
| Compatibility-profile exclusion of sprint/flight | Implemented | Typed capability and two real-scene regressions | Application shell selection remains future work |
|
||||
| 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.
|
||||
- Input Map still exposes debug bindings process-wide; the movement consumer now
|
||||
rejects their effects outside `RenderSandbox`.
|
||||
- Camera state is extracted; active collision remains an explicit planned policy.
|
||||
- There is no persisted user keybinding format or UI yet.
|
||||
|
||||
@@ -205,10 +213,12 @@ provide a settings migration once user settings are persisted.
|
||||
| `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/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer |
|
||||
| `src/gameplay/movement/player_movement_capabilities.gd` | Execution gate for sampled debug requests |
|
||||
| `src/scenes/player/third_person_camera_rig.gd` | Camera-action consumer and orbit/zoom state |
|
||||
| `src/scenes/player/third_person_wow_controller.gd` | Composition and input-event dispatcher |
|
||||
| `src/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture |
|
||||
| `src/tools/verify_player_input.gd` | Headless contract/integration regression |
|
||||
| `src/tools/verify_player_movement_capabilities.gd` | Sandbox versus compatibility-profile integration regression |
|
||||
|
||||
## Related decisions and references
|
||||
|
||||
|
||||
@@ -13,25 +13,32 @@ var is_flight_enabled: bool:
|
||||
get:
|
||||
return _is_flight_enabled
|
||||
|
||||
var movement_profile_id: StringName:
|
||||
get:
|
||||
return _movement_capabilities.profile_id
|
||||
|
||||
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 _movement_capabilities: PlayerMovementCapabilities
|
||||
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.
|
||||
## Creates a movement state with explicit Godot-unit speeds, acceleration and capabilities.
|
||||
## The current compatibility renderer treats one Godot unit as one WoW yard.
|
||||
## A null capability input fails closed to Blizzlike335 debug permissions.
|
||||
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
|
||||
sandbox_sprint_multiplier: float,
|
||||
movement_capabilities: PlayerMovementCapabilities = null
|
||||
) -> void:
|
||||
_run_speed_units_per_second = run_speed_units_per_second
|
||||
_backward_speed_units_per_second = backward_speed_units_per_second
|
||||
@@ -39,11 +46,18 @@ func _init(
|
||||
_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
|
||||
_movement_capabilities = (
|
||||
movement_capabilities
|
||||
if movement_capabilities != null
|
||||
else PlayerMovementCapabilities.blizzlike_335()
|
||||
)
|
||||
|
||||
|
||||
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
|
||||
## Returns the new flight state.
|
||||
## Toggles sandbox free flight and clears velocity when the profile permits it.
|
||||
## Returns the resulting state; rejected compatibility-profile requests return false.
|
||||
func toggle_sandbox_flight() -> bool:
|
||||
if not _movement_capabilities.allows_debug_free_flight:
|
||||
return false
|
||||
_is_flight_enabled = not _is_flight_enabled
|
||||
_godot_world_velocity_units_per_second = Vector3.ZERO
|
||||
return _is_flight_enabled
|
||||
@@ -83,7 +97,11 @@ func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_ba
|
||||
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 speed_multiplier := (
|
||||
_sandbox_sprint_multiplier
|
||||
if move_intent.is_sprint_requested and _movement_capabilities.allows_debug_sprint
|
||||
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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
class_name PlayerMovementCapabilities
|
||||
extends RefCounted
|
||||
|
||||
## Immutable local-player movement capability selection.
|
||||
## Unknown or missing profile identifiers collapse to the safe Blizzlike335
|
||||
## capability set; only RenderSandbox enables debug sprint and free flight.
|
||||
|
||||
const BLIZZLIKE_335_PROFILE_ID: StringName = &"Blizzlike335"
|
||||
const RENDER_SANDBOX_PROFILE_ID: StringName = &"RenderSandbox"
|
||||
|
||||
var profile_id: StringName:
|
||||
get:
|
||||
return _profile_id
|
||||
|
||||
var allows_debug_sprint: bool:
|
||||
get:
|
||||
return _allows_debug_sprint
|
||||
|
||||
var allows_debug_free_flight: bool:
|
||||
get:
|
||||
return _allows_debug_free_flight
|
||||
|
||||
var _profile_id: StringName
|
||||
var _allows_debug_sprint: bool
|
||||
var _allows_debug_free_flight: bool
|
||||
|
||||
|
||||
func _init(requested_profile_id: StringName = BLIZZLIKE_335_PROFILE_ID) -> void:
|
||||
if requested_profile_id == RENDER_SANDBOX_PROFILE_ID:
|
||||
_profile_id = RENDER_SANDBOX_PROFILE_ID
|
||||
_allows_debug_sprint = true
|
||||
_allows_debug_free_flight = true
|
||||
return
|
||||
_profile_id = BLIZZLIKE_335_PROFILE_ID
|
||||
_allows_debug_sprint = false
|
||||
_allows_debug_free_flight = false
|
||||
|
||||
|
||||
## Returns the debug-enabled capability set used by current renderer scenes.
|
||||
static func render_sandbox() -> PlayerMovementCapabilities:
|
||||
return PlayerMovementCapabilities.new(RENDER_SANDBOX_PROFILE_ID)
|
||||
|
||||
|
||||
## Returns the compatibility-safe capability set with debug movement disabled.
|
||||
## This name identifies the intended compatibility profile, not proven 1:1 movement.
|
||||
static func blizzlike_335() -> PlayerMovementCapabilities:
|
||||
return PlayerMovementCapabilities.new(BLIZZLIKE_335_PROFILE_ID)
|
||||
|
||||
|
||||
## Maps scene/application profile configuration to a known capability set.
|
||||
## Unknown identifiers fail closed to Blizzlike335.
|
||||
static func for_profile_id(requested_profile_id: StringName) -> PlayerMovementCapabilities:
|
||||
return PlayerMovementCapabilities.new(requested_profile_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdnulu22drbdw
|
||||
@@ -0,0 +1,95 @@
|
||||
class_name CharacterAnimationPresenter
|
||||
extends Node
|
||||
|
||||
## Selects and plays the current sandbox idle or moving animation.
|
||||
## Animation import, movement simulation and model composition remain external.
|
||||
|
||||
const MOVING_ANIMATION_CANDIDATES: Array[String] = ["Run", "Walk"]
|
||||
const STATIONARY_ANIMATION_CANDIDATES: Array[String] = ["Stand", "Idle"]
|
||||
|
||||
## Cross-fade time retained from the current sandbox controller.
|
||||
@export var animation_blend_time_seconds: float = 0.15
|
||||
|
||||
var active_animation_name: String:
|
||||
get:
|
||||
return _active_animation_name
|
||||
|
||||
var has_animation_player: bool:
|
||||
get:
|
||||
return _animation_player != null and is_instance_valid(_animation_player)
|
||||
|
||||
var _animation_player: AnimationPlayer
|
||||
var _active_animation_name := ""
|
||||
|
||||
|
||||
## Finds the first AnimationPlayer below a newly composed character root.
|
||||
## Binding null or a root without animations clears the current presentation.
|
||||
func bind_character_root(character_root: Node) -> bool:
|
||||
_animation_player = (
|
||||
_find_animation_player(character_root)
|
||||
if character_root != null and is_instance_valid(character_root)
|
||||
else null
|
||||
)
|
||||
_active_animation_name = ""
|
||||
if _animation_player == null:
|
||||
return false
|
||||
_prepare_animation_player(_animation_player)
|
||||
present_locomotion(false)
|
||||
return true
|
||||
|
||||
|
||||
## Presents the current moving/stationary locomotion state.
|
||||
## Returns true only when a different animation starts playing.
|
||||
func present_locomotion(is_moving: bool) -> bool:
|
||||
if _animation_player == null or not is_instance_valid(_animation_player):
|
||||
_animation_player = null
|
||||
_active_animation_name = ""
|
||||
return false
|
||||
var candidates := MOVING_ANIMATION_CANDIDATES if is_moving else STATIONARY_ANIMATION_CANDIDATES
|
||||
var selected_animation := _choose_animation(candidates)
|
||||
if selected_animation.is_empty() or selected_animation == _active_animation_name:
|
||||
return false
|
||||
_active_animation_name = selected_animation
|
||||
_animation_player.play(selected_animation, animation_blend_time_seconds)
|
||||
return true
|
||||
|
||||
|
||||
func _prepare_animation_player(animation_player: AnimationPlayer) -> void:
|
||||
animation_player.playback_default_blend_time = animation_blend_time_seconds
|
||||
for animation_name in animation_player.get_animation_list():
|
||||
var lowercase_name := String(animation_name).to_lower()
|
||||
if (
|
||||
lowercase_name == "stand"
|
||||
or lowercase_name == "idle"
|
||||
or lowercase_name == "run"
|
||||
or lowercase_name == "walk"
|
||||
or lowercase_name.contains("stand")
|
||||
or lowercase_name.contains("run")
|
||||
or lowercase_name.contains("walk")
|
||||
):
|
||||
var animation := animation_player.get_animation(animation_name)
|
||||
if animation != null:
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _choose_animation(candidates: Array[String]) -> String:
|
||||
for candidate in candidates:
|
||||
if _animation_player.has_animation(candidate):
|
||||
return candidate
|
||||
var animation_names := _animation_player.get_animation_list()
|
||||
for candidate in candidates:
|
||||
var lowercase_candidate := candidate.to_lower()
|
||||
for animation_name in animation_names:
|
||||
if String(animation_name).to_lower().contains(lowercase_candidate):
|
||||
return String(animation_name)
|
||||
return ""
|
||||
|
||||
|
||||
func _find_animation_player(root: Node) -> AnimationPlayer:
|
||||
if root is AnimationPlayer:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var animation_player := _find_animation_player(child)
|
||||
if animation_player != null:
|
||||
return animation_player
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2jnn0e3mmymc
|
||||
@@ -0,0 +1,150 @@
|
||||
class_name CharacterAppearancePresenter
|
||||
extends Node3D
|
||||
|
||||
## Composes one sandbox character model, texture compositor and starter outfit.
|
||||
## The presenter owns only nodes below the existing Visual scene boundary.
|
||||
|
||||
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
|
||||
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
|
||||
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
|
||||
|
||||
signal character_appearance_ready(character_root: Node3D)
|
||||
|
||||
## Packed character scene used by the current render sandbox.
|
||||
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
||||
## Character class used when resolving the current starter outfit.
|
||||
@export var character_class_id: int = 1
|
||||
## Uniform scale applied to the composed character root.
|
||||
@export var character_scale: float = 1.0
|
||||
## Model-space yaw correction retained from the current sandbox.
|
||||
@export var character_yaw_offset_degrees: float = 90.0
|
||||
## Root containing extracted DBC and item texture inputs.
|
||||
@export var extracted_data_directory: String = "res://data/extracted"
|
||||
|
||||
var character_root: Node3D:
|
||||
get:
|
||||
return _character_root
|
||||
|
||||
var _character_root: Node3D
|
||||
|
||||
|
||||
## Loads and composes the configured character model below this Visual node.
|
||||
## A non-empty directory argument keeps the player's terrain/content root aligned.
|
||||
## Returns false immediately when no model is configured or the resource is invalid.
|
||||
func load_character_appearance(content_root_override: String = "") -> bool:
|
||||
if not content_root_override.is_empty():
|
||||
extracted_data_directory = content_root_override
|
||||
_clear_character_root()
|
||||
if character_model_path.is_empty():
|
||||
return false
|
||||
var character_scene := load(character_model_path) as PackedScene
|
||||
if character_scene == null:
|
||||
push_warning("CharacterAppearancePresenter: cannot load character model: %s" % character_model_path)
|
||||
return false
|
||||
|
||||
if is_class("MeshInstance3D"):
|
||||
set("mesh", null)
|
||||
position = Vector3.ZERO
|
||||
|
||||
_character_root = Node3D.new()
|
||||
_character_root.name = "CharacterModel"
|
||||
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
|
||||
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
|
||||
_character_root.rotation_degrees.y = character_yaw_offset_degrees
|
||||
|
||||
var character_instance := character_scene.instantiate()
|
||||
character_instance.name = character_model_path.get_file().get_basename()
|
||||
_character_root.add_child(character_instance)
|
||||
|
||||
var texture_compositor := Node.new()
|
||||
texture_compositor.name = "CharacterTextureCompositor"
|
||||
texture_compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
|
||||
texture_compositor.set("textures_dir", _character_textures_directory(character_model_path))
|
||||
texture_compositor.set("extracted_dir", extracted_data_directory)
|
||||
_character_root.add_child(texture_compositor)
|
||||
add_child(_character_root)
|
||||
_complete_character_composition.call_deferred(_character_root, texture_compositor)
|
||||
return true
|
||||
|
||||
|
||||
func _complete_character_composition(expected_character_root: Node3D, texture_compositor: Node) -> void:
|
||||
if expected_character_root != _character_root or not is_instance_valid(expected_character_root):
|
||||
return
|
||||
_fit_character_to_ground(expected_character_root)
|
||||
_apply_character_starter_outfit(expected_character_root, texture_compositor)
|
||||
character_appearance_ready.emit(expected_character_root)
|
||||
|
||||
|
||||
func _clear_character_root() -> void:
|
||||
if _character_root == null or not is_instance_valid(_character_root):
|
||||
_character_root = null
|
||||
return
|
||||
remove_child(_character_root)
|
||||
_character_root.queue_free()
|
||||
_character_root = null
|
||||
|
||||
|
||||
func _fit_character_to_ground(root: Node3D) -> void:
|
||||
var character_bounds := _calculate_global_bounds(root)
|
||||
if character_bounds.size == Vector3.ZERO:
|
||||
return
|
||||
var local_minimum_y := character_bounds.position.y - root.global_position.y
|
||||
root.position.y -= local_minimum_y
|
||||
|
||||
|
||||
func _apply_character_starter_outfit(root: Node3D, texture_compositor: Node) -> void:
|
||||
if texture_compositor == null:
|
||||
return
|
||||
var outfit_resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
|
||||
if not outfit_resolver.call("load_dbcs", extracted_data_directory):
|
||||
return
|
||||
var inferred_identity: Dictionary = outfit_resolver.call(
|
||||
"infer_race_gender_from_model_path",
|
||||
character_model_path
|
||||
)
|
||||
var race_name := String(inferred_identity.get("race", "Human"))
|
||||
var gender_name := String(inferred_identity.get("gender", "Male"))
|
||||
var starter_outfit: Dictionary = outfit_resolver.call(
|
||||
"resolve_start_outfit",
|
||||
race_name,
|
||||
gender_name,
|
||||
character_class_id
|
||||
)
|
||||
if starter_outfit.is_empty():
|
||||
return
|
||||
if texture_compositor.has_method("set_equipment_components"):
|
||||
texture_compositor.call(
|
||||
"set_equipment_components",
|
||||
starter_outfit.get("textures", PackedStringArray())
|
||||
)
|
||||
if root.has_method("apply_outfit_defaults"):
|
||||
root.call("apply_outfit_defaults", starter_outfit)
|
||||
|
||||
|
||||
func _calculate_global_bounds(root: Node) -> AABB:
|
||||
var combined_bounds := AABB()
|
||||
var has_bounds := false
|
||||
for mesh_instance in _mesh_instances_below(root):
|
||||
if mesh_instance.mesh == null:
|
||||
continue
|
||||
var global_bounds := mesh_instance.global_transform * mesh_instance.mesh.get_aabb()
|
||||
combined_bounds = global_bounds if not has_bounds else combined_bounds.merge(global_bounds)
|
||||
has_bounds = true
|
||||
return combined_bounds if has_bounds else AABB()
|
||||
|
||||
|
||||
func _mesh_instances_below(root: Node) -> Array[MeshInstance3D]:
|
||||
var mesh_instances: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(root, mesh_instances)
|
||||
return mesh_instances
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, mesh_instances: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
mesh_instances.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(child, mesh_instances)
|
||||
|
||||
|
||||
func _character_textures_directory(model_path: String) -> String:
|
||||
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhqbo1ftrt3a3
|
||||
@@ -1,8 +1,5 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
|
||||
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
|
||||
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
|
||||
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
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")
|
||||
@@ -10,6 +7,7 @@ const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/ad
|
||||
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 PLAYER_MOVEMENT_CAPABILITIES_SCRIPT := preload("res://src/gameplay/movement/player_movement_capabilities.gd")
|
||||
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
|
||||
|
||||
@export var extracted_dir: String = "res://data/extracted"
|
||||
@@ -24,22 +22,19 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
|
||||
@export var sprint_multiplier: float = 6.0
|
||||
@export var flight_vertical_speed: float = 7.0
|
||||
@export var acceleration: float = 28.0
|
||||
## Local composition profile. RenderSandbox preserves debug sprint/free flight;
|
||||
## Blizzlike335 rejects them but does not yet claim complete movement parity.
|
||||
@export_enum("RenderSandbox", "Blizzlike335") var movement_profile_id: String = "RenderSandbox"
|
||||
@export var ground_offset: float = 0.05
|
||||
@export var ground_snap_speed: float = 24.0
|
||||
|
||||
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
|
||||
@export var visual_path: NodePath = NodePath("Visual")
|
||||
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
||||
@export var character_class_id: int = 1
|
||||
@export var character_scale: float = 1.0
|
||||
@export var character_yaw_offset_degrees: float = 90.0
|
||||
@export var animation_blend_time: float = 0.15
|
||||
@export var animation_presenter_path: NodePath = NodePath("Visual/AnimationPresenter")
|
||||
|
||||
var _camera_rig: ThirdPersonCameraRig
|
||||
var _visual: Node3D
|
||||
var _character_root: Node3D
|
||||
var _animation_player: AnimationPlayer
|
||||
var _active_animation := ""
|
||||
var _appearance_presenter: CharacterAppearancePresenter
|
||||
var _animation_presenter: CharacterAnimationPresenter
|
||||
var _player_input_source: PlayerInputSource
|
||||
var _local_movement_controller: LocalPlayerMovementController
|
||||
var _terrain_query: TerrainQuery
|
||||
@@ -64,21 +59,29 @@ func _ready() -> void:
|
||||
strafe_speed,
|
||||
flight_vertical_speed,
|
||||
acceleration,
|
||||
sprint_multiplier
|
||||
sprint_multiplier,
|
||||
PLAYER_MOVEMENT_CAPABILITIES_SCRIPT.for_profile_id(StringName(movement_profile_id))
|
||||
)
|
||||
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
|
||||
_visual = get_node_or_null(visual_path) as Node3D
|
||||
_appearance_presenter = get_node_or_null(visual_path) as CharacterAppearancePresenter
|
||||
_animation_presenter = get_node_or_null(animation_presenter_path) as CharacterAnimationPresenter
|
||||
if _camera_rig == null:
|
||||
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
|
||||
else:
|
||||
_camera_rig.initialize_for_character(self)
|
||||
if _appearance_presenter == null:
|
||||
push_error("ThirdPersonWowController: CharacterAppearancePresenter not found at %s" % visual_path)
|
||||
else:
|
||||
_appearance_presenter.character_appearance_ready.connect(_on_character_appearance_ready)
|
||||
_appearance_presenter.load_character_appearance(extracted_dir)
|
||||
if _animation_presenter == null:
|
||||
push_error("ThirdPersonWowController: CharacterAnimationPresenter not found at %s" % animation_presenter_path)
|
||||
if spawn_at_tile_center:
|
||||
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
|
||||
var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
|
||||
var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
|
||||
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
|
||||
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
|
||||
_load_character_visual()
|
||||
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||
if spawn_ground_sample.is_available:
|
||||
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||
@@ -106,160 +109,16 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
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(-movement_velocity.x, -movement_velocity.z)
|
||||
if _appearance_presenter != null and horizontal_motion.length_squared() > 0.01:
|
||||
_appearance_presenter.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
|
||||
|
||||
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
func _load_character_visual() -> void:
|
||||
if character_model_path.is_empty() or _visual == null:
|
||||
return
|
||||
if _visual is MeshInstance3D:
|
||||
(_visual as MeshInstance3D).mesh = null
|
||||
_visual.position = Vector3.ZERO
|
||||
|
||||
if _character_root and is_instance_valid(_character_root):
|
||||
_character_root.queue_free()
|
||||
_character_root = null
|
||||
_animation_player = null
|
||||
_active_animation = ""
|
||||
|
||||
var scene: PackedScene = load(character_model_path)
|
||||
if scene == null:
|
||||
push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path)
|
||||
return
|
||||
|
||||
_character_root = Node3D.new()
|
||||
_character_root.name = "CharacterModel"
|
||||
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
|
||||
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
|
||||
_character_root.rotation_degrees.y = character_yaw_offset_degrees
|
||||
|
||||
var instance := scene.instantiate()
|
||||
instance.name = character_model_path.get_file().get_basename()
|
||||
_character_root.add_child(instance)
|
||||
|
||||
var compositor := Node.new()
|
||||
compositor.name = "CharacterTextureCompositor"
|
||||
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
|
||||
compositor.set("textures_dir", _character_textures_dir(character_model_path))
|
||||
compositor.set("extracted_dir", extracted_dir)
|
||||
_character_root.add_child(compositor)
|
||||
|
||||
_visual.add_child(_character_root)
|
||||
await get_tree().process_frame
|
||||
_fit_character_to_ground()
|
||||
_apply_character_starter_outfit(compositor)
|
||||
_animation_player = _find_animation_player(_character_root)
|
||||
if _animation_player:
|
||||
_prepare_animation_player(_animation_player)
|
||||
_update_character_animation(false)
|
||||
if _animation_presenter != null:
|
||||
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
|
||||
func _fit_character_to_ground() -> void:
|
||||
if _character_root == null:
|
||||
return
|
||||
var aabb := _calculate_aabb(_character_root)
|
||||
if aabb.size == Vector3.ZERO:
|
||||
return
|
||||
var local_min_y := aabb.position.y - _character_root.global_position.y
|
||||
_character_root.position.y -= local_min_y
|
||||
|
||||
|
||||
func _apply_character_starter_outfit(compositor: Node) -> void:
|
||||
if _character_root == null or compositor == null:
|
||||
return
|
||||
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
|
||||
if not resolver.call("load_dbcs", extracted_dir):
|
||||
return
|
||||
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
|
||||
var race := String(inferred.get("race", "Human"))
|
||||
var gender := String(inferred.get("gender", "Male"))
|
||||
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
|
||||
if outfit.is_empty():
|
||||
return
|
||||
if compositor.has_method("set_equipment_components"):
|
||||
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
|
||||
if _character_root.has_method("apply_outfit_defaults"):
|
||||
_character_root.call("apply_outfit_defaults", outfit)
|
||||
|
||||
|
||||
func _update_character_animation(is_moving: bool) -> void:
|
||||
if _animation_player == null:
|
||||
return
|
||||
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
|
||||
if wanted.is_empty() or wanted == _active_animation:
|
||||
return
|
||||
_active_animation = wanted
|
||||
_animation_player.play(wanted, animation_blend_time)
|
||||
|
||||
|
||||
func _prepare_animation_player(player: AnimationPlayer) -> void:
|
||||
player.playback_default_blend_time = animation_blend_time
|
||||
for animation_name in player.get_animation_list():
|
||||
var lower := String(animation_name).to_lower()
|
||||
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
|
||||
var animation := player.get_animation(animation_name)
|
||||
if animation:
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _choose_animation(candidates: Array[String]) -> String:
|
||||
if _animation_player == null:
|
||||
return ""
|
||||
for candidate in candidates:
|
||||
if _animation_player.has_animation(candidate):
|
||||
return candidate
|
||||
var list := _animation_player.get_animation_list()
|
||||
for candidate in candidates:
|
||||
var lower := candidate.to_lower()
|
||||
for animation_name in list:
|
||||
if String(animation_name).to_lower().contains(lower):
|
||||
return String(animation_name)
|
||||
return ""
|
||||
|
||||
|
||||
func _find_animation_player(root: Node) -> AnimationPlayer:
|
||||
if root is AnimationPlayer:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_animation_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _calculate_aabb(root: Node) -> AABB:
|
||||
var result := AABB()
|
||||
var has_aabb := false
|
||||
for mesh_inst in _iter_mesh_instances(root):
|
||||
if mesh_inst.mesh == null:
|
||||
continue
|
||||
var local_aabb := mesh_inst.mesh.get_aabb()
|
||||
var global_aabb := mesh_inst.global_transform * local_aabb
|
||||
if not has_aabb:
|
||||
result = global_aabb
|
||||
has_aabb = true
|
||||
else:
|
||||
result = result.merge(global_aabb)
|
||||
return result if has_aabb else AABB()
|
||||
|
||||
|
||||
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(root, result)
|
||||
return result
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(child, result)
|
||||
|
||||
|
||||
func _character_textures_dir(model_path: String) -> String:
|
||||
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
|
||||
func _on_character_appearance_ready(character_root: Node3D) -> void:
|
||||
if _animation_presenter != null:
|
||||
_animation_presenter.bind_character_root(character_root)
|
||||
|
||||
|
||||
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
|
||||
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -105,6 +107,10 @@ shape = SubResource("PlayerCapsuleShape_1")
|
||||
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
|
||||
mesh = SubResource("PlayerCapsuleMesh_1")
|
||||
script = ExtResource("5_appearance")
|
||||
|
||||
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
|
||||
script = ExtResource("6_animation")
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"]
|
||||
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -106,6 +108,10 @@ shape = SubResource("PlayerCapsuleShape_1")
|
||||
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
|
||||
mesh = SubResource("PlayerCapsuleMesh_1")
|
||||
script = ExtResource("5_appearance")
|
||||
|
||||
[node name="AnimationPresenter" type="Node" parent="ThirdPersonPlayer/Visual"]
|
||||
script = ExtResource("6_animation")
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_character"]
|
||||
size = Vector3(1, 2, 1)
|
||||
|
||||
[node name="SyntheticCharacter" type="Node3D"]
|
||||
|
||||
[node name="Body" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(0, 2, 0)
|
||||
mesh = SubResource("BoxMesh_character")
|
||||
@@ -1,14 +1,20 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
[gd_scene load_steps=5 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"]
|
||||
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="2_camera_rig"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="3_appearance"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="4_animation"]
|
||||
|
||||
[node name="PlayerInputRegression" type="CharacterBody3D"]
|
||||
script = ExtResource("1_player")
|
||||
spawn_at_tile_center = false
|
||||
character_model_path = ""
|
||||
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
script = ExtResource("3_appearance")
|
||||
character_model_path = ""
|
||||
|
||||
[node name="AnimationPresenter" type="Node" parent="Visual"]
|
||||
script = ExtResource("4_animation")
|
||||
|
||||
[node name="CameraPivot" type="Node3D" parent="."]
|
||||
script = ExtResource("2_camera_rig")
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free M02 appearance, animation and player-boundary regression.
|
||||
|
||||
const MODEL_FIXTURE_PATH := "res://src/tests/scenes/character_presentation_model_fixture.tscn"
|
||||
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
|
||||
const APPEARANCE_PRESENTER_PATH := "res://src/scenes/character/character_appearance_presenter.gd"
|
||||
const ANIMATION_PRESENTER_PATH := "res://src/scenes/character/character_animation_presenter.gd"
|
||||
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
|
||||
const RUNTIME_SCENE_PATHS: Array[String] = [
|
||||
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
|
||||
"res://src/scenes/streaming/kalimdor_streaming.tscn",
|
||||
]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_verification.call_deferred()
|
||||
|
||||
|
||||
func _run_verification() -> void:
|
||||
var failures: Array[String] = []
|
||||
await _verify_appearance_presenter(failures)
|
||||
_verify_animation_presenter(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
_verify_scene_wiring(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("CHARACTER_PRESENTATION: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("CHARACTER_PRESENTATION PASS appearance_cases=9 animation_cases=10 scenes=3 player_boundary=1")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_appearance_presenter(failures: Array[String]) -> void:
|
||||
var presenter := CharacterAppearancePresenter.new()
|
||||
presenter.character_model_path = MODEL_FIXTURE_PATH
|
||||
presenter.character_scale = 2.0
|
||||
presenter.character_yaw_offset_degrees = 45.0
|
||||
root.add_child(presenter)
|
||||
var ready_roots: Array[Node3D] = []
|
||||
presenter.character_appearance_ready.connect(func(character_root: Node3D) -> void: ready_roots.append(character_root))
|
||||
|
||||
_expect_true(presenter.load_character_appearance("res://missing-extracted-fixture"), "fixture load starts", failures)
|
||||
await process_frame
|
||||
var character_root := presenter.character_root
|
||||
_expect_true(character_root != null, "character root composed", failures)
|
||||
if character_root != null:
|
||||
_expect_near(character_root.scale.x, 2.0, "uniform character scale", failures)
|
||||
_expect_near(character_root.rotation_degrees.y, 45.0, "character yaw offset", failures)
|
||||
_expect_near(character_root.position.y, -2.0, "character grounded after scale", failures)
|
||||
_expect_true(character_root.has_node("CharacterTextureCompositor"), "texture compositor composed", failures)
|
||||
_expect_true(ready_roots.size() == 1, "appearance ready emitted", failures)
|
||||
_expect_true(presenter.extracted_data_directory == "res://missing-extracted-fixture", "content root override retained", failures)
|
||||
|
||||
var previous_root := character_root
|
||||
_expect_true(presenter.load_character_appearance(), "replacement load starts", failures)
|
||||
_expect_true(presenter.character_root != previous_root, "replacement swaps owned root", failures)
|
||||
await process_frame
|
||||
_expect_true(ready_roots.size() == 2, "replacement ready emitted", failures)
|
||||
|
||||
presenter.character_model_path = ""
|
||||
_expect_true(not presenter.load_character_appearance(), "empty model rejected", failures)
|
||||
_expect_true(presenter.character_root == null, "empty model clears old root", failures)
|
||||
await process_frame
|
||||
presenter.free()
|
||||
|
||||
|
||||
func _verify_animation_presenter(failures: Array[String]) -> void:
|
||||
var animation_root := Node.new()
|
||||
var nested_node := Node.new()
|
||||
var animation_player := AnimationPlayer.new()
|
||||
animation_root.add_child(nested_node)
|
||||
nested_node.add_child(animation_player)
|
||||
var animation_library := AnimationLibrary.new()
|
||||
for animation_name in ["Stand", "Run", "Emote"]:
|
||||
animation_library.add_animation(animation_name, Animation.new())
|
||||
animation_player.add_animation_library("", animation_library)
|
||||
|
||||
var presenter := CharacterAnimationPresenter.new()
|
||||
presenter.animation_blend_time_seconds = 0.25
|
||||
_expect_true(presenter.bind_character_root(animation_root), "nested animation player bound", failures)
|
||||
_expect_true(presenter.has_animation_player, "animation player state exposed", failures)
|
||||
_expect_true(presenter.active_animation_name == "Stand", "stationary animation selected", failures)
|
||||
_expect_true(animation_player.current_animation == "Stand", "stationary animation playing", failures)
|
||||
_expect_near(animation_player.playback_default_blend_time, 0.25, "default blend configured", failures)
|
||||
_expect_true(animation_player.get_animation("Stand").loop_mode == Animation.LOOP_LINEAR, "stand loops", failures)
|
||||
_expect_true(animation_player.get_animation("Run").loop_mode == Animation.LOOP_LINEAR, "run loops", failures)
|
||||
_expect_true(animation_player.get_animation("Emote").loop_mode == Animation.LOOP_NONE, "unrelated animation unchanged", failures)
|
||||
_expect_true(presenter.present_locomotion(true), "moving transition starts", failures)
|
||||
_expect_true(presenter.active_animation_name == "Run", "moving animation selected", failures)
|
||||
_expect_true(not presenter.present_locomotion(true), "duplicate moving state ignored", failures)
|
||||
|
||||
var fallback_root := Node.new()
|
||||
var fallback_player := AnimationPlayer.new()
|
||||
fallback_root.add_child(fallback_player)
|
||||
var fallback_library := AnimationLibrary.new()
|
||||
fallback_library.add_animation("CharacterStandLoop", Animation.new())
|
||||
fallback_library.add_animation("FastRunCycle", Animation.new())
|
||||
fallback_player.add_animation_library("", fallback_library)
|
||||
_expect_true(presenter.bind_character_root(fallback_root), "fallback animation player bound", failures)
|
||||
_expect_true(presenter.active_animation_name == "CharacterStandLoop", "substring stationary fallback", failures)
|
||||
presenter.present_locomotion(true)
|
||||
_expect_true(presenter.active_animation_name == "FastRunCycle", "substring moving fallback", failures)
|
||||
fallback_root.free()
|
||||
_expect_true(not presenter.present_locomotion(false), "freed animation root rejected", failures)
|
||||
_expect_true(not presenter.has_animation_player, "freed animation binding cleared", failures)
|
||||
var missing_animation_root := Node.new()
|
||||
_expect_true(not presenter.bind_character_root(missing_animation_root), "missing animation player rejected", failures)
|
||||
_expect_true(not presenter.has_animation_player, "missing binding clears state", failures)
|
||||
presenter.free()
|
||||
animation_root.free()
|
||||
missing_animation_root.free()
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||
for forbidden_text in [
|
||||
"GEOSET_CONTROLLER_SCRIPT",
|
||||
"TEXTURE_COMPOSITOR_SCRIPT",
|
||||
"OUTFIT_RESOLVER_SCRIPT",
|
||||
"func _load_character_visual",
|
||||
"func _find_animation_player",
|
||||
"func _calculate_aabb",
|
||||
"_animation_player",
|
||||
]:
|
||||
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
|
||||
_expect_true(player_source.contains("_appearance_presenter.load_character_appearance"), "player delegates appearance", failures)
|
||||
_expect_true(player_source.contains("_animation_presenter.present_locomotion"), "player delegates animation", failures)
|
||||
|
||||
var appearance_source := _read_text(APPEARANCE_PRESENTER_PATH, failures)
|
||||
for forbidden_text in ["MoveIntent", "TerrainQuery", "ThirdPersonCameraRig", "AnimationPlayer"]:
|
||||
_expect_true(not appearance_source.contains(forbidden_text), "appearance omits %s" % forbidden_text, failures)
|
||||
var animation_source := _read_text(ANIMATION_PRESENTER_PATH, failures)
|
||||
for forbidden_text in ["load(character_model_path)", "TerrainQuery", "MoveIntent", "Camera3D"]:
|
||||
_expect_true(not animation_source.contains(forbidden_text), "animation omits %s" % forbidden_text, failures)
|
||||
|
||||
|
||||
func _verify_scene_wiring(failures: Array[String]) -> void:
|
||||
for scene_path in RUNTIME_SCENE_PATHS + [REGRESSION_SCENE_PATH]:
|
||||
var scene_source := _read_text(scene_path, failures)
|
||||
_expect_true(scene_source.contains("character_appearance_presenter.gd"), "%s references appearance presenter" % scene_path, failures)
|
||||
_expect_true(scene_source.contains("character_animation_presenter.gd"), "%s references animation presenter" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('name="AnimationPresenter"'), "%s composes animation presenter" % scene_path, failures)
|
||||
|
||||
|
||||
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)
|
||||
@@ -102,7 +102,15 @@ func _verify_scene_boundary(failures: Array[String]) -> void:
|
||||
|
||||
|
||||
func _new_controller() -> LocalPlayerMovementController:
|
||||
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
|
||||
return LocalPlayerMovementController.new(
|
||||
7.0,
|
||||
4.5,
|
||||
4.5,
|
||||
7.0,
|
||||
28.0,
|
||||
6.0,
|
||||
PlayerMovementCapabilities.render_sandbox()
|
||||
)
|
||||
|
||||
|
||||
func _intent(
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless M02 profile/capability gate regression for debug player movement.
|
||||
|
||||
const MOVEMENT_CAPABILITIES_PATH := "res://src/gameplay/movement/player_movement_capabilities.gd"
|
||||
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"
|
||||
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_capability_profiles(failures)
|
||||
_verify_movement_gate(failures)
|
||||
_verify_real_scene_profiles(failures)
|
||||
_verify_source_boundary(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("PLAYER_MOVEMENT_CAPABILITIES: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("PLAYER_MOVEMENT_CAPABILITIES PASS profiles=3 sprint=2 flight=2 scenes=2 boundary=1")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_capability_profiles(failures: Array[String]) -> void:
|
||||
var render_sandbox := PlayerMovementCapabilities.render_sandbox()
|
||||
_expect_true(render_sandbox.profile_id == PlayerMovementCapabilities.RENDER_SANDBOX_PROFILE_ID, "sandbox profile identity", failures)
|
||||
_expect_true(render_sandbox.allows_debug_sprint, "sandbox sprint capability", failures)
|
||||
_expect_true(render_sandbox.allows_debug_free_flight, "sandbox flight capability", failures)
|
||||
|
||||
var blizzlike := PlayerMovementCapabilities.blizzlike_335()
|
||||
_expect_true(blizzlike.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "blizzlike profile identity", failures)
|
||||
_expect_true(not blizzlike.allows_debug_sprint, "blizzlike rejects sprint", failures)
|
||||
_expect_true(not blizzlike.allows_debug_free_flight, "blizzlike rejects flight", failures)
|
||||
|
||||
var unknown_profile := PlayerMovementCapabilities.for_profile_id(&"UnknownProfile")
|
||||
_expect_true(unknown_profile.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "unknown profile fails closed", failures)
|
||||
_expect_true(not unknown_profile.allows_debug_sprint, "unknown profile rejects sprint", failures)
|
||||
_expect_true(not unknown_profile.allows_debug_free_flight, "unknown profile rejects flight", failures)
|
||||
|
||||
|
||||
func _verify_movement_gate(failures: Array[String]) -> void:
|
||||
var sprint_intent := MoveIntent.new(1.0, 0.0, 0.0, true)
|
||||
var sandbox_controller := _new_controller(PlayerMovementCapabilities.render_sandbox())
|
||||
sandbox_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
|
||||
_expect_vector_near(sandbox_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint applied", failures)
|
||||
_expect_true(sandbox_controller.toggle_sandbox_flight(), "sandbox flight enabled", failures)
|
||||
|
||||
var blizzlike_controller := _new_controller(PlayerMovementCapabilities.blizzlike_335())
|
||||
blizzlike_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
|
||||
_expect_vector_near(blizzlike_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike sprint ignored", failures)
|
||||
_expect_true(not blizzlike_controller.toggle_sandbox_flight(), "blizzlike flight rejected", failures)
|
||||
_expect_true(not blizzlike_controller.is_flight_enabled, "blizzlike remains grounded", failures)
|
||||
|
||||
var safe_default_controller := LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
|
||||
_expect_true(safe_default_controller.movement_profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "missing capability defaults safe", failures)
|
||||
|
||||
|
||||
func _verify_real_scene_profiles(failures: Array[String]) -> void:
|
||||
var sandbox_player := _instantiate_player("RenderSandbox", failures)
|
||||
if sandbox_player != null:
|
||||
_press_sprint_forward_for_two_seconds(sandbox_player)
|
||||
var sandbox_movement: LocalPlayerMovementController = sandbox_player.get("_local_movement_controller")
|
||||
_expect_vector_near(sandbox_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox scene sprint", failures)
|
||||
sandbox_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
|
||||
_expect_true(sandbox_movement.is_flight_enabled, "sandbox scene flight", failures)
|
||||
sandbox_player.free()
|
||||
|
||||
var blizzlike_player := _instantiate_player("Blizzlike335", failures)
|
||||
if blizzlike_player != null:
|
||||
_press_sprint_forward_for_two_seconds(blizzlike_player)
|
||||
var blizzlike_movement: LocalPlayerMovementController = blizzlike_player.get("_local_movement_controller")
|
||||
_expect_vector_near(blizzlike_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike scene sprint rejected", failures)
|
||||
blizzlike_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
|
||||
_expect_true(not blizzlike_movement.is_flight_enabled, "blizzlike scene flight rejected", failures)
|
||||
blizzlike_player.free()
|
||||
|
||||
|
||||
func _instantiate_player(profile_id: String, failures: Array[String]) -> CharacterBody3D:
|
||||
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
|
||||
if packed_scene == null:
|
||||
failures.append("regression scene loads")
|
||||
return null
|
||||
var player := packed_scene.instantiate() as CharacterBody3D
|
||||
if player == null:
|
||||
failures.append("regression player instantiates")
|
||||
return null
|
||||
player.set("movement_profile_id", profile_id)
|
||||
root.add_child(player)
|
||||
player.set_physics_process(false)
|
||||
return player
|
||||
|
||||
|
||||
func _press_sprint_forward_for_two_seconds(player: CharacterBody3D) -> void:
|
||||
Input.action_press(PlayerInputActions.MOVE_FORWARD)
|
||||
Input.action_press(PlayerInputActions.DEBUG_SPRINT)
|
||||
player.call("_physics_process", 2.0)
|
||||
Input.action_release(PlayerInputActions.DEBUG_SPRINT)
|
||||
Input.action_release(PlayerInputActions.MOVE_FORWARD)
|
||||
|
||||
|
||||
func _action_event(action_name: StringName) -> InputEventAction:
|
||||
var input_event := InputEventAction.new()
|
||||
input_event.action = action_name
|
||||
input_event.pressed = true
|
||||
return input_event
|
||||
|
||||
|
||||
func _verify_source_boundary(failures: Array[String]) -> void:
|
||||
var capability_source := _read_text(MOVEMENT_CAPABILITIES_PATH, failures)
|
||||
for forbidden_text in ["extends Node", "extends Resource", "Input.", "CharacterBody3D"]:
|
||||
_expect_true(not capability_source.contains(forbidden_text), "capability omits %s" % forbidden_text, failures)
|
||||
_expect_true(not capability_source.contains("\n\tset:"), "capability exposes no property setters", failures)
|
||||
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
|
||||
_expect_true(movement_source.contains("allows_debug_sprint"), "movement gates sprint capability", failures)
|
||||
_expect_true(movement_source.contains("allows_debug_free_flight"), "movement gates flight capability", failures)
|
||||
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
|
||||
_expect_true(player_source.contains("for_profile_id(StringName(movement_profile_id))"), "scene maps profile once", failures)
|
||||
_expect_true(not player_source.contains('movement_profile_id == "'), "scene omits profile branches", failures)
|
||||
|
||||
|
||||
func _new_controller(capabilities: PlayerMovementCapabilities) -> LocalPlayerMovementController:
|
||||
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0, capabilities)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user