Refactor rhythm action architecture
This commit is contained in:
211
tests/test_action_controller_flow.gd
Normal file
211
tests/test_action_controller_flow.gd
Normal 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)
|
||||
1
tests/test_action_controller_flow.gd.uid
Normal file
1
tests/test_action_controller_flow.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfrcdxpy2fwkd
|
||||
158
tests/test_architecture_refactor.gd
Normal file
158
tests/test_architecture_refactor.gd
Normal 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)
|
||||
1
tests/test_architecture_refactor.gd.uid
Normal file
1
tests/test_architecture_refactor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dfs2gbjkn4png
|
||||
131
tests/test_chart_layer.gd
Normal file
131
tests/test_chart_layer.gd
Normal 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)
|
||||
1
tests/test_chart_layer.gd.uid
Normal file
1
tests/test_chart_layer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bphi58hvfrrnl
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
82
tests/test_input_component_intents.gd
Normal file
82
tests/test_input_component_intents.gd
Normal 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)
|
||||
1
tests/test_input_component_intents.gd.uid
Normal file
1
tests/test_input_component_intents.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dq3egd3nxnund
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
236
tests/test_rhythm_action_architecture.gd
Normal file
236
tests/test_rhythm_action_architecture.gd
Normal 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)
|
||||
1
tests/test_rhythm_action_architecture.gd.uid
Normal file
1
tests/test_rhythm_action_architecture.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://2fdtunkqclaw
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
109
tests/test_ui_animation_regression.gd
Normal file
109
tests/test_ui_animation_regression.gd
Normal 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)
|
||||
1
tests/test_ui_animation_regression.gd.uid
Normal file
1
tests/test_ui_animation_regression.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://sdwicy8mmpkr
|
||||
Reference in New Issue
Block a user