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

@@ -24,8 +24,14 @@ var charge_ready := false
var charge_active := false var charge_active := false
var _pending_combo_clear_reason := "" var _pending_combo_clear_reason := ""
var _charge_effect_time := 0.0 var _charge_effect_time := 0.0
var _charge_animation_time := 0.0
var _charge_hold_symbol := "" var _charge_hold_symbol := ""
var _charge_hold_direction := Vector2.ZERO var _charge_hold_direction := Vector2.ZERO
var _suppressed_movement_actions := {
"player_a": false,
"player_d": false,
}
var _last_combo_input_accepted := false
func _ready() -> void: func _ready() -> void:
@@ -61,9 +67,11 @@ func _handle_combo_key_event(event: InputEvent) -> bool:
return false return false
if not key_event.pressed: if not key_event.pressed:
if _event_matches_key(key_event, KEY_A): if _event_matches_key(key_event, KEY_A):
_set_movement_action_suppressed("player_a", false)
_finish_charge_hold("A") _finish_charge_hold("A")
return true return true
elif _event_matches_key(key_event, KEY_D): elif _event_matches_key(key_event, KEY_D):
_set_movement_action_suppressed("player_d", false)
_finish_charge_hold("D") _finish_charge_hold("D")
return true return true
return false return false
@@ -71,15 +79,17 @@ func _handle_combo_key_event(event: InputEvent) -> bool:
_submit_combo_input_from_event("W") _submit_combo_input_from_event("W")
return true return true
elif _event_matches_key(key_event, KEY_A): elif _event_matches_key(key_event, KEY_A):
_set_movement_action_suppressed("player_a", true)
heading = Vector2.LEFT heading = Vector2.LEFT
var skill_id := _submit_combo_input_from_event("A") _submit_combo_input_from_event("A")
if skill_id == "skill_a": if _last_combo_input_accepted:
_begin_charge_hold("A", Vector2.LEFT) _begin_charge_hold("A", Vector2.LEFT)
return true return true
elif _event_matches_key(key_event, KEY_D): elif _event_matches_key(key_event, KEY_D):
_set_movement_action_suppressed("player_d", true)
heading = Vector2.RIGHT heading = Vector2.RIGHT
var skill_id := _submit_combo_input_from_event("D") _submit_combo_input_from_event("D")
if skill_id == "skill_d": if _last_combo_input_accepted:
_begin_charge_hold("D", Vector2.RIGHT) _begin_charge_hold("D", Vector2.RIGHT)
return true return true
elif _event_matches_key(key_event, KEY_S): elif _event_matches_key(key_event, KEY_S):
@@ -96,7 +106,7 @@ func handle_input() -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
return return
_apply_horizontal_movement() _apply_horizontal_movement()
if Input.is_action_just_pressed("jump"): if Input.is_action_just_pressed("jump") and not Input.is_action_just_pressed("player_space"):
judge_rhythm_action("jump") judge_rhythm_action("jump")
if can_jump(): if can_jump():
start_jump() start_jump()
@@ -161,18 +171,20 @@ func submit_combo_input(symbol: String, forced_rating := "") -> String:
func _record_combo_symbol(symbol: String, rhythm_action: String, forced_rating := "") -> String: func _record_combo_symbol(symbol: String, rhythm_action: String, forced_rating := "") -> String:
_last_combo_input_accepted = false
var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating) var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating)
_apply_energy_reward(str(rating.get("label", "perfect")))
if not _record_rated_combo_input(symbol, rating): if not _record_rated_combo_input(symbol, rating):
if symbol == "A" or symbol == "D": if symbol == "A" or symbol == "D":
_cancel_missed_direction_action() _cancel_missed_direction_action()
return "" return ""
_last_combo_input_accepted = true
var resolved := InputResolver.resolve(combo_window) var resolved := InputResolver.resolve(combo_window)
if resolved.is_empty() and _pending_combo_clear_reason == "full": if resolved.is_empty() and _pending_combo_clear_reason == "full":
resolved = _resolve_full_window_fallback(symbol) resolved = _resolve_full_window_fallback(symbol)
if not resolved.is_empty(): if not resolved.is_empty():
if not _execute_combo_skill(resolved): if not _execute_combo_skill(resolved):
return "" return ""
_apply_skill_energy_reward(last_requested_skill_id)
if symbol == "SP" and not _is_projectile_space_chain() and _pending_combo_clear_reason.is_empty(): if symbol == "SP" and not _is_projectile_space_chain() and _pending_combo_clear_reason.is_empty():
_schedule_combo_clear("space") _schedule_combo_clear("space")
return last_requested_skill_id if not resolved.is_empty() else "" return last_requested_skill_id if not resolved.is_empty() else ""
@@ -277,11 +289,9 @@ func _skill_displacement_direction(skill: Dictionary) -> Vector2:
return Vector2.ZERO return Vector2.ZERO
func _apply_energy_reward(rating_label: String) -> void: func _apply_skill_energy_reward(skill_id: String) -> void:
match rating_label: match skill_id:
"perfect": "skill_a", "skill_aa", "skill_aaa", "skill_d", "skill_dd", "skill_ddd":
_change_energy(2)
"good":
_change_energy(1) _change_energy(1)
@@ -332,9 +342,7 @@ func _update_charge(delta: float) -> void:
attack_time_left = 0.0 attack_time_left = 0.0
attack_lunge_time_left = 0.0 attack_lunge_time_left = 0.0
velocity = Vector2.ZERO velocity = Vector2.ZERO
var player_animation := _get_animation_player() _update_charge_animation(delta)
if player_animation != null and player_animation.has_animation("warrior_idle") and player_animation.current_animation != "warrior_idle":
player_animation.play("warrior_idle")
charge_value = minf(charge_duration, charge_value + delta) charge_value = minf(charge_duration, charge_value + delta)
charge_ready = charge_value >= charge_duration charge_ready = charge_value >= charge_duration
_update_charge_effect(delta) _update_charge_effect(delta)
@@ -346,6 +354,8 @@ func _start_charge() -> void:
charge_value = 0.0 charge_value = 0.0
charge_ready = false charge_ready = false
_charge_effect_time = 0.0 _charge_effect_time = 0.0
_charge_animation_time = 0.0
_play_charge_animation("warrior_charge_intro")
_update_charge_effect(0.0) _update_charge_effect(0.0)
_emit_charge_changed() _emit_charge_changed()
@@ -356,6 +366,7 @@ func _cancel_charge() -> void:
charge_active = false charge_active = false
charge_value = 0.0 charge_value = 0.0
charge_ready = false charge_ready = false
_charge_animation_time = 0.0
_set_charge_effect_visible(false) _set_charge_effect_visible(false)
_emit_charge_changed() _emit_charge_changed()
@@ -378,6 +389,21 @@ func _update_charge_effect(delta: float) -> void:
sprite.frame = int(_charge_effect_time * 12.0) % 5 sprite.frame = int(_charge_effect_time * 12.0) % 5
func _update_charge_animation(delta: float) -> void:
_charge_animation_time += delta
var intro_length := _animation_length("warrior_charge_intro")
if _charge_animation_time < intro_length:
_play_charge_animation("warrior_charge_intro")
else:
_play_charge_animation("warrior_charge_loop")
func _play_charge_animation(animation_name: String) -> void:
var player_animation := _get_animation_player()
if player_animation != null and player_animation.has_animation(animation_name) and player_animation.current_animation != animation_name:
player_animation.play(animation_name)
func _set_charge_effect_visible(is_visible: bool) -> void: func _set_charge_effect_visible(is_visible: bool) -> void:
var sprite := _get_charge_effect_sprite() var sprite := _get_charge_effect_sprite()
if sprite != null: if sprite != null:
@@ -419,9 +445,9 @@ func _apply_horizontal_movement() -> void:
if state != State.IDLE and state != State.WALK: if state != State.IDLE and state != State.WALK:
return return
var direction := 0.0 var direction := 0.0
if Input.is_action_pressed("player_a"): if _is_movement_action_pressed("player_a"):
direction -= 1.0 direction -= 1.0
if Input.is_action_pressed("player_d"): if _is_movement_action_pressed("player_d"):
direction += 1.0 direction += 1.0
if direction < 0.0: if direction < 0.0:
heading = Vector2.LEFT heading = Vector2.LEFT
@@ -430,6 +456,14 @@ func _apply_horizontal_movement() -> void:
velocity.x = direction * speed velocity.x = direction * speed
func _set_movement_action_suppressed(action_name: String, suppressed: bool) -> void:
_suppressed_movement_actions[action_name] = suppressed
func _is_movement_action_pressed(action_name: String) -> bool:
return Input.is_action_pressed(action_name) and not bool(_suppressed_movement_actions.get(action_name, false))
func _animation_length(animation_name: String) -> float: func _animation_length(animation_name: String) -> float:
var player_animation := _get_animation_player() var player_animation := _get_animation_player()
if player_animation != null and player_animation.has_animation(animation_name): if player_animation != null and player_animation.has_animation(animation_name):

View File

@@ -332,9 +332,9 @@ tracks/4/keys = {
"values": [176, 177, 178, 179, 180, 181, 182, 183] "values": [176, 177, 178, 179, 180, 181, 182, 183]
} }
[sub_resource type="Animation" id="Animation_jk2m4"] [sub_resource type="Animation" id="Animation_charge_intro"]
resource_name = "warrior_charge_release" resource_name = "warrior_charge_intro"
length = 1.3333334 length = 0.6666667
step = 0.083333336 step = 0.083333336
tracks/0/type = "value" tracks/0/type = "value"
tracks/0/imported = false tracks/0/imported = false
@@ -391,10 +391,141 @@ tracks/4/path = NodePath("CharacterSprite:frame")
tracks/4/interp = 1 tracks/4/interp = 1
tracks/4/loop_wrap = true tracks/4/loop_wrap = true
tracks/4/keys = { tracks/4/keys = {
"times": PackedFloat32Array(0, 0.083333336, 0.16666667, 0.25, 0.33333334, 0.41666666, 0.5, 0.5833333, 0.6666667, 0.75, 0.8333333, 0.9166667, 1, 1.0833334, 1.1666666, 1.25), "times": PackedFloat32Array(0, 0.083333336, 0.16666667, 0.25, 0.33333334, 0.41666666, 0.5, 0.5833333),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 1, "update": 1,
"values": [192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207] "values": [192, 193, 194, 195, 196, 197, 198, 199]
}
[sub_resource type="Animation" id="Animation_charge_loop"]
resource_name = "warrior_charge_loop"
length = 0.33333334
loop_mode = 1
step = 0.083333336
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("CharacterSprite:texture")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("3_dyp2m")]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("CharacterSprite:hframes")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [16]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("CharacterSprite:vframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [25]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("CharacterSprite:offset")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [Vector2(-40, -48)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("CharacterSprite:frame")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.083333336, 0.16666667, 0.25),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [196, 197, 198, 199]
}
[sub_resource type="Animation" id="Animation_jk2m4"]
resource_name = "warrior_charge_release"
length = 0.6666667
step = 0.083333336
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("CharacterSprite:texture")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("3_dyp2m")]
}
tracks/1/type = "value"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("CharacterSprite:hframes")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [16]
}
tracks/2/type = "value"
tracks/2/imported = false
tracks/2/enabled = true
tracks/2/path = NodePath("CharacterSprite:vframes")
tracks/2/interp = 1
tracks/2/loop_wrap = true
tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [25]
}
tracks/3/type = "value"
tracks/3/imported = false
tracks/3/enabled = true
tracks/3/path = NodePath("CharacterSprite:offset")
tracks/3/interp = 1
tracks/3/loop_wrap = true
tracks/3/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [Vector2(-40, -48)]
}
tracks/4/type = "value"
tracks/4/imported = false
tracks/4/enabled = true
tracks/4/path = NodePath("CharacterSprite:frame")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.083333336, 0.16666667, 0.25, 0.33333334, 0.41666666, 0.5, 0.5833333),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [200, 201, 202, 203, 204, 205, 206, 207]
} }
[sub_resource type="Animation" id="Animation_kqtwu"] [sub_resource type="Animation" id="Animation_kqtwu"]
@@ -730,6 +861,8 @@ _data = {
&"warrior_a_space_space": SubResource("Animation_2l4js"), &"warrior_a_space_space": SubResource("Animation_2l4js"),
&"warrior_aa": SubResource("Animation_dyp2m"), &"warrior_aa": SubResource("Animation_dyp2m"),
&"warrior_aaa": SubResource("Animation_atpat"), &"warrior_aaa": SubResource("Animation_atpat"),
&"warrior_charge_intro": SubResource("Animation_charge_intro"),
&"warrior_charge_loop": SubResource("Animation_charge_loop"),
&"warrior_charge_release": SubResource("Animation_jk2m4"), &"warrior_charge_release": SubResource("Animation_jk2m4"),
&"warrior_idle": SubResource("Animation_kqtwu"), &"warrior_idle": SubResource("Animation_kqtwu"),
&"warrior_s": SubResource("Animation_6eyoc"), &"warrior_s": SubResource("Animation_6eyoc"),

View File

@@ -2,6 +2,12 @@ class_name InputResolver
extends RefCounted extends RefCounted
const SKILLS := { const SKILLS := {
"W": {
"type": "skill",
"id": "skill_w",
"animation": "warrior_w",
"clear_window": false,
},
"A": { "A": {
"type": "skill", "type": "skill",
"id": "skill_a", "id": "skill_a",

View File

@@ -34,7 +34,9 @@ func _init() -> void:
window.record("W") window.record("W")
var resolved: Dictionary = resolver_script.resolve(window) 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") window.record("A")
resolved = resolver_script.resolve(window) resolved = resolver_script.resolve(window)
_expect_string(str(resolved.get("id", "")), "skill_wa", "W+A skill") _expect_string(str(resolved.get("id", "")), "skill_wa", "W+A skill")
@@ -50,7 +52,7 @@ func _init() -> void:
window.record("W") window.record("W")
resolved = resolver_script.resolve(window) 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") window.record("D")
resolved = resolver_script.resolve(window) 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("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_aa", 11, 5)
_expect_warrior_animation(animation_player, "warrior_aaa", 12, 8) _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_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_space", 15, 12)
_expect_warrior_animation(animation_player, "warrior_a_space", 17, 10) _expect_warrior_animation(animation_player, "warrior_a_space", 17, 10)
_expect_charge_effect(player) _expect_charge_effect(player)
@@ -46,7 +49,8 @@ func _init() -> void:
failures.append("Old slash animation should be removed") failures.append("Old slash animation should be removed")
player.call("submit_combo_input", "W") 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") 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("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") _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)]) 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): if not animation_player.has_animation(animation_name):
failures.append("Missing animation: %s" % animation_name) failures.append("Missing animation: %s" % animation_name)
return return
@@ -106,7 +110,7 @@ func _expect_warrior_animation(animation_player: AnimationPlayer, animation_name
failures.append("Missing vframes track: %s" % animation_name) failures.append("Missing vframes track: %s" % animation_name)
if frame_values.size() != expected_frames: if frame_values.size() != expected_frames:
failures.append("%s should key %d frames, got %d" % [animation_name, expected_frames, frame_values.size()]) 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()): for index: int in range(frame_values.size()):
var expected := first_frame + index var expected := first_frame + index
if frame_values[index] != expected: 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]) 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: func _expect_projectile_animation(projectile: Node) -> void:
if projectile.get_child_count() == 0: if projectile.get_child_count() == 0:
failures.append("Projectile should create a Sprite2D child") failures.append("Projectile should create a Sprite2D child")

View File

@@ -54,12 +54,17 @@ func _init() -> void:
w_event.echo = true w_event.echo = true
player.call("_input", w_event) player.call("_input", w_event)
_expect_array(player.call("get_combo_slots"), ["W"], "W key press should enter once and ignore echo repeat") _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") player.get("combo_window").clear("test-reset")
requested_skills.clear()
var a_event := InputEventKey.new() var a_event := InputEventKey.new()
a_event.pressed = true a_event.pressed = true
a_event.physical_keycode = KEY_A 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) player.call("_input", a_event)
_expect_array(player.call("get_combo_slots"), ["A"], "A alone should stay visible in the combo window") _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_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.set("attack_time_left", 0.0)
player.call("_process", 0.2) player.call("_process", 0.2)
_expect_bool(player.call("is_charge_active"), true, "holding A after its animation should enter charge state") _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") _expect_positive(player.call("get_charge"), "holding A should grow charge")
var charge_effect := player.get_node_or_null("ChargeEffectSprite") as Sprite2D var charge_effect := player.get_node_or_null("ChargeEffectSprite") as Sprite2D
if charge_effect == null: if charge_effect == null:
@@ -82,9 +87,6 @@ func _init() -> void:
else: else:
_expect_bool(charge_effect.visible, true, "holding A should show charge effect") _expect_bool(charge_effect.visible, true, "holding A should show charge effect")
requested_skills.clear() 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) 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_active"), false, "early A release should cancel charge")
_expect_bool(player.call("is_charge_ready"), false, "early A release should not be ready") _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.set("attack_time_left", 0.0)
player.call("_process", player.call("get_max_charge") + 0.1) 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_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() requested_skills.clear()
player.call("_input", a_release_event) player.call("_input", a_release_event)
_expect_last_skill("skill_a_charge_release", "full A release should request charge release skill") _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) player.call("_input", a_event)
Input.action_press("player_a") Input.action_press("player_a")
player.set("state", Character.State.IDLE)
player.set("velocity", Vector2.ZERO)
player.call("handle_input") player.call("handle_input")
Input.action_release("player_a") 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_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_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.call("flush_pending_combo_clear")
player.get("combo_window").clear("test-reset") player.get("combo_window").clear("test-reset")
requested_skills.clear() requested_skills.clear()
@@ -140,6 +150,7 @@ func _init() -> void:
player.set("state", Character.State.IDLE) player.set("state", Character.State.IDLE)
player.set("attack_time_left", 0.0) player.set("attack_time_left", 0.0)
player.call("_process", player.call("get_max_charge") + 0.1) 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() var d_release_event := InputEventKey.new()
d_release_event.pressed = false d_release_event.pressed = false
d_release_event.physical_keycode = KEY_D 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") _expect_positive((player as CharacterBody2D).velocity.x, "full D release should lunge right")
player.get("combo_window").clear("test-reset") player.get("combo_window").clear("test-reset")
requested_skills.clear() 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") Input.action_press("player_a")
player.set("state", Character.State.IDLE) player.set("state", Character.State.IDLE)
@@ -186,20 +265,39 @@ func _init() -> void:
if supports_energy: if supports_energy:
player.set("current_energy", 0) player.set("current_energy", 0)
player.call("submit_combo_input", "W", "perfect") 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.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "A", "good") 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.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "D", "bad") 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.get("combo_window").clear("test-reset")
player.call("submit_combo_input", "S", "miss") 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.get("combo_window").clear("test-reset")
player.set("current_energy", 9) player.set("current_energy", 9)
player.call("submit_combo_input", "W", "perfect") 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") player.get("combo_window").clear("test-reset")
requested_skills.clear() requested_skills.clear()
@@ -349,34 +447,58 @@ func _init() -> void:
player.call("flush_pending_combo_clear") player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "D+D+Space should clear combo window") _expect_array(player.call("get_combo_slots"), [], "D+D+Space should clear combo window")
player.get("combo_window").clear("test-reset") player.get("combo_window").clear("test-reset")
requested_skills.clear()
player.call("submit_combo_input", "SP") player.call("submit_combo_input", "SP")
_expect_array(player.call("get_combo_slots"), ["SP"], "Space should be visible before space clear") _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") player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "Space should clear combo window") _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: 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", "S", "perfect")
player.call("submit_combo_input", "SP", "perfect") player.call("submit_combo_input", "SP", "perfect")
_expect_last_skill("skill_s_projectile_1", "S+Space should request projectile skill") _expect_last_skill("skill_s_projectile_1", "S+Space should request projectile skill")
_expect_projectile_count(1, "S+Space should fire one projectile") _expect_projectile_count(1, "S+Space should fire one projectile")
if supports_energy: 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") player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP"], "S+Space should not clear combo window") _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") player.call("submit_combo_input", "SP", "perfect")
_expect_last_skill("skill_s_projectile_2", "S+Space+Space should request projectile skill") _expect_last_skill("skill_s_projectile_2", "S+Space+Space should request projectile skill")
_expect_projectile_count(2, "Second Space should fire another projectile") _expect_projectile_count(2, "Second Space should fire another projectile")
if supports_energy: 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") player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), ["S", "SP", "SP"], "S+Space+Space should not clear combo window") _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") player.call("submit_combo_input", "SP", "perfect")
_expect_last_skill("skill_s_projectile_3", "S+Space+Space+Space should request projectile skill") _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") _expect_projectile_count(3, "Third Space should fire another projectile")
if supports_energy: 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") _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") player.call("flush_pending_combo_clear")
_expect_array(player.call("get_combo_slots"), [], "projectile chain should clear combo window because four slots are full") _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_a",
"warrior_aa", "warrior_aa",
"warrior_aaa", "warrior_aaa",
"warrior_charge_intro",
"warrior_charge_loop",
"warrior_charge_release", "warrior_charge_release",
"warrior_s_projectile", "warrior_s_projectile",
"warrior_a_space_space", "warrior_a_space_space",