Refine combo charge controls

This commit is contained in:
wxm
2026-07-02 05:46:33 -07:00
parent 67db812de4
commit fc941cf08d
7 changed files with 354 additions and 42 deletions

View File

@@ -34,7 +34,9 @@ func _init() -> void:
window.record("W")
var resolved: Dictionary = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "W alone should not resolve a skill")
_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")
@@ -50,7 +52,7 @@ func _init() -> void:
window.record("W")
resolved = resolver_script.resolve(window)
_expect_bool(resolved.is_empty(), true, "W alone should not resolve before mirrored W+D")
_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")

View File

@@ -35,7 +35,10 @@ func _init() -> void:
_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_charge_intro", 13, 8, WARRIOR_WOMAN_TEXTURE)
_expect_warrior_animation(animation_player, "warrior_charge_loop", 13, 4, WARRIOR_WOMAN_TEXTURE, 4)
_expect_animation_loops(animation_player, "warrior_charge_loop")
_expect_warrior_animation(animation_player, "warrior_charge_release", 13, 8, WARRIOR_WOMAN_TEXTURE, 8)
_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)
@@ -46,7 +49,8 @@ func _init() -> void:
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")
_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")
_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")
@@ -71,7 +75,7 @@ func _expect_action_has_key(action_name: String, key: Key) -> void:
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:
func _expect_warrior_animation(animation_player: AnimationPlayer, animation_name: String, row: int, expected_frames: int, texture_path := WARRIOR_TEXTURE, start_column := 0) -> void:
if not animation_player.has_animation(animation_name):
failures.append("Missing animation: %s" % animation_name)
return
@@ -106,7 +110,7 @@ func _expect_warrior_animation(animation_player: AnimationPlayer, animation_name
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
var first_frame := (row - 1) * WARRIOR_COLUMNS + start_column
for index: int in range(frame_values.size()):
var expected := first_frame + index
if frame_values[index] != expected:
@@ -123,6 +127,15 @@ func _expect_string(actual: String, expected: String, label: String) -> void:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_animation_loops(animation_player: AnimationPlayer, animation_name: String) -> void:
if not animation_player.has_animation(animation_name):
failures.append("Missing animation for loop check: %s" % animation_name)
return
var animation: Animation = animation_player.get_animation(animation_name)
if int(animation.loop_mode) != 1:
failures.append("%s should loop, got loop_mode %d" % [animation_name, int(animation.loop_mode)])
func _expect_projectile_animation(projectile: Node) -> void:
if projectile.get_child_count() == 0:
failures.append("Projectile should create a Sprite2D child")

View File

@@ -54,12 +54,17 @@ func _init() -> void:
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")
_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")
@@ -74,7 +79,7 @@ func _init() -> void:
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_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:
@@ -82,9 +87,6 @@ func _init() -> void:
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")
@@ -97,6 +99,7 @@ func _init() -> void:
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")
@@ -115,10 +118,17 @@ func _init() -> void:
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()
@@ -140,6 +150,7 @@ func _init() -> void:
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
@@ -150,6 +161,74 @@ func _init() -> void:
_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)
@@ -186,20 +265,39 @@ func _init() -> void:
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")
_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"), 3, "good input should add one energy segment")
_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"), 3, "bad input should not add energy")
_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"), 3, "miss input should not add energy")
_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"), 10, "energy should cap at ten segments")
_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()
@@ -349,34 +447,58 @@ func _init() -> void:
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")
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")
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", 0)
player.set("current_energy", 3)
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")
_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"), 1, "S+Space+Space should spend two energy after the next perfect input")
_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"), 2, "S+Space+Space+Space should spend one energy after the next perfect input")
_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")

View File

@@ -10,6 +10,8 @@ const WARRIOR_ANIMATIONS := [
"warrior_a",
"warrior_aa",
"warrior_aaa",
"warrior_charge_intro",
"warrior_charge_loop",
"warrior_charge_release",
"warrior_s_projectile",
"warrior_a_space_space",