From 97bb53f6cd6db7675bc0d50207818dd4809e9721 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:42:12 +0400 Subject: [PATCH 1/2] rnd(M02): extract character presenters Work-Package: M02-RND-CHARACTER-PRESENTATION-001 Agent: sindo-main-codex Tests: presentation/input/movement/terrain/camera regressions; coordinate/streaming/docs/coordination gates; renderer dry-run 7/7 Fidelity: preserves current sandbox model/outfit and Stand/Idle/Run/Walk behavior; build-12340 parity remains unverified --- .../M02-RND-CHARACTER-PRESENTATION-001.md | 83 ++++++ docs/modules/README.md | 1 + docs/modules/character-presentation.md | 273 ++++++++++++++++++ docs/modules/local-player-movement.md | 13 +- .../character_animation_presenter.gd | 95 ++++++ .../character_animation_presenter.gd.uid | 1 + .../character_appearance_presenter.gd | 150 ++++++++++ .../character_appearance_presenter.gd.uid | 1 + .../player/third_person_wow_controller.gd | 184 ++---------- .../streaming/eastern_kingdoms_streaming.tscn | 6 + src/scenes/streaming/kalimdor_streaming.tscn | 6 + .../character_presentation_model_fixture.tscn | 10 + src/tests/scenes/player_input_regression.tscn | 10 +- src/tools/verify_character_presentation.gd | 164 +++++++++++ 14 files changed, 824 insertions(+), 173 deletions(-) create mode 100644 coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md create mode 100644 docs/modules/character-presentation.md create mode 100644 src/scenes/character/character_animation_presenter.gd create mode 100644 src/scenes/character/character_animation_presenter.gd.uid create mode 100644 src/scenes/character/character_appearance_presenter.gd create mode 100644 src/scenes/character/character_appearance_presenter.gd.uid create mode 100644 src/tests/scenes/character_presentation_model_fixture.tscn create mode 100644 src/tools/verify_character_presentation.gd diff --git a/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md b/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md new file mode 100644 index 0000000..7894a0a --- /dev/null +++ b/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md @@ -0,0 +1,83 @@ +# M02-RND-CHARACTER-PRESENTATION-001 — Character presentation boundary + + + +## 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: active +- Done: claim and isolated worktree from merged camera master +- Next: extract presenters, migrate scenes/player, test and document +- Blocked by: diff --git a/docs/modules/README.md b/docs/modules/README.md index 62440f4..ac6a46a 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -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) | diff --git a/docs/modules/character-presentation.md b/docs/modules/character-presentation.md new file mode 100644 index 0000000..371e085 --- /dev/null +++ b/docs/modules/character-presentation.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 | `working tree`, 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`. diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index 5cc4199..388ed95 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -5,9 +5,9 @@ | 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, camera basis and character-presentation consumer updates | | Owners | Gameplay local movement state; scene composition owns the instance | -| Last verified | `8c1cf9b`, 2026-07-14 | +| Last verified | `working tree`, 2026-07-14 | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending | ## Purpose @@ -38,7 +38,7 @@ flowchart LR 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: @@ -83,7 +83,7 @@ Constructor units: | 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: @@ -128,7 +128,7 @@ sequenceDiagram participant Input as PlayerInputSource participant Scene as ThirdPersonWowController participant Move as LocalPlayerMovementController - participant Present as Facing/Animation adapter + participant Present as CharacterAnimationPresenter adapter Input-->>Scene: MoveIntent Scene->>Move: advance(intent, selected basis, delta) Move-->>Scene: displacement @@ -234,7 +234,8 @@ separate versioned movement snapshot contract. | `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration | | `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/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture | | `src/tools/verify_player_input.gd` | Input-to-movement scene regression | diff --git a/src/scenes/character/character_animation_presenter.gd b/src/scenes/character/character_animation_presenter.gd new file mode 100644 index 0000000..4cbfb0a --- /dev/null +++ b/src/scenes/character/character_animation_presenter.gd @@ -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 diff --git a/src/scenes/character/character_animation_presenter.gd.uid b/src/scenes/character/character_animation_presenter.gd.uid new file mode 100644 index 0000000..cf08a8b --- /dev/null +++ b/src/scenes/character/character_animation_presenter.gd.uid @@ -0,0 +1 @@ +uid://c2jnn0e3mmymc diff --git a/src/scenes/character/character_appearance_presenter.gd b/src/scenes/character/character_appearance_presenter.gd new file mode 100644 index 0000000..8376e4f --- /dev/null +++ b/src/scenes/character/character_appearance_presenter.gd @@ -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") diff --git a/src/scenes/character/character_appearance_presenter.gd.uid b/src/scenes/character/character_appearance_presenter.gd.uid new file mode 100644 index 0000000..f4495bf --- /dev/null +++ b/src/scenes/character/character_appearance_presenter.gd.uid @@ -0,0 +1 @@ +uid://bhqbo1ftrt3a3 diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index bd69455..dcf33eb 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -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") @@ -29,17 +26,11 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai @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 @@ -67,18 +58,25 @@ func _ready() -> void: sprint_multiplier ) _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 +104,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: diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 9411095..ff568ca 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -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) diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index bc44f99..3a7f685 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -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) diff --git a/src/tests/scenes/character_presentation_model_fixture.tscn b/src/tests/scenes/character_presentation_model_fixture.tscn new file mode 100644 index 0000000..8e0cbdc --- /dev/null +++ b/src/tests/scenes/character_presentation_model_fixture.tscn @@ -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") diff --git a/src/tests/scenes/player_input_regression.tscn b/src/tests/scenes/player_input_regression.tscn index c942201..147c7f6 100644 --- a/src/tests/scenes/player_input_regression.tscn +++ b/src/tests/scenes/player_input_regression.tscn @@ -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") diff --git a/src/tools/verify_character_presentation.gd b/src/tools/verify_character_presentation.gd new file mode 100644 index 0000000..7651166 --- /dev/null +++ b/src/tools/verify_character_presentation.gd @@ -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) From ad820699bcf6abfab32c4052148ad83cdd68ce51 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:42:42 +0400 Subject: [PATCH 2/2] docs(M02): publish character presentation handoff Work-Package: M02-RND-CHARACTER-PRESENTATION-001 Agent: sindo-main-codex --- .../M02-RND-CHARACTER-PRESENTATION-001.md | 47 +++++++++++++++++-- docs/modules/character-presentation.md | 2 +- docs/modules/local-player-movement.md | 2 +- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md b/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md index 7894a0a..3426a47 100644 --- a/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md +++ b/coordination/claims/M02-RND-CHARACTER-PRESENTATION-001.md @@ -77,7 +77,48 @@ components without changing the current sandbox presentation. ## Status -- State: active -- Done: claim and isolated worktree from merged camera master -- Next: extract presenters, migrate scenes/player, test and document +- 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: + + + +## 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. diff --git a/docs/modules/character-presentation.md b/docs/modules/character-presentation.md index 371e085..d65c637 100644 --- a/docs/modules/character-presentation.md +++ b/docs/modules/character-presentation.md @@ -7,7 +7,7 @@ | Status | Implemented boundary / Partial fidelity | | Target/work package | M02 / M02-RND-CHARACTER-PRESENTATION-001 | | Owners | Character appearance composition and locomotion animation presentation | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `97bb53f`, 2026-07-14 | | Profiles/capabilities | Current render sandbox character experiment | ## Purpose diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index 388ed95..0fa6526 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -7,7 +7,7 @@ | Status | Implemented | | Target/work package | M02 / M02-GMP-MOVEMENT-001, camera basis and character-presentation consumer updates | | Owners | Gameplay local movement state; scene composition owns the instance | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `97bb53f`, 2026-07-14 | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending | ## Purpose