Add combat combo gameplay

This commit is contained in:
wxm
2026-07-02 05:11:24 -07:00
parent 8c0c5e5067
commit 67db812de4
32 changed files with 3297 additions and 205 deletions

87
tests/test_combo_hud.gd Normal file
View File

@@ -0,0 +1,87 @@
extends SceneTree
var failures: Array[String] = []
func _init() -> void:
var scene: PackedScene = load("res://scenes/main/main.tscn")
if scene == null:
push_error("Could not load main.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()
if failures.is_empty():
print("PASS combo hud")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

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

209
tests/test_combo_window.gd Normal file
View File

@@ -0,0 +1,209 @@
extends SceneTree
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:
failures.append("Missing combo_window.gd")
_finish()
return
if resolver_script == null:
failures.append("Missing input_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")
_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_bool(resolved.is_empty(), true, "W alone should not resolve a skill")
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")
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")
resolved = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "W alone should not resolve 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("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")
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("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("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("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("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("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_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])
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 combo window")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

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

View File

@@ -0,0 +1,172 @@
extends SceneTree
const WARRIOR_TEXTURE := "res://assets/art/characters/warrior_man_sheet.png"
const WARRIOR_WOMAN_TEXTURE := "res://assets/art/characters/warrior_woman_sheet.png"
const CHARGE_EFFECT_TEXTURE := "res://assets/art/effects/effect_hp_mp_sheet.png"
const EFFECT_TEXTURE := "res://assets/art/effects/effect_sheet.png"
const WARRIOR_COLUMNS := 16
const WARRIOR_ROWS := 25
var failures: Array[String] = []
func _init() -> 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
_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_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_a", 10, 7)
_expect_warrior_animation(animation_player, "warrior_aa", 11, 5)
_expect_warrior_animation(animation_player, "warrior_aaa", 12, 8)
_expect_warrior_animation(animation_player, "warrior_s_projectile", 14, 13)
_expect_warrior_animation(animation_player, "warrior_charge_release", 13, 16, WARRIOR_WOMAN_TEXTURE)
_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")
_expect_string(str(player.get("last_requested_skill_id")), "", "W alone should not request a skill")
player.call("submit_combo_input", "A")
_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()
func _expect_action_has_key(action_name: String, key: Key) -> void:
if not InputMap.has_action(action_name):
failures.append("Missing input action: %s" % action_name)
return
for event: InputEvent in InputMap.action_get_events(action_name):
var key_event := event as InputEventKey
if key_event != null and (key_event.keycode == key or key_event.physical_keycode == key):
return
failures.append("Input action %s should be bound to key %s" % [action_name, OS.get_keycode_string(key)])
func _expect_warrior_animation(animation_player: AnimationPlayer, animation_name: String, row: int, expected_frames: int, texture_path := WARRIOR_TEXTURE) -> void:
if not animation_player.has_animation(animation_name):
failures.append("Missing animation: %s" % animation_name)
return
var animation: Animation = animation_player.get_animation(animation_name)
var hframes_found := false
var vframes_found := false
var texture_found := false
var frame_values: Array[int] = []
for track_index: int in range(animation.get_track_count()):
var track_path := animation.track_get_path(track_index)
if track_path == NodePath("CharacterSprite:texture"):
var texture: Texture2D = animation.track_get_key_value(track_index, 0)
texture_found = texture != null and texture.resource_path == texture_path
elif track_path == NodePath("CharacterSprite:hframes"):
hframes_found = true
var hframes: int = animation.track_get_key_value(track_index, 0)
if hframes != WARRIOR_COLUMNS:
failures.append("%s hframes expected %d, got %d" % [animation_name, WARRIOR_COLUMNS, hframes])
elif track_path == NodePath("CharacterSprite:vframes"):
vframes_found = true
var vframes: int = animation.track_get_key_value(track_index, 0)
if vframes != WARRIOR_ROWS:
failures.append("%s vframes expected %d, got %d" % [animation_name, WARRIOR_ROWS, vframes])
elif track_path == NodePath("CharacterSprite:frame"):
for key_index: int in range(animation.track_get_key_count(track_index)):
frame_values.append(animation.track_get_key_value(track_index, key_index))
if not texture_found:
failures.append("%s should use %s" % [animation_name, texture_path])
if not hframes_found:
failures.append("Missing hframes track: %s" % animation_name)
if not vframes_found:
failures.append("Missing vframes track: %s" % animation_name)
if frame_values.size() != expected_frames:
failures.append("%s should key %d frames, got %d" % [animation_name, expected_frames, frame_values.size()])
var first_frame := (row - 1) * WARRIOR_COLUMNS
for index: int in range(frame_values.size()):
var expected := first_frame + index
if frame_values[index] != expected:
failures.append("%s frame %d expected sheet frame %d, got %d" % [
animation_name,
index,
expected,
frame_values[index],
])
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_projectile_animation(projectile: Node) -> void:
if projectile.get_child_count() == 0:
failures.append("Projectile should create a Sprite2D child")
return
var sprite := projectile.get_child(0) as Sprite2D
if sprite == null:
failures.append("Projectile child should be Sprite2D")
return
if sprite.texture == null or sprite.texture.resource_path != EFFECT_TEXTURE:
failures.append("Projectile should use %s" % EFFECT_TEXTURE)
if sprite.hframes != 6:
failures.append("Projectile hframes expected 6, got %d" % sprite.hframes)
if sprite.vframes != 2:
failures.append("Projectile vframes expected 2, got %d" % sprite.vframes)
projectile.call("_process", 0.0)
if sprite.frame != 0:
failures.append("Projectile first frame expected 0, got %d" % sprite.frame)
projectile.call("_process", 0.18)
if sprite.frame != 3:
failures.append("Projectile should use first row frame 3 after 0.18s, got %d" % sprite.frame)
func _expect_charge_effect(player: Node) -> void:
var sprite := player.get_node_or_null("ChargeEffectSprite") as Sprite2D
if sprite == null:
failures.append("Player should include ChargeEffectSprite")
return
if sprite.texture == null or sprite.texture.resource_path != CHARGE_EFFECT_TEXTURE:
failures.append("Charge effect should use %s" % CHARGE_EFFECT_TEXTURE)
if sprite.hframes != 5:
failures.append("Charge effect hframes expected 5, got %d" % sprite.hframes)
if sprite.vframes != 2:
failures.append("Charge effect vframes expected 2, got %d" % sprite.vframes)
if sprite.z_index < 1:
failures.append("Charge effect should draw above the player feet, got z_index %d" % sprite.z_index)
if sprite.visible:
failures.append("Charge effect should start hidden")
func _finish() -> void:
if failures.is_empty():
print("PASS player warrior actions")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

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

View File

@@ -0,0 +1,510 @@
extends SceneTree
var failures: Array[String] = []
var requested_skills: Array[String] = []
func _init() -> 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_no_skill_requested("W alone should not request a skill")
player.get("combo_window").clear("test-reset")
var a_event := InputEventKey.new()
a_event.pressed = true
a_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()
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_idle", "holding A charge should keep idle 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()
var a_release_event := InputEventKey.new()
a_release_event.pressed = false
a_release_event.physical_keycode = KEY_A
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()
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")
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.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()
player.call("_input", a_event)
Input.action_press("player_a")
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")
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()
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)
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()
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"), 2, "perfect input should add two energy segments")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "good")
_expect_int(player.call("get_energy"), 3, "good input 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"), 3, "bad input should not add energy")
player.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "miss")
_expect_int(player.call("get_energy"), 3, "miss input should not add energy")
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"), 10, "energy 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")
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")
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", "SP")
_expect_array(player.call("get_combo_slots"), ["SP"], "Space should be visible before space clear")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "Space should clear combo window")
if supports_energy:
player.set("current_energy", 0)
player.call("submit_combo_input", "S", "perfect")
player.call("submit_combo_input", "SP", "perfect")
_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"), 1, "S+Space should spend three energy after two perfect inputs")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP"], "S+Space should not clear combo window")
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"), 1, "S+Space+Space should spend two energy after the next perfect input")
player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP", "SP"], "S+Space+Space should not clear combo window")
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"), 2, "S+Space+Space+Space should spend one energy after the next perfect input")
_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")
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")
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()
func _expect_action(action_name: String, key: Key) -> void:
if not InputMap.has_action(action_name):
failures.append("Missing input action: %s" % action_name)
return
for event: InputEvent in InputMap.action_get_events(action_name):
var key_event := event as InputEventKey
if key_event != null and (key_event.keycode == key or key_event.physical_keycode == key):
return
failures.append("Input action %s should be bound to %s" % [action_name, OS.get_keycode_string(key)])
func _expect_last_skill(expected: String, label: String) -> void:
if requested_skills.is_empty():
failures.append("%s: no skill requested" % label)
return
var actual := requested_skills[requested_skills.size() - 1]
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_no_skill_requested(label: String) -> void:
if not requested_skills.is_empty():
failures.append("%s: expected no skill, got %s" % [label, requested_skills[requested_skills.size() - 1]])
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_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
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:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_negative(actual: float, label: String) -> void:
if actual >= 0.0:
failures.append("%s: expected negative x velocity, got %.3f" % [label, actual])
func _expect_positive(actual: float, label: String) -> void:
if actual <= 0.0:
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 _finish() -> void:
if failures.is_empty():
print("PASS player combo input")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)

View File

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

View File

@@ -1,6 +1,20 @@
extends SceneTree
var failures: Array[String] = []
const PLAYER_SPRITE_ANCHOR := Vector2(-40, -48)
const WARRIOR_ANIMATIONS := [
"warrior_idle",
"warrior_w",
"warrior_wa",
"warrior_s",
"warrior_a",
"warrior_aa",
"warrior_aaa",
"warrior_charge_release",
"warrior_s_projectile",
"warrior_a_space_space",
"warrior_a_space",
]
func _init() -> void:
@@ -18,14 +32,13 @@ func _init() -> void:
_expect_vector((player as Node2D).scale, Vector2(4, 4), "Player root scale")
_expect_vector(sprite.scale, Vector2.ONE, "CharacterSprite local scale should keep anchor")
_expect_vector(sprite.offset, Vector2(-24, -40), "CharacterSprite visible foot offset")
_expect_vector(sprite.offset, PLAYER_SPRITE_ANCHOR, "CharacterSprite visible foot offset")
_expect_vector(collision.scale, Vector2.ONE, "CollisionShape2D local scale should keep anchor")
_expect_vector(collision.position, Vector2(0, -18), "CollisionShape2D local position should keep anchor")
_expect_vector(camera.position, Vector2(0, -37.5), "Camera2D position should compensate player scale")
_expect_vector(camera.scale, Vector2(0.25, 0.25), "Camera2D scale should compensate player scale")
_expect_animation_offset(animation_player, "idle", Vector2(-24, -40))
_expect_animation_offset(animation_player, "jump", Vector2(-24, -44))
_expect_animation_offset(animation_player, "挥砍", Vector2(-40, -48))
for animation_name: String in WARRIOR_ANIMATIONS:
_expect_animation_offset(animation_player, animation_name, PLAYER_SPRITE_ANCHOR)
player.free()