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
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user