Refactor rhythm action architecture

This commit is contained in:
wxm
2026-07-02 09:47:52 -07:00
parent fc941cf08d
commit e62ed84518
124 changed files with 7516 additions and 2440 deletions

View File

@@ -0,0 +1,211 @@
extends SceneTree
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
var failures: Array[String] = []
var rejected: Array[StringName] = []
var started: Array[StringName] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/characters/player.tscn")
var player := scene.instantiate()
root.add_child(player)
await process_frame
var controller := player.get_node_or_null("ActionController")
_expect_bool(controller != null, true, "Player should have ActionController")
if controller == null:
player.free()
_finish()
return
controller.connect("action_rejected", _on_action_rejected)
controller.connect("action_started", _on_action_started)
player.get_node("ComboWindow").record(&"S")
var miss: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
miss.judgement = {"label": "miss", "diff": 0.0}
controller.call("submit_intent", miss)
_expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should enter ComboWindow as an explicit placeholder")
_expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss intent rejection reason")
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [&"S", &"Ø"], "Miss intent should not clear ComboWindow by itself")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
rejected.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
var startup_miss: RefCounted = InputIntentScript.create(&"D", &"d", &"pressed", float(Time.get_ticks_msec()))
startup_miss.judgement = {"label": "miss", "diff": 0.0}
controller.call("submit_intent", startup_miss)
_expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should enter ComboWindow immediately")
if rejected.is_empty():
failures.append("Miss during an action should be rejected immediately: no rejection emitted")
else:
_expect_string(str(rejected[rejected.size() - 1]), "miss", "Miss during an action should be rejected immediately")
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss during an action should not clear ComboWindow by itself")
player.get_node("ComboWindow").clear(&"test-reset")
started.clear()
player.get_node("EnergyComponent").set_current(0)
var first: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
first.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", first)
_expect_int(player.call("get_energy"), 0, "A reward should wait until active phase")
var second: RefCounted = InputIntentScript.create(&"A", &"a", &"pressed", float(Time.get_ticks_msec()))
second.judgement = {"label": "perfect", "diff": 0.0}
controller.call("submit_intent", second)
_expect_array(player.call("get_combo_slots"), [&"A"], "Input during startup should wait outside ComboWindow")
_expect_int(started.size(), 1, "Second input during startup should not start a second action immediately")
await create_timer(0.2).timeout
await physics_frame
_expect_int(player.call("get_energy"), 1, "A reward should apply once active phase begins")
await create_timer(0.48).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"A"], "Pending input should enter ComboWindow only when cancel window opens")
_expect_bool(started.size() >= 2, true, "Pending input should start after phase completes or cancel window opens")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
rejected.clear()
started.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
controller.call("submit_intent", _perfect_intent(&"D", &"d"))
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
_expect_array(player.call("get_combo_slots"), [&"A"], "Rapid inputs should not advance ComboWindow at raw input speed")
_expect_bool(rejected.has(&"replaced"), true, "Rapid inputs should replace the single pending intent")
await create_timer(0.5).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"S"], "Only the latest pending intent should enter ComboWindow at cancel time")
_expect_int(started.size(), 2, "Replaced rapid inputs should start only the initial action and the latest pending fallback")
_expect_bool(started.has(&"skill_s"), true, "Latest pending S should fall back to normal S when A+S is unresolved")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
controller.call("submit_intent", _perfect_intent(&"A", &"a"))
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
await create_timer(0.55).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should show its full input pattern")
await create_timer(0.2).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "Clear-window action should not clear from a stale display timer before it finishes")
await create_timer(0.4).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [], "Clear-window action should clear when its action finishes")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
player.get_node("ChargeComponent").cancel()
controller.call("_reset_to_idle")
started.clear()
player.call("_on_input_intent_created", _perfect_intent(&"D", &"d"))
player.call("_on_input_intent_created", _release_intent(&"D", &"d"))
player.call("_on_input_intent_created", _perfect_intent(&"D", &"d"))
player.call("_on_input_intent_created", _release_intent(&"D", &"d"))
await create_timer(0.55).timeout
await physics_frame
_expect_array(player.call("get_combo_slots"), [&"D", &"D"], "Released pending D should still be adjudicated into DD at cancel time")
await create_timer(0.8).timeout
await process_frame
_expect_bool(player.call("is_charge_active"), false, "Released pending D should not leave player stuck charging")
_expect_int(int(round(float(player.call("get_charge")))), 0, "Released pending D should not accumulate charge")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
_expect_bool(started.has(&"skill_s_projectile_1"), true, "First S+SP should start projectile")
_expect_bool(started.count(&"skill_s_projectile_1") >= 2, true, "Second S+SP should restart projectile chain")
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("EnergyComponent").set_current(10)
controller.call("_reset_to_idle")
started.clear()
rejected.clear()
player.get_node("ComboWindow").record(&"A")
player.get_node("ComboWindow").record(&"D")
controller.call("submit_intent", _perfect_intent(&"S", &"s"))
controller.call("_reset_to_idle")
controller.call("submit_intent", _perfect_intent(&"SP", &"space"))
_expect_bool(started.has(&"skill_s_projectile_1"), true, "S+SP should start projectile from the trailing two slots")
_expect_bool(not rejected.has(&"unresolved"), true, "Trailing S+SP should not be rejected as unresolved")
var context: Dictionary = controller.call("_resolver_context")
_expect_bool(context.has("state"), true, "Resolver context should include state")
_expect_bool(context.has("blade_chain_active"), true, "Resolver context should include blade_chain_active")
_expect_bool(context.has("counter_ready"), true, "Resolver context should include counter_ready")
_expect_bool(context.has("burst_action_id"), true, "Resolver context should include burst_action_id")
player.free()
_finish()
func _on_action_rejected(_intent, reason: StringName) -> void:
rejected.append(reason)
func _on_action_started(action: Resource, _intent) -> void:
started.append(StringName(str(action.get("id"))))
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_string(actual: String, expected: String, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_array(actual: Array, expected: Array, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted:
var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
func _release_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted:
var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"released", float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
func _finish() -> void:
if failures.is_empty():
print("PASS action controller flow")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://bfrcdxpy2fwkd

View File

@@ -0,0 +1,158 @@
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_check_event_bus_autoload()
_check_main_is_thin()
_check_player_components()
_check_skill_resources()
_check_stage_and_ui_scenes()
_check_project_layers_and_inputs()
_finish()
func _check_event_bus_autoload() -> void:
_expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus should be registered as a game autoload")
if ProjectSettings.has_setting("autoload/EventBus"):
var path := str(ProjectSettings.get_setting("autoload/EventBus"))
_expect(path.contains("res://autoload/event_bus.gd"), "EventBus autoload should point at autoload/event_bus.gd")
var event_bus_script := load("res://autoload/event_bus.gd")
_expect(event_bus_script != null, "autoload/event_bus.gd should exist")
func _check_main_is_thin() -> void:
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
_expect(main_scene != null, "main.tscn should load")
if main_scene != null:
var main := main_scene.instantiate()
get_root().add_child(main)
_expect(main.has_node("Stage"), "Main should instance a Stage child")
_expect(main.has_node("UI"), "Main should instance a standalone UI child")
_expect(not main.has_node("RhythmConductor"), "Main should use RhythmManager autoload instead of a RhythmConductor child")
main.free()
var source := _read_text("res://scenes/main/main.gd")
_expect_not_contains(source, "has_signal", "Main should not probe child signals with has_signal")
_expect_not_contains(source, "has_method", "Main should not probe child methods with has_method")
_expect_not_contains(source, ".call(", "Main should not use dynamic call() for game wiring")
func _check_player_components() -> void:
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
_expect(player_scene != null, "player.tscn should load")
if player_scene != null:
var player := player_scene.instantiate()
get_root().add_child(player)
for node_name: String in [
"StateMachine",
"InputComponent",
"ComboWindow",
"ActionResolver",
"ActionExecutor",
"MotionExecutor",
"BurstComponent",
"ChargeComponent",
"EnergyComponent",
"HealthComponent",
"DamageReceiver",
"DamageEmitter",
]:
_expect(player.has_node(node_name), "Player should have %s child component" % node_name)
player.free()
var source := _read_text("res://scenes/characters/player.gd")
_expect_not_contains(source, "KEY_", "Player should not match raw KEY_* values")
_expect_not_contains(source, "get_first_node_in_group(\"rhythm_conductor\")", "Player should not look up RhythmConductor by group")
_expect_not_contains(source, "PlayerProjectile.new()", "Player should not instantiate projectiles directly")
_expect_not_contains(source, "get_parent()", "Player should not reach upward with get_parent()")
func _check_skill_resources() -> void:
var action_script := load("res://resources/action_data.gd")
_expect(action_script != null, "resources/action_data.gd should exist")
var action_dir := DirAccess.open("res://resources/actions")
_expect(action_dir != null, "resources/actions should exist")
if action_dir != null:
var action_files: Array[String] = []
for file_name: String in action_dir.get_files():
if file_name.ends_with(".tres"):
action_files.append(file_name)
_expect(action_files.size() >= 20, "actions should be saved as migrated .tres resources")
var tracker_script := load("res://scenes/components/combo_window.gd")
var resolver_script := load("res://scenes/combat/action_resolver.gd")
_expect(tracker_script != null, "ComboWindow script should load")
_expect(resolver_script != null, "ActionResolver script should load")
if tracker_script == null or resolver_script == null:
return
var window = tracker_script.new()
window.record(&"A")
var resolved = resolver_script.resolve(window)
_expect(resolved is Resource, "ActionResolver.resolve should return an ActionData Resource, not a Dictionary")
if resolved is Resource:
_expect(resolved.get("input_pattern") is Array, "Resolved ActionData should expose input_pattern")
_expect(str(resolved.get("id")) == "skill_a", "A pattern should resolve to skill_a")
resolver_script.clear_cache()
window.free()
func _check_stage_and_ui_scenes() -> void:
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
_expect(stage_scene != null, "stage.tscn should exist")
if stage_scene != null:
var stage := stage_scene.instantiate()
get_root().add_child(stage)
_expect(stage.has_node("ActorsContainer"), "Stage should own ActorsContainer")
_expect(stage.has_node("ActorsContainer/Player"), "ActorsContainer should own Player")
stage.free()
for scene_path: String in [
"res://scenes/ui/main_ui.tscn",
"res://scenes/ui/rhythm_track.tscn",
"res://scenes/ui/combo_window_hud.tscn",
"res://scenes/ui/energy_bar.tscn",
]:
_expect(load(scene_path) != null, "%s should exist" % scene_path)
func _check_project_layers_and_inputs() -> void:
var expected_layers := {
"layer_names/2d_physics/layer_1": "world",
"layer_names/2d_physics/layer_2": "player_hurtbox",
"layer_names/2d_physics/layer_3": "enemy_hurtbox",
"layer_names/2d_physics/layer_4": "player_hitbox",
"layer_names/2d_physics/layer_5": "enemy_hitbox",
}
for key: String in expected_layers:
_expect(ProjectSettings.has_setting(key), "%s should be configured" % key)
if ProjectSettings.has_setting(key):
_expect(str(ProjectSettings.get_setting(key)) == expected_layers[key], "%s should be named %s" % [key, expected_layers[key]])
for action_name: String in ["move_left", "move_right", "combo_w", "combo_a", "combo_d", "combo_s", "combo_space"]:
_expect(InputMap.has_action(action_name), "InputMap should define %s" % action_name)
_expect(not InputMap.has_action("player_space"), "InputMap should remove duplicate player_space action")
func _read_text(path: String) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("Could not read %s" % path)
return ""
return file.get_as_text()
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _expect_not_contains(source: String, needle: String, label: String) -> void:
if source.contains(needle):
failures.append(label)
func _finish() -> void:
if failures.is_empty():
print("PASS architecture refactor")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://dfs2gbjkn4png

131
tests/test_chart_layer.gd Normal file
View File

@@ -0,0 +1,131 @@
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await process_frame
_check_resources_load()
await _check_runner_upcoming_and_triggered_once()
await _check_event_bus_mirroring()
_finish()
func _check_resources_load() -> void:
_expect(load("res://resources/chart_event.gd") != null, "ChartEvent script should load")
_expect(load("res://resources/chart_track.gd") != null, "ChartTrack script should load")
_expect(load("res://resources/beat_chart.gd") != null, "BeatChart script should load")
_expect(load("res://scenes/chart/chart_runner.gd") != null, "ChartRunner script should load")
func _make_event(beat: int, event_type: StringName, target_id := &"", lead_beats := 1.0):
var event_script: Script = load("res://resources/chart_event.gd")
var event: Resource = event_script.new()
event.set("beat_index", beat)
event.set("event_type", event_type)
event.set("target_id", target_id)
event.set("lead_beats", lead_beats)
return event
func _make_chart() -> Resource:
var chart_script: Script = load("res://resources/beat_chart.gd")
var track_script: Script = load("res://resources/chart_track.gd")
var chart: Resource = chart_script.new()
var track: Resource = track_script.new()
chart.set("chart_id", &"test_chart")
track.set("track_id", &"enemy")
track.set("track_type", &"enemy")
track.set("events", [
_make_event(2, &"enemy_prepare_attack", &"test_enemy", 1.0),
_make_event(3, &"enemy_attack_active", &"test_enemy", 1.0),
])
chart.set("tracks", [track])
return chart
func _make_runner(chart: Resource) -> Node:
var runner_script: Script = load("res://scenes/chart/chart_runner.gd")
var runner: Node = runner_script.new()
runner.set("beat_time_override", 0.5)
runner.call("set_chart", chart)
root.add_child(runner)
return runner
func _check_runner_upcoming_and_triggered_once() -> void:
var runner := _make_runner(_make_chart())
var upcoming: Array[StringName] = []
var triggered: Array[StringName] = []
runner.connect("chart_event_upcoming", func(event: Resource, _time_to_event: float) -> void:
upcoming.append(StringName(str(event.get("event_type"))))
)
runner.connect("chart_event_triggered", func(event: Resource) -> void:
triggered.append(StringName(str(event.get("event_type"))))
)
runner.call("update_for_song_time", 0.49)
_expect(upcoming == [&"enemy_prepare_attack"], "Prepare upcoming should fire at lead window")
_expect(triggered.is_empty(), "No event should trigger before event time")
runner.call("update_for_song_time", 1.0)
runner.call("update_for_song_time", 1.1)
_expect(triggered == [&"enemy_prepare_attack"], "Prepare triggered should fire once")
runner.call("update_for_song_time", 1.49)
runner.call("update_for_song_time", 1.50)
runner.call("update_for_song_time", 1.80)
_expect(upcoming.count(&"enemy_attack_active") == 1, "Attack upcoming should fire once")
_expect(triggered.count(&"enemy_attack_active") == 1, "Attack triggered should fire once")
runner.queue_free()
await process_frame
func _check_event_bus_mirroring() -> void:
var bus_script: Script = load("res://autoload/event_bus.gd")
var bus := root.get_node_or_null("EventBus")
var owns_bus := false
if bus == null:
bus = bus_script.new()
bus.name = "EventBus"
root.add_child(bus)
owns_bus = true
var mirrored_upcoming: Array[Resource] = []
var mirrored_triggered: Array[Resource] = []
bus.connect("chart_event_upcoming", func(_event: Resource, _time_to_event: float) -> void:
mirrored_upcoming.append(_event)
)
bus.connect("chart_event_triggered", func(_event: Resource) -> void:
mirrored_triggered.append(_event)
)
var runner := _make_runner(_make_chart())
runner.call("update_for_song_time", 0.49)
runner.call("update_for_song_time", 1.0)
_expect(mirrored_upcoming.size() == 2, "ChartRunner should mirror upcoming events to EventBus")
_expect(mirrored_triggered.size() == 1, "ChartRunner should mirror triggered events to EventBus")
runner.queue_free()
if owns_bus:
bus.queue_free()
await process_frame
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _finish() -> void:
if failures.is_empty():
print("PASS chart layer")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://bphi58hvfrrnl

View File

@@ -4,80 +4,82 @@ var failures: Array[String] = []
func _init() -> void:
var scene: PackedScene = load("res://scenes/main/main.tscn")
_run.call_deferred()
func _run() -> void:
_check_energy_bar_scene_has_editor_segments()
var scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
if scene == null:
push_error("Could not load main.tscn")
push_error("Could not load main_ui.tscn")
quit(1)
return
var main: Node = scene.instantiate()
get_root().add_child(main)
var player: Node = main.get_node_or_null("Player")
if player == null:
failures.append("Missing Player")
elif not player.has_signal("combo_window_cleared"):
failures.append("Player should expose combo_window_cleared")
if not main.has_method("_play_combo_clear_animation"):
failures.append("Main should implement _play_combo_clear_animation")
if not main.has_method("_on_energy_changed"):
failures.append("Main should implement _on_energy_changed")
if not main.has_method("_on_health_changed"):
failures.append("Main should implement _on_health_changed")
if not main.has_method("_on_charge_changed"):
failures.append("Main should implement _on_charge_changed")
var status_bars: Node = main.get_node_or_null("RhythmFeedback/StatusBars")
if status_bars == null:
failures.append("Missing StatusBars")
else:
var health_bar := status_bars.get_node_or_null("HealthBar")
if health_bar == null:
failures.append("Missing HealthBar")
elif not health_bar is ProgressBar:
failures.append("HealthBar should be a ProgressBar")
var energy_bar := status_bars.get_node_or_null("EnergyBar")
if energy_bar == null:
failures.append("Missing EnergyBar")
else:
for index: int in range(10):
var segment := energy_bar.get_node_or_null("Segment%d" % index)
if segment == null:
failures.append("Missing energy segment %d" % index)
elif not segment is Panel:
failures.append("Energy segment %d should be a Panel" % index)
var charge_bar := status_bars.get_node_or_null("ChargeBar")
if charge_bar == null:
failures.append("Missing ChargeBar")
elif not charge_bar is ProgressBar:
failures.append("ChargeBar should be a ProgressBar")
elif main.has_method("_on_charge_changed") and main.has_method("_update_charge_bar_flash"):
main.set("charge_bar", charge_bar)
main.call("_on_charge_changed", 1.1, 1.1, true, true)
main.call("_update_charge_bar_flash", 0.13)
var flashing_alpha: float = charge_bar.modulate.a
main.call("_on_charge_changed", 1.1, 1.1, true, true)
if is_equal_approx(charge_bar.modulate.a, 1.0):
failures.append("Ready charge updates should not reset ChargeBar flash alpha")
if not is_equal_approx(charge_bar.modulate.a, flashing_alpha):
failures.append("Ready charge updates should preserve ChargeBar flash alpha")
var combo_window: Node = main.get_node_or_null("RhythmFeedback/ComboWindow")
if combo_window == null:
failures.append("Missing ComboWindow")
else:
for index: int in range(4):
var slot := combo_window.get_node_or_null("Slot%d" % index)
if slot == null:
failures.append("Missing visual slot %d" % index)
continue
if not slot is PanelContainer:
failures.append("Slot%d should be a PanelContainer" % index)
if slot.get_node_or_null("Key") == null:
failures.append("Slot%d should contain Key label" % index)
main.free()
var ui := scene.instantiate()
root.add_child(ui)
await process_frame
_expect_node(ui, "RhythmTrack", "UI should include RhythmTrack")
_expect_node(ui, "ComboWindow", "UI should include ComboWindow")
_expect_node(ui, "StatusBars/HealthBar", "UI should include HealthBar")
_expect_node(ui, "StatusBars/EnergyBar", "UI should include EnergyBar")
_expect_node(ui, "StatusBars/ChargeBar", "UI should include ChargeBar")
var combo_window := ui.get_node("ComboWindow")
_expect_bool(combo_window.get_child_count() >= 4, true, "ComboWindowHud should build four visual slots")
var bus := _event_bus()
bus.emit_signal("player_health_changed", 42, 100)
bus.emit_signal("player_energy_changed", 3.0, 10.0)
bus.emit_signal("player_charge_changed", 0.8, 1.1, false, true)
bus.emit_signal("combo_updated", [&"A", &"SP"])
await process_frame
var health_bar := ui.get_node("StatusBars/HealthBar") as ProgressBar
var charge_bar := ui.get_node("StatusBars/ChargeBar") as ProgressBar
_expect_float(float(health_bar.value), 42.0, "HealthBar should follow EventBus health")
_expect_float(float(charge_bar.value), 0.8, "ChargeBar should follow EventBus charge")
ui.free()
_finish()
func _check_energy_bar_scene_has_editor_segments() -> void:
var scene: PackedScene = load("res://scenes/ui/energy_bar.tscn")
if scene == null:
failures.append("Could not load energy_bar.tscn")
return
var energy_bar := scene.instantiate()
_expect_bool(energy_bar.get_child_count() == 10, true, "EnergyBar scene should contain ten editor-visible segments before _ready")
energy_bar.free()
func _event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _expect_node(node: Node, path: String, label: String) -> void:
if node.get_node_or_null(path) == null:
failures.append(label)
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_float(actual: float, expected: float, label: String) -> void:
if not is_equal_approx(actual, expected):
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS combo hud")
quit(0)

View File

@@ -4,188 +4,98 @@ var failures: Array[String] = []
func _init() -> void:
var window_script: Script = load("res://scenes/combat/combo_window.gd")
var resolver_script: Script = load("res://scenes/combat/input_resolver.gd")
if window_script == null:
var tracker_script: Script = load("res://scenes/components/combo_window.gd")
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
if tracker_script == null:
failures.append("Missing combo_window.gd")
_finish()
return
if resolver_script == null:
failures.append("Missing input_resolver.gd")
failures.append("Missing action_resolver.gd")
_finish()
return
var window: RefCounted = window_script.new()
window.record("A")
window.record("Ø")
window.record("SP")
_expect_array(window.get_slots(), ["A", "Ø", "SP"], "miss placeholder should be visible in slots")
var window = tracker_script.new()
window.record(&"A")
window.record(&"Ø")
window.record(&"SP")
_expect_array(window.get_slots(), [&"A", &"Ø", &"SP"], "miss placeholder should be visible in slots")
_expect_bool(window.has_pending_clear(), false, "miss placeholder should not request a clear")
_expect_string(window.get_pattern(), "ASP", "miss placeholder should be ignored by pattern")
window.clear("test-reset")
window.record("W")
window.record("A")
window.record("S")
_expect_array(window.get_slots(), ["W", "A", "S"], "three recorded slots")
window.record("SP")
_expect_array(window.get_slots(), ["W", "A", "S", "SP"], "fourth input should be visible before clear")
_expect_string(window.consume_pending_clear_reason(), "full", "fourth input should request full clear")
window.clear("test-reset")
window.record("W")
var resolved: Dictionary = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_w", "W alone skill")
_expect_string(str(resolved.get("animation", "")), "warrior_w", "W should play row 6 animation")
_expect_bool(bool(resolved.get("clear_window", true)), false, "W skill should not clear window")
window.record("A")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_wa", "W+A skill")
_expect_array(window.get_slots(), ["W", "A"], "W+A should be visible before skill clear")
window.clear("test-reset")
_expect_string(window.get_contiguous_pattern(), "SP", "miss should break contiguous pattern")
window.clear(&"test-reset")
window.record("W")
window.record("Ø")
window.record("A")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_a", "miss should break W+A into trailing A only")
window.clear("test-reset")
window.record(&"W")
_expect_skill(resolver_script.resolve(window), &"skill_w", &"warrior_w", &"", false, "W alone")
window.record(&"A")
_expect_skill(resolver_script.resolve(window), &"skill_wa", &"warrior_wa", &"left", false, "W+A")
window.clear(&"test-reset")
window.record("W")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_w", "W alone before mirrored W+D")
window.record("D")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_wd", "W+D should mirror W+A skill")
_expect_string(str(resolved.get("animation", "")), "warrior_wa", "W+D should reuse W+A animation")
_expect_string(str(resolved.get("displacement", "")), "right", "W+D should move right")
_expect_array(window.get_slots(), ["W", "D"], "W+D should be visible before skill clear")
window.clear("test-reset")
window.record("A")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_a", "A skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a", "A should play row 10 animation")
_expect_string(str(resolved.get("displacement", "")), "left", "A should move left")
_expect_bool(bool(resolved.get("clear_window", true)), false, "A skill should not clear window")
window.record("A")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_aa", "A+A skill")
_expect_bool(bool(resolved.get("clear_window", true)), false, "A+A skill should not clear window")
window.record("A")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_aaa", "A+A+A skill")
_expect_bool(bool(resolved.get("clear_window", true)), false, "A+A+A skill should not clear window")
_expect_array(window.get_slots(), ["A", "A", "A"], "A+A+A should be visible before skill clear")
window.clear("test-reset")
window.record(&"W")
window.record(&"D")
_expect_skill(resolver_script.resolve(window), &"skill_wd", &"warrior_wa", &"right", false, "W+D")
window.clear(&"test-reset")
window.record("D")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_d", "D skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a", "D should reuse row 10 animation")
_expect_string(str(resolved.get("displacement", "")), "right", "D should move right")
_expect_bool(bool(resolved.get("clear_window", true)), false, "D skill should not clear window")
window.record("D")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_dd", "D+D should mirror A+A skill")
_expect_string(str(resolved.get("animation", "")), "warrior_aa", "D+D should reuse A+A animation")
window.record("D")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_ddd", "D+D+D should mirror A+A+A skill")
_expect_string(str(resolved.get("animation", "")), "warrior_aaa", "D+D+D should reuse A+A+A animation")
_expect_array(window.get_slots(), ["D", "D", "D"], "D+D+D should be visible before skill clear")
window.clear("test-reset")
window.record(&"A")
_expect_skill(resolver_script.resolve(window), &"skill_a", &"warrior_a", &"left", false, "A")
window.record(&"A")
_expect_skill(resolver_script.resolve(window), &"skill_aa", &"warrior_aa", &"left", false, "A+A")
window.record(&"A")
_expect_skill(resolver_script.resolve(window), &"skill_aaa", &"warrior_aaa", &"left", false, "A+A+A")
window.clear(&"test-reset")
window.record("A")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_a_space", "A+Space skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a_space", "A+Space should play row 17 animation")
_expect_string(str(resolved.get("displacement", "")), "left", "A+Space should move left")
_expect_bool(bool(resolved.get("clear_window", false)), true, "A+Space should clear window")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_a_space_space", "A+Space+Space skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a_space_space", "A+Space+Space should play row 15 animation")
_expect_bool(bool(resolved.get("clear_window", false)), true, "A+Space+Space should clear window")
window.clear("test-reset")
window.record(&"D")
window.record(&"D")
window.record(&"D")
_expect_skill(resolver_script.resolve(window), &"skill_ddd", &"warrior_aaa", &"right", false, "D+D+D")
window.clear(&"test-reset")
window.record("D")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_d_space", "D+Space skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a_space", "D+Space should reuse row 17 animation")
_expect_string(str(resolved.get("displacement", "")), "right", "D+Space should move right")
_expect_bool(bool(resolved.get("clear_window", false)), true, "D+Space should clear window")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_d_space_space", "D+Space+Space skill")
_expect_string(str(resolved.get("animation", "")), "warrior_a_space_space", "D+Space+Space should reuse row 15 animation")
_expect_bool(bool(resolved.get("clear_window", false)), true, "D+Space+Space should clear window")
window.clear("test-reset")
window.record(&"A")
window.record(&"SP")
_expect_skill(resolver_script.resolve(window), &"skill_a_space", &"warrior_a_space", &"left", true, "A+Space")
window.record(&"SP")
_expect_bool(resolver_script.resolve(window) == null, true, "A+Space+Space should not resolve")
window.clear(&"test-reset")
window.record("A")
window.record("A")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_aa_space", "A+A+Space skill")
_expect_bool(bool(resolved.get("clear_window", false)), true, "A+A+Space should clear window")
window.clear("test-reset")
window.record(&"S")
_expect_skill(resolver_script.resolve(window), &"skill_s", &"warrior_s", &"", false, "S")
window.record(&"SP")
_expect_skill(resolver_script.resolve(window), &"skill_s_projectile_1", &"warrior_s_projectile", &"", false, "S+Space")
window.record(&"SP")
_expect_skill(resolver_script.resolve(window), &"skill_s_projectile_2", &"warrior_s_projectile", &"", false, "S+Space+Space")
window.record(&"SP")
_expect_skill(resolver_script.resolve(window), &"skill_s_projectile_3", &"warrior_s_projectile", &"", false, "S+Space+Space+Space")
_expect_array(window.get_slots(), [&"S", &"SP", &"SP", &"SP"], "projectile chain should fill four slots")
_expect_string(str(window.consume_pending_clear_reason()), "full", "fourth slot should request full clear")
window.clear(&"test-reset")
window.record("A")
window.record("D")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_ad_space", "A+D+Space skill")
_expect_string(str(resolved.get("displacement", "")), "right", "A+D+Space should move toward the last direction")
_expect_bool(bool(resolved.get("clear_window", false)), true, "A+D+Space should clear window")
window.clear("test-reset")
window.record(&"S")
window.record(&"Ø")
window.record(&"SP")
_expect_bool(resolver_script.resolve(window) == null, true, "S miss Space should not resolve")
window.clear(&"test-reset")
window.record("D")
window.record("A")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_da_space", "D+A+Space skill")
_expect_string(str(resolved.get("displacement", "")), "left", "D+A+Space should move toward the last direction")
_expect_bool(bool(resolved.get("clear_window", false)), true, "D+A+Space should clear window")
window.clear("test-reset")
window.record(&"A")
window.record(&"D")
window.record(&"S")
window.record(&"SP")
_expect_skill(resolver_script.resolve(window), &"skill_s_projectile_1", &"warrior_s_projectile", &"", false, "S+Space in trailing slots")
resolver_script.clear_cache()
window.free()
window.record("D")
window.record("D")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_dd_space", "D+D+Space skill")
_expect_bool(bool(resolved.get("clear_window", false)), true, "D+D+Space should clear window")
window.clear("test-reset")
window.record("S")
resolved = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "S alone should not resolve a skill")
window.record("Ø")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "S miss Space should not resolve projectile skill")
window.clear("test-reset")
window.record("S")
resolved = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "S alone should not resolve a skill")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_s_projectile_1", "S+Space skill")
_expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space skill should not clear window")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_s_projectile_2", "S+Space+Space skill")
_expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space+Space skill should not clear window")
window.record("SP")
resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_s_projectile_3", "S+Space+Space+Space skill")
_expect_bool(bool(resolved.get("clear_window", true)), false, "S+Space+Space+Space skill should not clear window")
_expect_array(window.get_slots(), ["S", "SP", "SP", "SP"], "S projectile chain should fill four slots before clear")
_finish()
func _expect_skill(skill: Resource, expected_id: StringName, expected_animation: StringName, expected_displacement: StringName, expected_clear: bool, label: String) -> void:
if skill == null:
failures.append("%s should resolve a skill" % label)
return
_expect_string(str(skill.get("id")), str(expected_id), "%s id" % label)
_expect_string(str(skill.get("animation")), str(expected_animation), "%s animation" % label)
_expect_string(str(skill.get("displacement")), str(expected_displacement), "%s displacement" % label)
_expect_bool(bool(skill.get("clear_window")), expected_clear, "%s clear_window" % label)
func _expect_array(actual: Array, expected: Array, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])

View File

@@ -0,0 +1,82 @@
extends SceneTree
var failures: Array[String] = []
var intents: Array = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var component: Node = load("res://scenes/components/input_component.gd").new()
root.add_child(component)
await process_frame
if not component.has_signal("intent_created"):
failures.append("InputComponent should expose intent_created")
else:
component.connect("intent_created", _on_intent_created)
var normal := InputEventKey.new()
normal.pressed = true
normal.keycode = KEY_A
normal.physical_keycode = KEY_A
var handled: bool = component.call("handle_input_event", normal)
_expect_bool(handled, true, "A press should be handled")
_expect_int(intents.size(), 1, "A press should emit one intent")
if intents.size() == 1:
_expect_string(str(intents[0].get("symbol")), "A", "intent symbol")
_expect_string(str(intents[0].get("rhythm_action")), "a", "intent rhythm action")
_expect_string(str(intents[0].get("event_type")), "pressed", "intent event type")
_expect_bool(float(intents[0].get("timestamp_ms")) > 0.0, true, "intent timestamp")
var echo := InputEventKey.new()
echo.pressed = true
echo.echo = true
echo.keycode = KEY_A
echo.physical_keycode = KEY_A
handled = component.call("handle_input_event", echo)
_expect_bool(handled, false, "echo press should not be handled")
_expect_int(intents.size(), 1, "echo press should not emit another intent")
var release := InputEventKey.new()
release.pressed = false
release.keycode = KEY_A
release.physical_keycode = KEY_A
handled = component.call("handle_input_event", release)
_expect_bool(handled, true, "A release should be handled")
_expect_int(intents.size(), 2, "A release should emit one release intent")
if intents.size() == 2:
_expect_string(str(intents[1].get("event_type")), "released", "release event type")
component.free()
_finish()
func _on_intent_created(intent) -> void:
intents.append(intent)
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _expect_string(actual: String, expected: String, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS input component intents")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://dq3egd3nxnund

View File

@@ -11,26 +11,31 @@ var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/characters/player.tscn")
if scene == null:
push_error("Could not load player.tscn")
quit(1)
return
var player: Node = scene.instantiate()
get_root().add_child(player)
await process_frame
var animation_player: AnimationPlayer = player.get_node("AnimationPlayer") as AnimationPlayer
_expect_action_has_key("player_w", KEY_W)
_expect_action_has_key("player_a", KEY_A)
_expect_action_has_key("player_d", KEY_D)
_expect_action_has_key("player_s", KEY_S)
_expect_action_has_key("player_space", KEY_SPACE)
_expect_action_has_key("combo_w", KEY_W)
_expect_action_has_key("combo_a", KEY_A)
_expect_action_has_key("combo_d", KEY_D)
_expect_action_has_key("combo_s", KEY_S)
_expect_action_has_key("combo_space", KEY_SPACE)
_expect_warrior_animation(animation_player, "warrior_idle", 1, 8)
_expect_warrior_animation(animation_player, "warrior_w", 6, 6)
_expect_warrior_animation(animation_player, "warrior_wa", 7, 5)
_expect_warrior_animation(animation_player, "warrior_s", 9, 10)
_expect_warrior_animation(animation_player, "warrior_s", 9, 3)
_expect_warrior_animation(animation_player, "warrior_a", 10, 7)
_expect_warrior_animation(animation_player, "warrior_aa", 11, 5)
_expect_warrior_animation(animation_player, "warrior_aaa", 12, 8)
@@ -42,24 +47,26 @@ func _init() -> void:
_expect_warrior_animation(animation_player, "warrior_a_space_space", 15, 12)
_expect_warrior_animation(animation_player, "warrior_a_space", 17, 10)
_expect_charge_effect(player)
if animation_player.has_animation("player_punch"):
failures.append("Old player_punch animation should be removed")
if animation_player.has_animation("挥砍"):
failures.append("Old slash animation should be removed")
player.call("submit_combo_input", "W")
player.call("submit_combo_input", "W", "perfect")
_expect_string(str(player.get("last_requested_skill_id")), "skill_w", "W alone should request row 6 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_w", "W alone should play warrior_w")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A", "perfect")
await create_timer(0.55).timeout
await physics_frame
_expect_string(str(player.get("last_requested_skill_id")), "skill_wa", "W+A should request row 7 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+A should play warrior_wa")
var projectile := PlayerProjectile.new()
get_root().add_child(projectile)
_expect_projectile_animation(projectile)
projectile.queue_free()
player.queue_free()
_finish()

View File

@@ -1,541 +1,169 @@
extends SceneTree
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
var failures: Array[String] = []
var requested_skills: Array[String] = []
var projectile_requests: Array[Dictionary] = []
var judged_labels: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/characters/player.tscn")
if scene == null:
push_error("Could not load player.tscn")
quit(1)
return
var player: Node = scene.instantiate()
get_root().add_child(player)
var animation_player: AnimationPlayer = player.get_node("AnimationPlayer") as AnimationPlayer
var supports_energy := player.has_method("get_energy") and player.has_method("get_max_energy")
var supports_charge := player.has_method("get_charge") and player.has_method("get_max_charge") and player.has_method("is_charge_active") and player.has_method("is_charge_ready")
if player.has_signal("skill_requested"):
player.connect("skill_requested", _on_skill_requested)
else:
failures.append("Player missing skill_requested signal")
if not player.has_signal("charge_changed"):
failures.append("Player should expose charge_changed signal")
if supports_charge:
_expect_zero(player.call("get_charge"), "charge should start empty")
_expect_bool(player.call("is_charge_ready"), false, "charge should not start ready")
else:
failures.append("Player should expose charge getters")
if not player.has_signal("energy_changed"):
failures.append("Player should expose energy_changed signal")
if not player.has_signal("health_changed"):
failures.append("Player should expose health_changed signal")
if supports_energy:
_expect_int(player.call("get_max_energy"), 10, "energy bar should have ten segments")
_expect_int(player.call("get_energy"), 0, "energy should start empty")
else:
failures.append("Player should expose get_energy and get_max_energy")
if player.has_method("get_health") and player.has_method("get_max_health"):
_expect_int(player.call("get_health"), player.call("get_max_health"), "health should start full")
else:
failures.append("Player should expose get_health and get_max_health")
_expect_action("player_w", KEY_W)
_expect_action("player_a", KEY_A)
_expect_action("player_d", KEY_D)
_expect_action("player_s", KEY_S)
_expect_action("player_space", KEY_SPACE)
var w_event := InputEventKey.new()
w_event.pressed = true
w_event.physical_keycode = KEY_W
player.call("_input", w_event)
w_event.echo = true
player.call("_input", w_event)
_expect_array(player.call("get_combo_slots"), ["W"], "W key press should enter once and ignore echo repeat")
_expect_last_skill("skill_w", "W alone should request row 6 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_w", "W should play row 6 animation")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
var a_event := InputEventKey.new()
a_event.pressed = true
a_event.physical_keycode = KEY_A
var a_release_event := InputEventKey.new()
a_release_event.pressed = false
a_release_event.physical_keycode = KEY_A
player.call("_input", a_event)
_expect_array(player.call("get_combo_slots"), ["A"], "A alone should stay visible in the combo window")
_expect_last_skill("skill_a", "A should request row 10 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "A should play row 10 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "A should lunge left")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
root.add_child(player)
await process_frame
if supports_charge:
player.call("_input", a_event)
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.call("_process", 0.2)
_expect_bool(player.call("is_charge_active"), true, "holding A after its animation should enter charge state")
_expect_string(animation_player.current_animation, "warrior_charge_intro", "holding A charge should start with charge intro animation")
_expect_positive(player.call("get_charge"), "holding A should grow charge")
var charge_effect := player.get_node_or_null("ChargeEffectSprite") as Sprite2D
if charge_effect == null:
failures.append("ChargeEffectSprite missing during A charge test")
else:
_expect_bool(charge_effect.visible, true, "holding A should show charge effect")
requested_skills.clear()
player.call("_input", a_release_event)
_expect_bool(player.call("is_charge_active"), false, "early A release should cancel charge")
_expect_bool(player.call("is_charge_ready"), false, "early A release should not be ready")
_expect_no_skill_requested("early A release should not request charge release skill")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
for node_name: String in ["InputComponent", "ComboWindow", "ActionResolver", "ActionExecutor", "MotionExecutor", "BurstComponent", "ChargeComponent", "EnergyComponent", "HealthComponent", "DamageReceiver", "DamageEmitter"]:
if not player.has_node(node_name):
failures.append("Player missing component %s" % node_name)
for signal_name: String in ["skill_requested", "energy_changed", "health_changed", "charge_changed", "projectile_requested"]:
if not player.has_signal(signal_name):
failures.append("Player missing signal %s" % signal_name)
player.call("_input", a_event)
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.call("_process", player.call("get_max_charge") + 0.1)
_expect_bool(player.call("is_charge_ready"), true, "held A should become ready when charge is full")
_expect_string(animation_player.current_animation, "warrior_charge_loop", "full A hold should keep charge loop animation")
requested_skills.clear()
player.call("_input", a_release_event)
_expect_last_skill("skill_a_charge_release", "full A release should request charge release skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_charge_release", "full A release should play row 13 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "full A release should lunge left")
_expect_bool(player.call("is_charge_active"), false, "full A release should leave charge state")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.connect("skill_requested", _on_skill_requested)
player.connect("projectile_requested", _on_projectile_requested)
_event_bus().connect("action_judged", _on_action_judged)
player.call("_input", a_event)
player.call("_input", a_event)
_expect_array(player.call("get_combo_slots"), ["A", "A"], "two separate A presses should both enter the combo window")
_expect_last_skill("skill_aa", "A+A should request row 11 skill")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
_expect_action("move_left", KEY_A)
_expect_action("move_right", KEY_D)
_expect_action("combo_w", KEY_W)
_expect_action("combo_a", KEY_A)
_expect_action("combo_d", KEY_D)
_expect_action("combo_s", KEY_S)
_expect_action("combo_space", KEY_SPACE)
_expect_bool(InputMap.has_action("player_space"), false, "player_space should be removed")
player.call("_input", a_event)
Input.action_press("player_a")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
Input.action_release("player_a")
_expect_array(player.call("get_combo_slots"), ["A"], "one A key event should not be recorded again by physics polling")
_expect_last_skill("skill_a", "single A key event should still be the last requested skill after physics polling")
_expect_zero((player as CharacterBody2D).velocity.x, "holding consumed A key should not keep sliding after combo input")
var a_release_after_single_hold := InputEventKey.new()
a_release_after_single_hold.pressed = false
a_release_after_single_hold.physical_keycode = KEY_A
player.call("_input", a_release_after_single_hold)
player.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
var d_event := InputEventKey.new()
d_event.pressed = true
d_event.physical_keycode = KEY_D
player.call("_input", d_event)
_expect_array(player.call("get_combo_slots"), ["D"], "D key press should enter the combo window")
_expect_last_skill("skill_d", "D should request mirrored row 10 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "D should reuse row 10 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "D should lunge right")
player.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
_expect_int(player.call("get_max_energy"), 10, "energy bar should have ten segments")
_expect_int(player.call("get_energy"), 0, "energy should start empty")
_expect_int(player.call("get_health"), player.call("get_max_health"), "health should start full")
if supports_charge:
player.call("_input", d_event)
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.call("_process", player.call("get_max_charge") + 0.1)
_expect_string(animation_player.current_animation, "warrior_charge_loop", "full D hold should keep charge loop animation")
var d_release_event := InputEventKey.new()
d_release_event.pressed = false
d_release_event.physical_keycode = KEY_D
requested_skills.clear()
player.call("_input", d_release_event)
_expect_last_skill("skill_d_charge_release", "full D release should request charge release skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_charge_release", "full D release should reuse row 13 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "full D release should lunge right")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_input", d_event)
player.call("_input", d_release_event)
player.call("_input", d_event)
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.call("_process", 0.2)
_expect_array(player.call("get_combo_slots"), ["D", "D"], "second held D should keep D+D in the combo window")
_expect_last_skill("skill_dd", "second held D should trigger D+D skill before charging")
_expect_bool(player.call("is_charge_active"), true, "holding second D after D+D animation should enter charge state")
_expect_string(animation_player.current_animation, "warrior_charge_intro", "holding second D should start with charge intro animation")
_expect_positive(player.call("get_charge"), "holding second D should grow charge after D+D")
requested_skills.clear()
player.call("_input", d_release_event)
_expect_bool(player.call("is_charge_active"), false, "releasing held second D should cancel D+D charge")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_input", a_event)
player.call("_input", a_release_event)
player.call("_input", a_event)
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.call("_process", 0.2)
_expect_array(player.call("get_combo_slots"), ["A", "A"], "second held A should keep A+A in the combo window")
_expect_last_skill("skill_aa", "second held A should trigger A+A skill before charging")
_expect_bool(player.call("is_charge_active"), true, "holding second A after A+A animation should enter charge state")
_expect_string(animation_player.current_animation, "warrior_charge_intro", "holding second A should start with charge intro animation")
_expect_positive(player.call("get_charge"), "holding second A should grow charge after A+A")
requested_skills.clear()
player.call("_input", a_release_event)
_expect_bool(player.call("is_charge_active"), false, "releasing held second A should cancel A+A charge")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_input", d_event)
player.call("_input", d_event)
Input.action_press("player_d")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
Input.action_release("player_d")
_expect_array(player.call("get_combo_slots"), ["D", "D"], "held second D should still record D+D once")
_expect_last_skill("skill_dd", "held second D should request D+D skill")
_expect_zero((player as CharacterBody2D).velocity.x, "holding consumed second D should not slide in idle")
var d_release_after_hold := InputEventKey.new()
d_release_after_hold.pressed = false
d_release_after_hold.physical_keycode = KEY_D
player.call("_input", d_release_after_hold)
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_input", a_event)
player.call("_input", a_event)
Input.action_press("player_a")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
Input.action_release("player_a")
_expect_array(player.call("get_combo_slots"), ["A", "A"], "held second A should still record A+A once")
_expect_last_skill("skill_aa", "held second A should request A+A skill")
_expect_zero((player as CharacterBody2D).velocity.x, "holding consumed second A should not slide in idle")
var a_release_after_hold := InputEventKey.new()
a_release_after_hold.pressed = false
a_release_after_hold.physical_keycode = KEY_A
player.call("_input", a_release_after_hold)
player.get("combo_window").clear("test-reset")
requested_skills.clear()
Input.action_press("player_a")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
_expect_negative((player as CharacterBody2D).velocity.x, "A should move the player left")
_expect_vector(player.get("heading"), Vector2.LEFT, "A should face left")
_expect_array(player.call("get_combo_slots"), [], "physics-only movement polling should not write combo slots")
Input.action_release("player_a")
player.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset")
Input.action_press("player_d")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
_expect_positive((player as CharacterBody2D).velocity.x, "D should move the player right")
_expect_vector(player.get("heading"), Vector2.RIGHT, "D should face right")
Input.action_release("player_d")
player.get("combo_window").clear("test-reset")
var unhandled_s_event := InputEventKey.new()
unhandled_s_event.pressed = true
unhandled_s_event.physical_keycode = KEY_S
player.call("_unhandled_input", unhandled_s_event)
_expect_array(player.call("get_combo_slots"), ["S"], "unhandled S should enter S")
_expect_no_skill_requested("S alone should not request a skill")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "miss")
_expect_array(player.call("get_combo_slots"), ["Ø"], "miss should display Ø in the combo window")
player.get("combo_window").clear("test-reset")
if supports_energy:
player.set("current_energy", 0)
player.call("submit_combo_input", "W", "perfect")
_expect_int(player.call("get_energy"), 0, "W should not add energy")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "good")
_expect_int(player.call("get_energy"), 1, "A skill should add one energy segment")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D", "bad")
_expect_int(player.call("get_energy"), 2, "D skill should add one energy segment")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "miss")
_expect_int(player.call("get_energy"), 2, "miss input should not add energy")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "perfect")
player.call("submit_combo_input", "A", "perfect")
_expect_int(player.call("get_energy"), 4, "A+A skill should add one energy segment")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D", "perfect")
player.call("submit_combo_input", "D", "perfect")
player.call("submit_combo_input", "D", "perfect")
_expect_int(player.call("get_energy"), 7, "D, D+D, and D+D+D skills should each add one energy segment")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "miss")
_expect_int(player.call("get_energy"), 7, "missed A should not add energy")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "SP", "perfect")
_expect_int(player.call("get_energy"), 7, "Space should not add energy")
player.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset")
player.set("current_energy", 9)
player.call("submit_combo_input", "W", "perfect")
_expect_int(player.call("get_energy"), 9, "W should not change energy near cap")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "perfect")
_expect_int(player.call("get_energy"), 10, "A energy reward should cap at ten segments")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_play_skill_animation", "warrior_a", Vector2.LEFT)
player.call("submit_combo_input", "A", "miss")
_expect_array(player.call("get_combo_slots"), ["Ø"], "missed A should display Ø in the combo window")
_expect_no_skill_requested("missed A should not request a skill")
_expect_zero((player as CharacterBody2D).velocity.x, "missed A should stop horizontal lunge")
_expect_int(int(player.get("state")), Character.State.IDLE, "missed A should return to idle state")
_expect_string(animation_player.current_animation, "warrior_idle", "missed A should keep idle animation")
player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("_play_skill_animation", "warrior_a", Vector2.RIGHT)
player.call("submit_combo_input", "D", "miss")
_expect_array(player.call("get_combo_slots"), ["Ø"], "missed D should display Ø in the combo window")
_expect_no_skill_requested("missed D should not request a skill")
_expect_zero((player as CharacterBody2D).velocity.x, "missed D should stop horizontal lunge")
_expect_int(int(player.get("state")), Character.State.IDLE, "missed D should return to idle state")
_expect_string(animation_player.current_animation, "warrior_idle", "missed D should keep idle animation")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "W", "perfect")
_expect_last_judgement("perfect", "W should display Perfect judgement")
_expect_array(player.call("get_combo_slots"), [&"W"], "W should enter the combo window")
_expect_last_skill("skill_w", "W should request skill_w")
_expect_string(str(player.get("current_skill_animation")), "warrior_w", "W should play warrior_w")
_expect_int(player.call("get_energy"), 0, "W should not add energy")
player.get_node("ComboWindow").clear(&"test-reset")
requested_skills.clear()
await _wait_for_action_settle()
player.call("submit_combo_input", "A", "good")
_expect_array(player.call("get_combo_slots"), ["W", "A"], "W+A should stay visible after skill trigger")
_expect_last_skill("skill_wa", "W+A should request row 7 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+A should play row 7 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "W+A should lunge left")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["W", "A"], "W+A should not clear combo window")
player.get("combo_window").clear("test-reset")
_expect_last_judgement("good", "A should display Good judgement")
player.get_node("ChargeComponent").finish_hold(&"A")
_expect_last_skill("skill_a", "A should request skill_a")
_expect_int(player.call("get_energy"), 0, "A should not reward during startup")
await _wait_for_active_phase()
_expect_int(player.call("get_energy"), 1, "A should reward one energy")
_expect_negative((player as CharacterBody2D).velocity.x, "A should lunge left")
player.get_node("ComboWindow").clear(&"test-reset")
await _wait_for_action_settle()
player.call("submit_combo_input", "W", "perfect")
player.call("submit_combo_input", "D", "good")
_expect_array(player.call("get_combo_slots"), ["W", "D"], "W+D should stay visible after skill trigger")
_expect_last_skill("skill_wd", "W+D should request mirrored row 7 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_wa", "W+D should reuse row 7 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "W+D should lunge right")
_expect_vector(player.get("heading"), Vector2.RIGHT, "W+D should face right")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["W", "D"], "W+D should not clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
_expect_array(player.call("get_combo_slots"), ["A", "A", "A"], "A+A+A should stay visible after skill trigger")
_expect_last_skill("skill_aaa", "A+A+A should request row 12 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "A+A+A should play row 12 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "A+A+A should lunge left")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["A", "A", "A"], "A+A+A should not clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
_expect_array(player.call("get_combo_slots"), ["A", "A", "A", "A"], "fourth A should still fill the old four-slot window before clear")
_expect_last_skill("skill_a", "fourth A after A+A+A should play normal A animation")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "fourth A should fall back to row 10 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "fourth A should lunge left as a normal A")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "fourth A full window should clear after display")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
_expect_array(player.call("get_combo_slots"), ["D", "D", "D"], "D+D+D should stay visible after skill trigger")
_expect_last_skill("skill_ddd", "D+D+D should request mirrored row 12 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "D+D+D should reuse row 12 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "D+D+D should lunge right")
_expect_vector(player.get("heading"), Vector2.RIGHT, "D+D+D should face right")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["D", "D", "D"], "D+D+D should not clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
_expect_array(player.call("get_combo_slots"), ["D", "D", "D", "D"], "fourth D should still fill the old four-slot window before clear")
_expect_last_skill("skill_d", "fourth D after D+D+D should play normal D animation")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "fourth D should fall back to row 10 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "fourth D should lunge right as a normal D")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "fourth D full window should clear after display")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["A", "SP"], "A+Space should be visible before skill clear")
_expect_last_skill("skill_a_space", "A+Space should request row 17 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a_space", "A+Space should play row 17 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "A+Space should lunge left")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "A+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["D", "SP"], "D+Space should be visible before skill clear")
_expect_last_skill("skill_d_space", "D+Space should request mirrored row 17 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a_space", "D+Space should reuse row 17 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "D+Space should lunge right")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "D+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "SP")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["A", "SP", "SP"], "A+Space+Space should cancel the pending A+Space clear and stay visible before its own clear")
_expect_last_skill("skill_a_space_space", "A+Space+Space should request row 15 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a_space_space", "A+Space+Space should play row 15 animation")
_expect_negative((player as CharacterBody2D).velocity.x, "A+Space+Space should lunge left")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "A+Space+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "SP")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["D", "SP", "SP"], "D+Space+Space should cancel the pending D+Space clear and stay visible before its own clear")
_expect_last_skill("skill_d_space_space", "D+Space+Space should request mirrored row 15 skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_a_space_space", "D+Space+Space should reuse row 15 animation")
_expect_positive((player as CharacterBody2D).velocity.x, "D+Space+Space should lunge right")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "D+Space+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "A")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["A", "A", "SP"], "A+A+Space should be visible before skill clear")
_expect_last_skill("skill_aa_space", "A+A+Space should request clear skill")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "A+A+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "D")
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["D", "D", "SP"], "D+D+Space should be visible before skill clear")
_expect_last_skill("skill_dd_space", "D+D+Space should request clear skill")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "D+D+Space should clear combo window")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D", "bad")
_expect_last_judgement("bad", "D should display Bad judgement")
player.get_node("ChargeComponent").finish_hold(&"D")
_expect_last_skill("skill_d", "D should request skill_d")
await _wait_for_active_phase()
_expect_int(player.call("get_energy"), 2, "D should reward one energy")
_expect_positive((player as CharacterBody2D).velocity.x, "D should lunge right")
player.get_node("ComboWindow").clear(&"test-reset")
requested_skills.clear()
player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["SP"], "Space should be visible before space clear")
_expect_no_skill_requested("Space alone should not request a skill")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "Space should clear combo window")
await _wait_for_action_settle()
_reset_player_action_state(player)
_press_release_symbol(player, &"D")
await _wait_for_cancel_consumption()
_press_release_symbol(player, &"D")
await _wait_for_cancel_consumption()
player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed"))
_expect_last_skill("skill_ddd", "D+D+D should request the third D skill")
_expect_string(str(player.get("current_skill_animation")), "warrior_aaa", "D+D+D should start warrior_aaa")
player.call("_on_input_intent_created", _perfect_intent(&"D", &"released"))
await _wait_for_cancel_consumption()
_expect_array(player.call("get_combo_slots"), [&"D", &"D", &"D"], "D+D+D should keep the three input slots")
_expect_int(int(player.get("state")), Character.State.IDLE, "D+D+D should return player presentation to idle when the action ends")
_expect_string(_current_animation(player), "warrior_idle", "D+D+D should not leave warrior_aaa stuck after the action ends")
player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed"))
_expect_array(player.call("get_combo_slots"), [&"D", &"D", &"D", &"D"], "Fourth D should fill the four-slot window before clear")
_expect_last_skill("skill_d", "Fourth D after D+D+D should fall back to normal D")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "Fourth D should play normal D animation")
_expect_string(str(player.get("heading")), str(Vector2.RIGHT), "Fourth D should face right")
player.call("_on_input_intent_created", _perfect_intent(&"D", &"released"))
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [], "Fourth D full window should clear after display")
_reset_player_action_state(player)
player.call("_on_input_intent_created", _perfect_intent(&"A", &"pressed"))
player.call("_on_input_intent_created", _perfect_intent(&"A", &"released"))
player.call("_on_input_intent_created", _perfect_intent(&"D", &"pressed"))
_expect_array(player.call("get_combo_slots"), [&"A"], "A then immediate D should keep D outside the window during A startup")
_expect_string(str(player.get("heading")), str(Vector2.LEFT), "Pending D should not change heading before it is consumed")
player.call("_on_input_intent_created", _perfect_intent(&"D", &"released"))
await _wait_for_cancel_consumption()
_expect_array(player.call("get_combo_slots"), [&"A", &"D"], "A then D should enter the window only when the phase allows it")
_expect_last_skill("skill_d", "A then D unresolved prefix should fall back to normal D")
_expect_string(str(player.get("current_skill_animation")), "warrior_a", "A then D fallback should play normal D animation")
_expect_string(str(player.get("heading")), str(Vector2.RIGHT), "A then D fallback should face right")
await _wait_for_action_settle()
_expect_int(int(player.get("state")), Character.State.IDLE, "A then D fallback should settle back to idle")
_expect_string(_current_animation(player), "warrior_idle", "A then D fallback should not leave the previous presentation stuck")
_reset_player_action_state(player)
player.get_node("EnergyComponent").set_current(2)
player.call("submit_combo_input", "S", "miss")
_expect_last_judgement("miss", "S should display Miss judgement")
_expect_array(player.call("get_combo_slots"), [&"Ø"], "miss should enter the combo window as an explicit placeholder")
_expect_no_skill_requested("miss should not request a skill")
_expect_int(player.call("get_energy"), 2, "miss should not change energy")
await create_timer(0.4).timeout
await process_frame
_expect_array(player.call("get_combo_slots"), [&"Ø"], "miss should not clear the combo window by itself")
player.get_node("ComboWindow").clear(&"test-reset")
await _wait_for_action_settle()
player.call("submit_combo_input", "A", "perfect")
player.get_node("ChargeComponent").finish_hold(&"A")
player.call("submit_combo_input", "SP", "perfect")
_expect_array(player.call("get_combo_slots"), [&"A"], "A+Space should wait for cancel window before adding Space")
await _wait_for_cancel_consumption()
_expect_last_skill("skill_a_space", "A+Space should request clear-window skill")
_expect_array(player.call("get_combo_slots"), [&"A", &"SP"], "A+Space should stay visible before clear")
await _wait_for_action_settle()
_expect_array(player.call("get_combo_slots"), [], "A+Space should clear when the action finishes")
player.get_node("ComboWindow").clear(&"test-reset")
await _wait_for_action_settle()
player.get_node("EnergyComponent").set_current(3)
projectile_requests.clear()
requested_skills.clear()
var space_event := InputEventKey.new()
space_event.pressed = true
space_event.physical_keycode = KEY_SPACE
player.set("state", Character.State.IDLE)
player.set("current_skill_animation", "")
animation_player.play("warrior_idle")
player.call("_input", space_event)
Input.action_press("player_space")
Input.action_press("jump")
player.call("handle_input")
Input.action_release("jump")
Input.action_release("player_space")
_expect_int(int(player.get("state")), Character.State.IDLE, "direct Space should keep idle state")
_expect_string(animation_player.current_animation, "warrior_idle", "direct Space should keep idle animation")
_expect_no_skill_requested("direct Space key should not request a skill")
player.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset")
if supports_energy:
player.set("current_energy", 3)
player.call("submit_combo_input", "S", "perfect")
player.call("submit_combo_input", "SP", "perfect")
_expect_array(player.call("get_combo_slots"), [&"S"], "S+Space should wait for cancel window before adding Space")
await _wait_for_cancel_consumption()
_expect_last_skill("skill_s_projectile_1", "S+Space should request projectile skill")
_expect_projectile_count(1, "S+Space should fire one projectile")
if supports_energy:
_expect_int(player.call("get_energy"), 0, "S+Space should spend three energy without input rewards")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP"], "S+Space should not clear combo window")
if supports_energy:
player.set("current_energy", 2)
player.call("submit_combo_input", "SP", "perfect")
_expect_last_skill("skill_s_projectile_2", "S+Space+Space should request projectile skill")
_expect_projectile_count(2, "Second Space should fire another projectile")
if supports_energy:
_expect_int(player.call("get_energy"), 0, "S+Space+Space should spend two energy without Space reward")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP", "SP"], "S+Space+Space should not clear combo window")
if supports_energy:
player.set("current_energy", 1)
player.call("submit_combo_input", "SP", "perfect")
_expect_last_skill("skill_s_projectile_3", "S+Space+Space+Space should request projectile skill")
_expect_projectile_count(3, "Third Space should fire another projectile")
if supports_energy:
_expect_int(player.call("get_energy"), 0, "S+Space+Space+Space should spend one energy without Space reward")
_expect_array(player.call("get_combo_slots"), ["S", "SP", "SP", "SP"], "projectile chain should fill four slots before clear")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "projectile chain should clear combo window because four slots are full")
await _wait_for_active_phase()
_expect_int(player.call("get_energy"), 0, "S+Space should spend three energy")
_expect_int(projectile_requests.size(), 1, "S+Space should emit one projectile request")
await _wait_for_action_settle()
if supports_energy:
requested_skills.clear()
player.set("current_energy", 0)
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "bad")
player.call("submit_combo_input", "SP", "bad")
_expect_no_skill_requested("S+Space should not execute when energy is insufficient")
_expect_projectile_count(3, "insufficient energy should not fire another projectile")
_expect_int(player.call("get_energy"), 0, "insufficient projectile attempt should leave energy at zero")
player.get("combo_window").clear("test-reset")
player.get_node("ChargeComponent").cancel()
Input.action_press(&"move_left")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input")
Input.action_release(&"move_left")
_expect_negative((player as CharacterBody2D).velocity.x, "move_left should move the player left")
requested_skills.clear()
if supports_energy:
player.set("current_energy", 10)
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "perfect")
player.call("submit_combo_input", "A", "miss")
player.call("submit_combo_input", "SP", "perfect")
_expect_array(player.call("get_combo_slots"), ["S", "Ø", "SP"], "miss should remain visible between S and Space")
_expect_no_skill_requested("S miss Space should not execute projectile skill")
_expect_projectile_count(3, "S miss Space should not fire another projectile")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "S miss Space should clear as a normal Space input")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "W")
player.call("submit_combo_input", "W")
player.call("submit_combo_input", "W")
player.call("submit_combo_input", "W")
_expect_array(player.call("get_combo_slots"), ["W", "W", "W", "W"], "four non-skill inputs should be visible before clear")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "four non-skill inputs should clear combo window")
player.queue_free()
_finish()
@@ -565,6 +193,20 @@ func _expect_no_skill_requested(label: String) -> void:
failures.append("%s: expected no skill, got %s" % [label, requested_skills[requested_skills.size() - 1]])
func _expect_last_judgement(expected: String, label: String) -> void:
if judged_labels.is_empty():
failures.append("%s: no judgement displayed" % label)
return
var actual := judged_labels[judged_labels.size() - 1]
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_array(actual: Array, expected: Array, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_string(actual: String, expected: String, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
@@ -575,20 +217,7 @@ func _expect_int(actual: int, expected: int, label: String) -> void:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _expect_projectile_count(expected: int, label: String) -> void:
var actual := _count_projectiles(get_root())
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _count_projectiles(node: Node) -> int:
var total := 1 if node.is_in_group("player_projectiles") else 0
for child: Node in node.get_children():
total += _count_projectiles(child)
return total
func _expect_array(actual: Array, expected: Array, label: String) -> void:
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
@@ -603,25 +232,80 @@ func _expect_positive(actual: float, label: String) -> void:
failures.append("%s: expected positive x velocity, got %.3f" % [label, actual])
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_zero(actual: float, label: String) -> void:
if not is_zero_approx(actual):
failures.append("%s: expected zero x velocity, got %.3f" % [label, actual])
func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void:
if not actual.is_equal_approx(expected):
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _on_skill_requested(skill_id: String) -> void:
requested_skills.append(skill_id)
func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2) -> void:
projectile_requests.append({
"scene": projectile_scene,
"position": spawn_position,
"direction": direction,
})
func _on_action_judged(_action_name: StringName, rating: Dictionary) -> void:
judged_labels.append(str(rating.get("label", "")))
func _event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _reset_player_action_state(player: Node) -> void:
player.get_node("ComboWindow").clear(&"test-reset")
player.get_node("ActionController").call("_reset_to_idle")
player.get_node("ChargeComponent").cancel()
player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0)
player.set("velocity", Vector2.ZERO)
player.set("heading", Vector2.RIGHT)
player.set("current_skill_animation", "warrior_idle")
var animation_player := player.get_node("AnimationPlayer") as AnimationPlayer
if animation_player != null and animation_player.has_animation("warrior_idle"):
animation_player.play("warrior_idle")
requested_skills.clear()
func _press_release_symbol(player: Node, symbol: StringName) -> void:
player.call("_on_input_intent_created", _perfect_intent(symbol, &"pressed"))
player.call("_on_input_intent_created", _perfect_intent(symbol, &"released"))
func _perfect_intent(symbol: StringName, event_type: StringName) -> RefCounted:
var rhythm_action: StringName = symbol.to_lower()
if symbol == &"SP":
rhythm_action = &"space"
var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, event_type, float(Time.get_ticks_msec()))
intent.judgement = {"label": "perfect", "diff": 0.0}
return intent
func _current_animation(player: Node) -> String:
var animation_player := player.get_node("AnimationPlayer") as AnimationPlayer
return animation_player.current_animation if animation_player != null else ""
func _wait_for_active_phase() -> void:
await create_timer(0.2).timeout
await physics_frame
func _wait_for_cancel_consumption() -> void:
await create_timer(0.55).timeout
await physics_frame
func _wait_for_action_settle() -> void:
await create_timer(0.7).timeout
await physics_frame
func _finish() -> void:
if failures.is_empty():
print("PASS player combo input")

View File

@@ -0,0 +1,236 @@
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
_check_autoloads()
_check_chart_layer()
_check_action_data()
_check_combo_window()
_check_action_resolver()
_check_player_components()
_check_no_legacy_runtime_architecture()
_check_combat_manager()
_finish()
func _check_autoloads() -> void:
_expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager should be registered as an autoload")
if ProjectSettings.has_setting("autoload/RhythmManager"):
_expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload should point at autoload/rhythm_manager.gd")
_expect(ProjectSettings.has_setting("autoload/CombatManager"), "CombatManager should be registered as an autoload")
if ProjectSettings.has_setting("autoload/CombatManager"):
_expect(str(ProjectSettings.get_setting("autoload/CombatManager")).contains("res://autoload/combat_manager.gd"), "CombatManager autoload should point at autoload/combat_manager.gd")
var rhythm_script: Script = load("res://autoload/rhythm_manager.gd")
_expect(rhythm_script != null, "autoload/rhythm_manager.gd should load")
if rhythm_script != null:
var rhythm: Node = rhythm_script.new() as Node
_expect(_has_property(rhythm, "beat_time"), "RhythmManager should expose beat_time")
_expect(_has_property(rhythm, "beat_index"), "RhythmManager should expose beat_index")
_expect(_has_property(rhythm, "judgement_scale"), "RhythmManager should expose judgement_scale")
_expect(rhythm.has_method("judge"), "RhythmManager should expose judge(timestamp)")
_expect(rhythm.has_method("judge_action"), "RhythmManager should expose judge_action(action_name)")
rhythm.free()
func _check_chart_layer() -> void:
for path: String in [
"res://resources/chart_event.gd",
"res://resources/chart_track.gd",
"res://resources/beat_chart.gd",
"res://scenes/chart/chart_runner.gd",
]:
_expect(load(path) != null, "%s should load" % path)
var bus_script: Script = load("res://autoload/event_bus.gd")
_expect(bus_script != null, "EventBus should load for chart signal checks")
if bus_script != null:
var bus: Node = bus_script.new()
_expect(bus.has_signal("chart_event_upcoming"), "EventBus should expose chart_event_upcoming")
_expect(bus.has_signal("chart_event_triggered"), "EventBus should expose chart_event_triggered")
_expect(bus.has_signal("chart_reset"), "EventBus should expose chart_reset")
bus.free()
func _check_action_data() -> void:
var action_script: Script = load("res://resources/action_data.gd")
_expect(action_script != null, "resources/action_data.gd should load")
if action_script == null:
return
var action: Resource = action_script.new() as Resource
for property_name: String in [
"id",
"display_name",
"input_pattern",
"required_state",
"base_cost",
"damage_mult",
"move_mult_x",
"move_mult_y",
"action_beats",
"hit_type",
"range",
"target_type",
"armor_level",
"clear_window",
"can_chain",
"special",
"startup_beats",
"active_beats",
"recovery_beats",
"cancel_from",
]:
_expect(_has_property(action, property_name), "ActionData should expose %s" % property_name)
_expect(DirAccess.open("res://resources/actions") != null, "resources/actions should exist")
var action_dir := DirAccess.open("res://resources/actions")
if action_dir != null:
var action_count := 0
for file_name: String in action_dir.get_files():
if file_name.ends_with(".tres"):
action_count += 1
_expect(action_count >= 20, "resources/actions should contain the full migrated action set")
func _check_combo_window() -> void:
var combo_script: Script = load("res://scenes/components/combo_window.gd")
_expect(combo_script != null, "scenes/components/combo_window.gd should load")
if combo_script == null:
return
var combo: Node = combo_script.new() as Node
root.add_child(combo)
await process_frame
combo.call("record", &"A")
_expect(combo.call("get_slots") == [&"A"], "ComboWindow should record explicit inputs")
combo.call("record", &"Ø")
_expect(combo.call("get_slots") == [&"A", &"Ø"], "ComboWindow should keep explicit miss placeholders")
combo.free()
func _check_action_resolver() -> void:
var resolver_script: Script = load("res://scenes/combat/action_resolver.gd")
_expect(resolver_script != null, "scenes/combat/action_resolver.gd should load")
if resolver_script == null:
return
if resolver_script.has_method("clear_cache"):
resolver_script.clear_cache()
var action: Resource = resolver_script.resolve_pattern("A", null, {"state": &"ground"})
_expect(action != null, "ActionResolver should resolve A from resources/actions")
if action != null:
_expect(str(action.get("id")) == "skill_a", "ActionResolver should resolve A to skill_a")
_expect(_has_property(action, "action_beats"), "Resolved action should be full ActionData")
_expect(str(action.resource_path).contains("res://resources/actions/"), "ActionResolver should resolve migrated ActionData resources, not legacy skill resources")
_expect(resolver_script.has_method("space_priority_labels"), "ActionResolver should expose ordered Space priority labels")
func _check_player_components() -> void:
var player_scene: PackedScene = load("res://scenes/characters/player.tscn")
_expect(player_scene != null, "player.tscn should load")
if player_scene == null:
return
var player := player_scene.instantiate()
root.add_child(player)
await process_frame
for node_path: String in [
"ComboWindow",
"ActionResolver",
"ActionExecutor",
"MotionExecutor",
"BurstComponent",
"ChargeComponent",
"EnergyComponent",
"DamageEmitter",
"DamageReceiver",
]:
_expect(player.get_node_or_null(node_path) != null, "Player should own %s component" % node_path)
_expect(player.get_node_or_null("ComboTracker") == null, "Player should not keep legacy ComboTracker component")
player.free()
func _check_no_legacy_runtime_architecture() -> void:
var player_source := _read_text("res://scenes/characters/player.gd")
_expect(not player_source.contains("InputResolver"), "Player should not reference legacy InputResolver")
_expect(not player_source.contains("charge_duration"), "Player charge timing should live in a component")
_expect(not player_source.contains("func _update_charge"), "Player should not own charge update loop")
_expect(not player_source.contains("attack_lunge_time_left ="), "Player should not set lunge timers directly")
_expect(not player_source.contains("energy_component.spend"), "Player should delegate action costs to ActionExecutor")
_expect(not player_source.contains("damage_emitter.configure_hit"), "Player should delegate damage context setup to ActionExecutor")
_expect(not player_source.contains("resolve_cost"), "Player should not resolve action costs directly")
_expect(player_source.contains("intent_created.connect"), "Player should connect InputComponent intents to ActionController")
_expect(not player_source.contains("action_resolver.resolve_window"), "Player should not resolve actions directly")
_expect(not player_source.contains("action_executor.execute"), "Player should not execute actions directly")
_expect(not player_source.contains("func _record_combo_symbol"), "Player should not own combo symbol adjudication")
_expect(not player_source.contains("func _execute_combo_skill"), "Player should not own action execution")
_expect(not player_source.contains("func judge_rhythm_action"), "Player should not own rhythm judgement")
_expect(player_source.contains("func _on_action_started"), "Player should present actions from ActionController")
var resolver_source := _read_text("res://scenes/combat/action_resolver.gd")
_expect(not resolver_source.contains("resources/skills"), "ActionResolver should not load legacy resources/skills")
_expect(FileAccess.file_exists("res://scenes/combat/input_resolver.gd") == false, "Legacy InputResolver file should be removed")
_expect(FileAccess.file_exists("res://resources/skill_data.gd") == false, "Legacy SkillData script should be removed")
var damage_source := _read_text("res://scenes/components/damage_emitter.gd")
_expect(damage_source.contains("resolve_damage"), "DamageEmitter should route damage through CombatManager")
func _check_combat_manager() -> void:
var combat_script: Script = load("res://autoload/combat_manager.gd")
_expect(combat_script != null, "autoload/combat_manager.gd should load")
if combat_script == null:
return
var combat: Node = combat_script.new() as Node
_expect(combat.has_method("resolve_damage"), "CombatManager should expose resolve_damage")
_expect(combat.has_method("resolve_cost"), "CombatManager should expose resolve_cost")
_expect(combat.has_method("resolve_move"), "CombatManager should expose resolve_move")
var action_script: Script = load("res://resources/action_data.gd")
if action_script != null and combat.has_method("resolve_damage"):
var action: Resource = action_script.new() as Resource
action.set("damage_mult", 2.0)
var damage := float(combat.call("resolve_damage", 10.0, action, {"damage_mult": 1.5}, null, null))
_expect(is_equal_approx(damage, 30.0), "CombatManager should apply action and judgement damage multipliers")
combat.free()
func _rhythm_manager() -> Node:
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null:
return rhythm
var rhythm_script: Script = load("res://autoload/rhythm_manager.gd")
if rhythm_script == null:
return null
rhythm = rhythm_script.new()
rhythm.name = "RhythmManager"
root.add_child(rhythm)
return rhythm
func _has_property(object: Object, property_name: String) -> bool:
for property: Dictionary in object.get_property_list():
if str(property.get("name", "")) == property_name:
return true
return false
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _read_text(path: String) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("Could not read %s" % path)
return ""
return file.get_as_text()
func _finish() -> void:
if failures.is_empty():
print("PASS rhythm action architecture")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://2fdtunkqclaw

View File

@@ -9,30 +9,24 @@ func _init() -> void:
push_error("Could not load main.tscn")
quit(1)
return
var main: Node = scene.instantiate()
var main := scene.instantiate()
if main.get_script() == null:
failures.append("Main script failed to load")
var required_nodes := [
"RhythmConductor",
"RhythmFeedback",
"Player",
]
for node_name: String in required_nodes:
if not main.has_node(node_name):
failures.append("Missing required node: %s" % node_name)
for node_path: String in ["Stage", "Stage/ActorsContainer/Player", "ChartRunner", "UI"]:
if not main.has_node(node_path):
failures.append("Missing required node: %s" % node_path)
if main.has_node("RhythmConductor"):
var conductor: Node = main.get_node("RhythmConductor")
if not conductor.has_method("judge_action"):
failures.append("RhythmConductor missing judge_action")
if not conductor is AudioStreamPlayer:
failures.append("RhythmConductor should be an AudioStreamPlayer")
elif (conductor as AudioStreamPlayer).stream == null:
failures.append("RhythmConductor should have a music stream")
failures.append("RhythmConductor should be promoted to RhythmManager autoload")
if not ProjectSettings.has_setting("autoload/RhythmManager"):
failures.append("RhythmManager autoload should be configured")
main.free()
_finish()
func _finish() -> void:
if failures.is_empty():
print("PASS rhythm scene")
quit(0)

View File

@@ -4,34 +4,53 @@ var failures: Array[String] = []
func _init() -> void:
var scene: PackedScene = load("res://scenes/main/main.tscn")
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
if scene == null:
push_error("Could not load main.tscn")
push_error("Could not load main_ui.tscn")
quit(1)
return
var main: Node = scene.instantiate()
var required_nodes := [
"RhythmFeedback/RhythmTrack",
"RhythmFeedback/RhythmTrack/LeftRod",
"RhythmFeedback/RhythmTrack/RightRod",
"RhythmFeedback/RhythmTrack/CenterBase",
"RhythmFeedback/RhythmTrack/CenterFlash",
"RhythmFeedback/RhythmTrack/LeftMover",
"RhythmFeedback/RhythmTrack/RightMover",
"RhythmFeedback/RhythmTrack/BlueBallLeft1",
"RhythmFeedback/RhythmTrack/BlueBallRight1",
"RhythmFeedback/JudgementLabel",
]
for node_path: String in required_nodes:
if not main.has_node(node_path):
var ui := scene.instantiate()
root.add_child(ui)
await process_frame
for node_path: String in [
"RhythmTrack",
"RhythmTrack/JudgementLabel",
"RhythmTrack/ChartMarkerContainer",
"ComboWindow",
"StatusBars/HealthBar",
"StatusBars/EnergyBar",
"StatusBars/ChargeBar",
]:
if not ui.has_node(node_path):
failures.append("Missing rhythm UI node: %s" % node_path)
if main.has_method("_update_rhythm_track") == false:
failures.append("Main script missing _update_rhythm_track")
main.free()
var bus := _event_bus()
bus.emit_signal("action_judged", &"skill_a", {"label": "perfect", "diff": 0.0})
await process_frame
var label := ui.get_node("RhythmTrack/JudgementLabel") as Label
if not label.text.contains("SKILL_A"):
failures.append("RhythmTrack should render EventBus judgement text")
ui.free()
_finish()
func _event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _finish() -> void:
if failures.is_empty():
print("PASS rhythm ui")
quit(0)

View File

@@ -8,34 +8,35 @@ func _init() -> void:
func _run() -> void:
var scene: PackedScene = load("res://scenes/main/main.tscn")
var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn")
if scene == null:
push_error("Could not load main.tscn")
push_error("Could not load rhythm_track.tscn")
quit(1)
return
var main: Node = scene.instantiate()
var initial_left_mover: Control = main.get_node("RhythmFeedback/RhythmTrack/LeftMover")
var initial_right_mover: Control = main.get_node("RhythmFeedback/RhythmTrack/RightMover")
var initial_center_base: Control = main.get_node("RhythmFeedback/RhythmTrack/CenterBase")
var initial_center_flash: Control = main.get_node("RhythmFeedback/RhythmTrack/CenterFlash")
var expected_left_start := _control_center(initial_left_mover)
var expected_right_start := _control_center(initial_right_mover)
var expected_track_center := _control_center(initial_center_base)
var expected_mover_size := initial_left_mover.size
var expected_center_flash_size := initial_center_flash.size
root.add_child(main)
var track := scene.instantiate() as Control
root.add_child(track)
await process_frame
_expect_vector("left_mover_start", main.get("left_mover_start"), expected_left_start)
_expect_vector("right_mover_start", main.get("right_mover_start"), expected_right_start)
_expect_vector("track_center", main.get("track_center"), expected_track_center)
_expect_vector("mover_size", main.get("mover_size"), expected_mover_size)
_expect_vector("center_flash_size", main.get("center_flash_size"), expected_center_flash_size)
main.free()
_expect_float(track.anchor_left, 0.5, "RhythmTrack should stay centered")
_expect_float(track.anchor_right, 0.5, "RhythmTrack should stay centered")
_expect_bool(track.has_node("JudgementLabel"), true, "RhythmTrack should own its judgement label")
track.free()
_finish()
func _expect_float(actual: float, expected: float, label: String) -> void:
if not is_equal_approx(actual, expected):
failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual])
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS rhythm ui layout")
quit(0)
@@ -43,18 +44,3 @@ func _run() -> void:
for failure: String in failures:
push_error(failure)
quit(1)
func _control_center(control: Control) -> Vector2:
return Vector2(
(control.offset_left + control.offset_right) * 0.5,
(control.offset_top + control.offset_bottom) * 0.5
)
func _expect_vector(label: String, actual: Variant, expected: Vector2) -> void:
if not actual is Vector2:
failures.append("%s should be cached as Vector2, got %s" % [label, typeof(actual)])
return
if not (actual as Vector2).is_equal_approx(expected):
failures.append("%s should match scene layout: expected %s got %s" % [label, expected, actual])

View File

@@ -0,0 +1,109 @@
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var scene: PackedScene = load("res://scenes/ui/main_ui.tscn")
if scene == null:
push_error("Could not load main_ui.tscn")
quit(1)
return
var ui := scene.instantiate()
root.add_child(ui)
await process_frame
var track := ui.get_node_or_null("RhythmTrack") as Control
if track == null:
failures.append("Missing RhythmTrack")
_finish(ui)
return
_expect_node(track, "LeftMover", "RhythmTrack should keep the animated left mover")
_expect_node(track, "RightMover", "RhythmTrack should keep the animated right mover")
_expect_node(track, "CenterFlash", "RhythmTrack should keep the center beat flash")
var bus := _event_bus()
bus.emit_signal("beat_ticked", 1)
await process_frame
var center_flash := track.get_node_or_null("CenterFlash") as CanvasItem
if center_flash != null:
_expect_bool(center_flash.modulate.a > 0.5, true, "CenterFlash should become visible on beat")
var marker_container := track.get_node_or_null("ChartMarkerContainer") as Control
if marker_container == null:
failures.append("RhythmTrack should include ChartMarkerContainer")
else:
var event_script: Script = load("res://resources/chart_event.gd")
var event: Resource = event_script.new()
event.set("event_type", &"enemy_attack_active")
bus.emit_signal("chart_event_upcoming", event, 0.5)
await process_frame
_expect_bool(marker_container.get_child_count() > 0, true, "Chart upcoming event should create a rhythm marker")
bus.emit_signal("action_judged", &"skill_a", {"label": "perfect", "diff": 0.0, "color": Color("00f2ff")})
await process_frame
var label := track.get_node_or_null("JudgementLabel") as Label
if label != null:
_expect_bool(label.scale.x > 1.0, true, "JudgementLabel should pulse on judgement")
_expect_bool(label.modulate.is_equal_approx(Color("00f2ff")), true, "JudgementLabel should use judgement color")
var combo_window := ui.get_node_or_null("ComboWindow") as Control
if combo_window == null:
failures.append("Missing ComboWindow")
else:
bus.emit_signal("combo_updated", [&"A"])
await process_frame
var first_slot := combo_window.get_child(0) as Control
_expect_bool(first_slot.scale.x > 1.0, true, "Combo slot should pulse when filled")
bus.emit_signal("combo_cleared", &"full")
await process_frame
_expect_bool(first_slot.scale.x > 1.0, true, "Combo slots should flash when cleared")
var charge_bar := ui.get_node_or_null("StatusBars/ChargeBar") as ProgressBar
if charge_bar == null:
failures.append("Missing ChargeBar")
else:
bus.emit_signal("player_charge_changed", 1.1, 1.1, true, true)
await create_timer(0.05).timeout
var first_alpha := charge_bar.modulate.a
await create_timer(0.05).timeout
var second_alpha := charge_bar.modulate.a
_expect_bool(not is_equal_approx(first_alpha, second_alpha), true, "ChargeBar should flash while ready")
_finish(ui)
func _event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _expect_node(node: Node, path: String, label: String) -> void:
if node.get_node_or_null(path) == null:
failures.append(label)
func _expect_bool(actual: bool, expected: bool, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _finish(ui: Node) -> void:
ui.free()
if failures.is_empty():
print("PASS ui animation regression")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

@@ -0,0 +1 @@
uid://sdwicy8mmpkr