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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
.godot/

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c3okawgkks4td"
path="res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/player_punch.png"
dest_files=["res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bbkamgcdsw5g6"
path="res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/warrior_man_sheet.png"
dest_files=["res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://womoel71g8ae"
path="res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/characters/warrior_woman_sheet.png"
dest_files=["res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://1p2uqgg1jole"
path="res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/effects/effect_hp_mp_sheet.png"
dest_files=["res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgpl0g56pw1qu"
path="res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/art/effects/effect_sheet.png"
dest_files=["res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -32,6 +32,31 @@ jump={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
player_w={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
player_a={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
player_s={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
player_space={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
player_d={
"deadzone": 0.2,
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
])
}
[physics]

View File

@@ -8,18 +8,22 @@ const GRAVITY := 1200.0
@export var attack_duration := 0.4
@export var attack_lunge_duration := 0.18
@export var attack_lunge_speed := 220.0
@export var air_attack_duration := 0.45
@export var air_attack_lunge_duration := 0.22
@export var air_attack_lunge_speed := 260.0
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var character_sprite: Sprite2D = $CharacterSprite
enum State { IDLE, WALK, JUMP, LAND, ATTACK }
enum State { IDLE, WALK, JUMP, LAND, ATTACK, AIR_ATTACK }
var anim_map := {
State.IDLE: "idle",
State.WALK: "idle",
State.JUMP: "jump",
State.LAND: "idle",
State.ATTACK: "挥砍",
State.IDLE: "warrior_idle",
State.WALK: "warrior_idle",
State.JUMP: "warrior_w",
State.LAND: "warrior_idle",
State.ATTACK: "warrior_a",
State.AIR_ATTACK: "warrior_a",
}
var attack_direction := Vector2.RIGHT
var attack_lunge_time_left := 0.0
@@ -55,7 +59,7 @@ func handle_air_time(delta: float) -> void:
height_speed -= GRAVITY * delta
func handle_attack_time(delta: float) -> void:
if state != State.ATTACK:
if state != State.ATTACK and state != State.AIR_ATTACK:
return
velocity.y = 0.0
attack_time_left -= delta
@@ -68,7 +72,7 @@ func handle_attack_time(delta: float) -> void:
velocity.x = 0.0
func handle_movement() -> void:
if state == State.JUMP or state == State.ATTACK:
if state == State.JUMP or state == State.ATTACK or state == State.AIR_ATTACK:
return
if absf(velocity.x) > 0.0:
state = State.WALK
@@ -97,6 +101,9 @@ func can_jump() -> bool:
func can_attack() -> bool:
return state == State.IDLE or state == State.WALK
func can_air_attack() -> bool:
return state == State.IDLE or state == State.WALK
func start_jump() -> void:
state = State.JUMP
height_speed = jump_intensity
@@ -112,3 +119,15 @@ func start_directional_attack(direction: Vector2) -> void:
attack_time_left = attack_duration
attack_lunge_time_left = attack_lunge_duration
velocity = Vector2(attack_x * attack_lunge_speed, 0.0)
func start_air_attack() -> void:
start_directional_air_attack(heading)
func start_directional_air_attack(direction: Vector2) -> void:
var attack_x := -1.0 if direction.x < 0.0 else 1.0
attack_direction = Vector2(attack_x, 0.0)
heading = Vector2.RIGHT if attack_x > 0.0 else Vector2.LEFT
state = State.AIR_ATTACK
attack_time_left = air_attack_duration
attack_lunge_time_left = air_attack_lunge_duration
velocity = Vector2(attack_x * air_attack_lunge_speed, 0.0)

View File

@@ -1,23 +1,106 @@
class_name Player
extends Character
signal combo_window_changed(slots: Array)
signal combo_window_cleared(reason: String)
signal charge_changed(current: float, maximum: float, ready: bool, active: bool)
signal energy_changed(current: int, maximum: int)
signal health_changed(current: int, maximum: int)
signal skill_requested(skill_id: String)
@export var combo_clear_display_time := 0.35
@export var charge_duration := 1.1
@export var max_health := 100
@export var current_health := 100
@export var max_energy := 10
@export var current_energy := 0
var combo_window := ComboWindow.new()
var last_requested_skill_id := ""
var current_skill_animation := ""
var combo_clear_timer: Timer
var charge_value := 0.0
var charge_ready := false
var charge_active := false
var _pending_combo_clear_reason := ""
var _charge_effect_time := 0.0
var _charge_hold_symbol := ""
var _charge_hold_direction := Vector2.ZERO
func _ready() -> void:
combo_clear_timer = Timer.new()
combo_clear_timer.one_shot = true
combo_clear_timer.wait_time = combo_clear_display_time
combo_clear_timer.timeout.connect(flush_pending_combo_clear)
add_child(combo_clear_timer)
combo_window.window_cleared.connect(_on_combo_window_cleared)
_emit_combo_window_changed()
_emit_charge_changed()
_emit_health_changed()
_emit_energy_changed()
func _process(delta: float) -> void:
_update_charge(delta)
func _input(event: InputEvent) -> void:
if _handle_combo_key_event(event):
_mark_input_handled()
func _unhandled_input(event: InputEvent) -> void:
if _handle_combo_key_event(event):
_mark_input_handled()
func _handle_combo_key_event(event: InputEvent) -> bool:
var key_event := event as InputEventKey
if key_event == null or key_event.echo:
return false
if not key_event.pressed:
if _event_matches_key(key_event, KEY_A):
_finish_charge_hold("A")
return true
elif _event_matches_key(key_event, KEY_D):
_finish_charge_hold("D")
return true
return false
if _event_matches_key(key_event, KEY_W):
_submit_combo_input_from_event("W")
return true
elif _event_matches_key(key_event, KEY_A):
heading = Vector2.LEFT
var skill_id := _submit_combo_input_from_event("A")
if skill_id == "skill_a":
_begin_charge_hold("A", Vector2.LEFT)
return true
elif _event_matches_key(key_event, KEY_D):
heading = Vector2.RIGHT
var skill_id := _submit_combo_input_from_event("D")
if skill_id == "skill_d":
_begin_charge_hold("D", Vector2.RIGHT)
return true
elif _event_matches_key(key_event, KEY_S):
_submit_combo_input_from_event("S")
return true
elif _event_matches_key(key_event, KEY_SPACE):
_submit_combo_input_from_event("SP")
return true
return false
func handle_input() -> void:
if Input.is_action_just_pressed("ui_left"):
judge_rhythm_action("left")
if can_attack():
start_directional_attack(Vector2.LEFT)
return
if Input.is_action_just_pressed("ui_right"):
judge_rhythm_action("right")
if can_attack():
start_directional_attack(Vector2.RIGHT)
if charge_active:
velocity = Vector2.ZERO
return
_apply_horizontal_movement()
if Input.is_action_just_pressed("jump"):
judge_rhythm_action("jump")
if can_jump():
start_jump()
if state == State.IDLE or state == State.WALK:
velocity.x = 0.0
func set_heading() -> void:
if velocity.x > 0.0:
@@ -25,7 +108,407 @@ func set_heading() -> void:
elif velocity.x < 0.0:
heading = Vector2.LEFT
func judge_rhythm_action(action_name: String) -> void:
func get_combo_slots() -> Array[String]:
return combo_window.get_slots()
func get_energy() -> int:
return current_energy
func get_max_energy() -> int:
return max_energy
func get_health() -> int:
return current_health
func get_max_health() -> int:
return max_health
func get_charge() -> float:
return charge_value
func get_max_charge() -> float:
return charge_duration
func is_charge_active() -> bool:
return charge_active
func is_charge_ready() -> bool:
return charge_ready
func submit_combo_input(symbol: String, forced_rating := "") -> String:
match symbol:
"W":
return _record_combo_symbol("W", "w", forced_rating)
"A":
return _record_combo_symbol("A", "a", forced_rating)
"D":
return _record_combo_symbol("D", "d", forced_rating)
"S":
return _record_combo_symbol("S", "s", forced_rating)
"SP":
return _record_combo_symbol("SP", "space", forced_rating)
return ""
func _record_combo_symbol(symbol: String, rhythm_action: String, forced_rating := "") -> String:
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 symbol == "A" or symbol == "D":
_cancel_missed_direction_action()
return ""
var resolved := InputResolver.resolve(combo_window)
if resolved.is_empty() and _pending_combo_clear_reason == "full":
resolved = _resolve_full_window_fallback(symbol)
if not resolved.is_empty():
if not _execute_combo_skill(resolved):
return ""
if symbol == "SP" and not _is_projectile_space_chain() and _pending_combo_clear_reason.is_empty():
_schedule_combo_clear("space")
return last_requested_skill_id if not resolved.is_empty() else ""
func _submit_combo_input_from_event(symbol: String) -> String:
return submit_combo_input(symbol)
func _event_matches_key(event: InputEventKey, key: Key) -> bool:
return event.physical_keycode == key or event.keycode == key
func _event_matches_any_key(event: InputEventKey, keys: Array[Key]) -> bool:
for key: Key in keys:
if _event_matches_key(event, key):
return true
return false
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _record_combo_direction(symbol: String, direction: Vector2, rhythm_action: String, forced_rating := "") -> void:
heading = direction
var rating := _rating_or_forced(judge_rhythm_action(rhythm_action), forced_rating)
_record_rated_combo_input(symbol, rating)
if state == State.IDLE or state == State.WALK:
velocity.x = 0.0
func _record_rated_combo_input(symbol: String, rating: Dictionary) -> bool:
if str(rating.get("label", "perfect")) == "miss":
_record_combo_input("Ø")
return false
_record_combo_input(symbol)
return true
func _record_combo_input(symbol: String) -> void:
if combo_window.has_pending_clear() or not _pending_combo_clear_reason.is_empty():
if _pending_combo_clear_reason.begins_with("skill:") and not combo_window.has_pending_clear():
_cancel_pending_combo_clear()
else:
flush_pending_combo_clear()
combo_window.record(symbol)
_emit_combo_window_changed()
var reason := combo_window.consume_pending_clear_reason()
if not reason.is_empty():
_schedule_combo_clear(reason)
func _rating_or_forced(rating: Dictionary, forced_rating: String) -> Dictionary:
if forced_rating.is_empty():
return rating
var forced := rating.duplicate()
forced["label"] = forced_rating
return forced
func _execute_combo_skill(skill: Dictionary) -> bool:
if not _spend_skill_energy(skill):
return false
last_requested_skill_id = str(skill.get("id", ""))
current_skill_animation = str(skill.get("animation", "warrior_idle"))
skill_requested.emit(last_requested_skill_id)
judge_rhythm_action(last_requested_skill_id)
_play_skill_animation(current_skill_animation, _skill_displacement_direction(skill))
if bool(skill.get("projectile", false)):
_fire_projectile()
_emit_combo_window_changed()
if bool(skill.get("clear_window", false)):
_schedule_combo_clear("skill:%s" % last_requested_skill_id)
return true
func _play_skill_animation(animation_name: String, displacement_direction := Vector2.ZERO) -> void:
var player_animation := _get_animation_player()
anim_map[State.ATTACK] = animation_name
state = State.ATTACK
attack_time_left = _animation_length(animation_name)
if displacement_direction == Vector2.ZERO:
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO
else:
heading = displacement_direction
attack_lunge_time_left = attack_lunge_duration
velocity = Vector2(displacement_direction.x * attack_lunge_speed, 0.0)
if player_animation != null and player_animation.has_animation(animation_name):
player_animation.play(animation_name)
func _skill_displacement_direction(skill: Dictionary) -> Vector2:
match str(skill.get("displacement", "")):
"left":
return Vector2.LEFT
"right":
return Vector2.RIGHT
return Vector2.ZERO
func _apply_energy_reward(rating_label: String) -> void:
match rating_label:
"perfect":
_change_energy(2)
"good":
_change_energy(1)
func _spend_skill_energy(skill: Dictionary) -> bool:
var energy_cost := int(skill.get("energy_cost", 0))
if energy_cost <= 0:
return true
if current_energy < energy_cost:
return false
_change_energy(-energy_cost)
return true
func _change_energy(delta: int) -> void:
var next_energy := clampi(current_energy + delta, 0, max_energy)
if next_energy == current_energy:
return
current_energy = next_energy
_emit_energy_changed()
func _begin_charge_hold(symbol: String, direction: Vector2) -> void:
_charge_hold_symbol = symbol
_charge_hold_direction = direction
func _finish_charge_hold(symbol: String) -> void:
if _charge_hold_symbol != symbol:
return
var release_ready := charge_active and charge_ready
var release_direction := _charge_hold_direction
var release_skill := "skill_a_charge_release" if symbol == "A" else "skill_d_charge_release"
_cancel_charge()
if release_ready:
_execute_charge_release(release_skill, release_direction)
func _update_charge(delta: float) -> void:
if _charge_hold_symbol.is_empty():
return
if not charge_active:
if state != State.IDLE:
return
_start_charge()
if not charge_active:
return
state = State.IDLE
attack_time_left = 0.0
attack_lunge_time_left = 0.0
velocity = Vector2.ZERO
var player_animation := _get_animation_player()
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_ready = charge_value >= charge_duration
_update_charge_effect(delta)
_emit_charge_changed()
func _start_charge() -> void:
charge_active = true
charge_value = 0.0
charge_ready = false
_charge_effect_time = 0.0
_update_charge_effect(0.0)
_emit_charge_changed()
func _cancel_charge() -> void:
_charge_hold_symbol = ""
_charge_hold_direction = Vector2.ZERO
charge_active = false
charge_value = 0.0
charge_ready = false
_set_charge_effect_visible(false)
_emit_charge_changed()
func _execute_charge_release(skill_id: String, direction: Vector2) -> void:
last_requested_skill_id = skill_id
current_skill_animation = "warrior_charge_release"
skill_requested.emit(last_requested_skill_id)
_play_skill_animation(current_skill_animation, direction)
func _update_charge_effect(delta: float) -> void:
var sprite := _get_charge_effect_sprite()
if sprite == null:
return
sprite.visible = charge_active
if not charge_active:
return
_charge_effect_time += delta
sprite.frame = int(_charge_effect_time * 12.0) % 5
func _set_charge_effect_visible(is_visible: bool) -> void:
var sprite := _get_charge_effect_sprite()
if sprite != null:
sprite.visible = is_visible
func _get_charge_effect_sprite() -> Sprite2D:
if has_node("ChargeEffectSprite"):
return get_node("ChargeEffectSprite") as Sprite2D
return null
func _resolve_full_window_fallback(symbol: String) -> Dictionary:
if symbol != "A" and symbol != "D":
return {}
return InputResolver.resolve_pattern(symbol)
func _cancel_missed_direction_action() -> void:
velocity = Vector2.ZERO
attack_lunge_time_left = 0.0
attack_time_left = 0.0
state = State.IDLE
anim_map[State.ATTACK] = "warrior_a"
var player_animation := _get_animation_player()
if player_animation != null and player_animation.has_animation("warrior_idle"):
player_animation.play("warrior_idle")
func _is_projectile_space_chain() -> bool:
var pattern := combo_window.get_contiguous_pattern()
return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP"
func _apply_horizontal_movement() -> void:
if charge_active:
velocity.x = 0.0
return
if state != State.IDLE and state != State.WALK:
return
var direction := 0.0
if Input.is_action_pressed("player_a"):
direction -= 1.0
if Input.is_action_pressed("player_d"):
direction += 1.0
if direction < 0.0:
heading = Vector2.LEFT
elif direction > 0.0:
heading = Vector2.RIGHT
velocity.x = direction * speed
func _animation_length(animation_name: String) -> float:
var player_animation := _get_animation_player()
if player_animation != null and player_animation.has_animation(animation_name):
return maxf(0.1, player_animation.get_animation(animation_name).length)
return attack_duration
func _get_animation_player() -> AnimationPlayer:
if animation_player == null and has_node("AnimationPlayer"):
animation_player = get_node("AnimationPlayer") as AnimationPlayer
return animation_player
func _fire_projectile() -> void:
var projectile := PlayerProjectile.new()
projectile.direction = heading
projectile.global_position = global_position + Vector2(heading.x * 36.0, -30.0)
var parent := get_parent()
if parent != null:
parent.add_child(projectile)
else:
add_child(projectile)
projectile.add_to_group("player_projectiles")
func _cancel_pending_combo_clear() -> void:
_pending_combo_clear_reason = ""
if combo_clear_timer != null:
combo_clear_timer.stop()
func _schedule_combo_clear(reason: String) -> void:
_pending_combo_clear_reason = reason
if combo_clear_timer == null:
return
combo_clear_timer.stop()
combo_clear_timer.wait_time = combo_clear_display_time
combo_clear_timer.start()
func flush_pending_combo_clear() -> void:
var reason := _pending_combo_clear_reason
if reason.is_empty():
reason = combo_window.consume_pending_clear_reason()
else:
combo_window.consume_pending_clear_reason()
if reason.is_empty():
return
_pending_combo_clear_reason = ""
if combo_clear_timer != null:
combo_clear_timer.stop()
combo_window.clear(reason)
func _on_combo_window_cleared(reason: String) -> void:
combo_window_cleared.emit(reason)
_emit_combo_window_changed()
func _emit_combo_window_changed() -> void:
combo_window_changed.emit(combo_window.get_slots())
func _emit_charge_changed() -> void:
charge_changed.emit(charge_value, charge_duration, charge_ready, charge_active)
func _emit_energy_changed() -> void:
energy_changed.emit(current_energy, max_energy)
func _emit_health_changed() -> void:
health_changed.emit(current_health, max_health)
func judge_rhythm_action(action_name: String) -> Dictionary:
if not is_inside_tree():
return {"label": "perfect"}
var conductor: Node = get_tree().get_first_node_in_group("rhythm_conductor")
if conductor != null and conductor.has_method("judge_action"):
conductor.call("judge_action", action_name)
return conductor.call("judge_action", action_name) as Dictionary
return {"label": "perfect"}

View File

@@ -1,18 +1,17 @@
[gd_scene load_steps=10 format=3]
[gd_scene format=3 uid="uid://cs3s5wy1melul"]
[ext_resource type="Script" path="res://scenes/characters/player.gd" id="1_player_script"]
[ext_resource type="Texture2D" path="res://assets/art/characters/jump.png" id="2_jump_texture"]
[ext_resource type="Texture2D" path="res://assets/art/characters/katana_attack_sheathe.png" id="3_slash_texture"]
[ext_resource type="Texture2D" path="res://assets/art/characters/player_idle.png" id="4_idle_texture"]
[ext_resource type="Script" uid="uid://cwp1u2srtj5ko" path="res://scenes/characters/player.gd" id="1_player_script"]
[ext_resource type="Texture2D" uid="uid://bbkamgcdsw5g6" path="res://assets/art/characters/warrior_man_sheet.png" id="2_yewv4"]
[ext_resource type="Texture2D" uid="uid://womoel71g8ae" path="res://assets/art/characters/warrior_woman_sheet.png" id="3_dyp2m"]
[ext_resource type="Texture2D" uid="uid://1p2uqgg1jole" path="res://assets/art/effects/effect_hp_mp_sheet.png" id="4_atpat"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"]
size = Vector2(16, 36)
[sub_resource type="Animation" id="Animation_idle"]
resource_name = "idle"
length = 1.0
loop_mode = 1
step = 0.1
[sub_resource type="Animation" id="Animation_76oj4"]
resource_name = "warrior_a"
length = 0.5833333
step = 0.083333336
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
@@ -23,7 +22,7 @@ tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [ExtResource("4_idle_texture")]
"values": [ExtResource("2_yewv4")]
}
tracks/1/type = "value"
tracks/1/imported = false
@@ -35,7 +34,7 @@ tracks/1/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [10]
"values": [16]
}
tracks/2/type = "value"
tracks/2/imported = false
@@ -47,138 +46,7 @@ tracks/2/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [1]
}
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(-24, -40)]
}
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.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
[sub_resource type="Animation" id="Animation_jump"]
resource_name = "jump"
length = 0.36
loop_mode = 1
step = 0.06
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("2_jump_texture")]
}
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": [6]
}
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": [1]
}
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(-24, -44)]
}
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.06, 0.12, 0.18, 0.24, 0.3),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 3, 4, 5]
}
[sub_resource type="Animation" id="Animation_slash"]
resource_name = "挥砍"
length = 0.4
step = 0.04
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_slash_texture")]
}
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": [10]
}
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": [1]
"values": [25]
}
tracks/3/type = "value"
tracks/3/imported = false
@@ -199,51 +67,708 @@ tracks/4/path = NodePath("CharacterSprite:frame")
tracks/4/interp = 1
tracks/4/loop_wrap = true
tracks/4/keys = {
"times": PackedFloat32Array(0, 0.04, 0.08, 0.12, 0.16, 0.2, 0.24, 0.28, 0.32, 0.36),
"times": PackedFloat32Array(0, 0.083333336, 0.16666667, 0.25, 0.33333334, 0.41666666, 0.5),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [144, 145, 146, 147, 148, 149, 150]
}
[sub_resource type="Animation" id="Animation_yewv4"]
resource_name = "warrior_a_space"
length = 0.8333333
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("2_yewv4")]
}
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, 0.6666667, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
"values": [256, 257, 258, 259, 260, 261, 262, 263, 264, 265]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_player"]
[sub_resource type="Animation" id="Animation_2l4js"]
resource_name = "warrior_a_space_space"
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("2_yewv4")]
}
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, 0.6666667, 0.75, 0.8333333, 0.9166667),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235]
}
[sub_resource type="Animation" id="Animation_dyp2m"]
resource_name = "warrior_aa"
length = 0.41666666
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("2_yewv4")]
}
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),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 1,
"values": [160, 161, 162, 163, 164]
}
[sub_resource type="Animation" id="Animation_atpat"]
resource_name = "warrior_aaa"
length = 0.5714286
step = 0.071428575
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("2_yewv4")]
}
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.071428575, 0.14285715, 0.21428572, 0.2857143, 0.35714287, 0.42857143, 0.5),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [176, 177, 178, 179, 180, 181, 182, 183]
}
[sub_resource type="Animation" id="Animation_jk2m4"]
resource_name = "warrior_charge_release"
length = 1.3333334
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, 0.6666667, 0.75, 0.8333333, 0.9166667, 1, 1.0833334, 1.1666666, 1.25),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207]
}
[sub_resource type="Animation" id="Animation_kqtwu"]
resource_name = "warrior_idle"
length = 0.8
loop_mode = 1
step = 0.1
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("2_yewv4")]
}
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.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 3, 4, 5, 6, 7]
}
[sub_resource type="Animation" id="Animation_6eyoc"]
resource_name = "warrior_s"
length = 0.71428573
step = 0.071428575
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("2_yewv4")]
}
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.071428575, 0.14285715, 0.21428572, 0.2857143, 0.35714287, 0.42857143, 0.5, 0.5714286, 0.64285713),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [128, 129, 130, 131, 132, 133, 134, 135, 136, 137]
}
[sub_resource type="Animation" id="Animation_eojnx"]
resource_name = "warrior_s_projectile"
length = 0.8125
step = 0.0625
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("2_yewv4")]
}
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.0625, 0.125, 0.1875, 0.25, 0.3125, 0.375, 0.4375, 0.5, 0.5625, 0.625, 0.6875, 0.75),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
"update": 1,
"values": [208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220]
}
[sub_resource type="Animation" id="Animation_qgnko"]
resource_name = "warrior_w"
length = 0.5
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("2_yewv4")]
}
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),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
"update": 1,
"values": [80, 81, 82, 83, 84, 85]
}
[sub_resource type="Animation" id="Animation_7vyk4"]
resource_name = "warrior_wa"
length = 0.41666666
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("2_yewv4")]
}
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),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 1,
"values": [96, 97, 98, 99, 100]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_2l4js"]
_data = {
"idle": SubResource("Animation_idle"),
"jump": SubResource("Animation_jump"),
"挥砍": SubResource("Animation_slash")
&"warrior_a": SubResource("Animation_76oj4"),
&"warrior_a_space": SubResource("Animation_yewv4"),
&"warrior_a_space_space": SubResource("Animation_2l4js"),
&"warrior_aa": SubResource("Animation_dyp2m"),
&"warrior_aaa": SubResource("Animation_atpat"),
&"warrior_charge_release": SubResource("Animation_jk2m4"),
&"warrior_idle": SubResource("Animation_kqtwu"),
&"warrior_s": SubResource("Animation_6eyoc"),
&"warrior_s_projectile": SubResource("Animation_eojnx"),
&"warrior_w": SubResource("Animation_qgnko"),
&"warrior_wa": SubResource("Animation_7vyk4")
}
[node name="Player" type="CharacterBody2D"]
collision_layer = 2
collision_mask = 1
safe_margin = 0.001
floor_snap_length = 0.0
[node name="Player" type="CharacterBody2D" unique_id=1029375298]
scale = Vector2(4, 4)
collision_layer = 2
floor_snap_length = 0.0
safe_margin = 0.001
script = ExtResource("1_player_script")
speed = 180.0
jump_intensity = 304.056
attack_duration = 0.4
attack_lunge_duration = 0.18
attack_lunge_speed = 220.0
[node name="CharacterSprite" type="Sprite2D" parent="."]
texture = ExtResource("4_idle_texture")
[node name="CharacterSprite" type="Sprite2D" parent="." unique_id=1175595770]
texture = ExtResource("2_yewv4")
centered = false
offset = Vector2(-24, -40)
hframes = 10
vframes = 1
offset = Vector2(-40, -48)
hframes = 16
vframes = 25
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1167515641]
position = Vector2(0, -18)
shape = SubResource("RectangleShape2D_player")
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
libraries = {
"": SubResource("AnimationLibrary_player")
}
autoplay = "idle"
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=822598049]
libraries/ = SubResource("AnimationLibrary_2l4js")
autoplay = &"warrior_idle"
[node name="Camera2D" type="Camera2D" parent="."]
[node name="Camera2D" type="Camera2D" parent="." unique_id=1607685219]
position = Vector2(0, -37.5)
scale = Vector2(0.25, 0.25)
zoom = Vector2(1.5, 1.5)
enabled = true
[node name="ChargeEffectSprite" type="Sprite2D" parent="." unique_id=1049185311]
visible = false
z_index = 2
position = Vector2(0, -8)
texture = ExtResource("4_atpat")
hframes = 5
vframes = 2

View File

@@ -0,0 +1,55 @@
class_name ComboWindow
extends RefCounted
signal window_cleared(reason: String)
const SIZE := 4
var slots: Array[String] = []
var pending_clear_reason := ""
func record(input: String) -> void:
if input.is_empty():
return
slots.append(input)
if slots.size() >= SIZE:
pending_clear_reason = "full"
func get_slots() -> Array[String]:
return slots.duplicate()
func has_pending_clear() -> bool:
return not pending_clear_reason.is_empty()
func consume_pending_clear_reason() -> String:
var reason := pending_clear_reason
pending_clear_reason = ""
return reason
func get_pattern() -> String:
var pattern := ""
for slot: String in slots:
if slot != "Ø":
pattern += slot
return pattern
func get_contiguous_pattern() -> String:
var pattern := ""
for index: int in range(slots.size() - 1, -1, -1):
var slot := slots[index]
if slot == "Ø":
break
pattern = slot + pattern
return pattern
func clear(reason := "") -> void:
slots.clear()
pending_clear_reason = ""
window_cleared.emit(reason)

View File

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

View File

@@ -0,0 +1,151 @@
class_name InputResolver
extends RefCounted
const SKILLS := {
"A": {
"type": "skill",
"id": "skill_a",
"animation": "warrior_a",
"displacement": "left",
"clear_window": false,
},
"D": {
"type": "skill",
"id": "skill_d",
"animation": "warrior_a",
"displacement": "right",
"clear_window": false,
},
"WA": {
"type": "skill",
"id": "skill_wa",
"animation": "warrior_wa",
"displacement": "left",
"clear_window": false,
},
"WD": {
"type": "skill",
"id": "skill_wd",
"animation": "warrior_wa",
"displacement": "right",
"clear_window": false,
},
"AA": {
"type": "skill",
"id": "skill_aa",
"animation": "warrior_aa",
"displacement": "left",
"clear_window": false,
},
"DD": {
"type": "skill",
"id": "skill_dd",
"animation": "warrior_aa",
"displacement": "right",
"clear_window": false,
},
"AAA": {
"type": "skill",
"id": "skill_aaa",
"animation": "warrior_aaa",
"displacement": "left",
"clear_window": false,
},
"DDD": {
"type": "skill",
"id": "skill_ddd",
"animation": "warrior_aaa",
"displacement": "right",
"clear_window": false,
},
"ASP": {
"type": "skill",
"id": "skill_a_space",
"animation": "warrior_a_space",
"displacement": "left",
"clear_window": true,
},
"DSP": {
"type": "skill",
"id": "skill_d_space",
"animation": "warrior_a_space",
"displacement": "right",
"clear_window": true,
},
"ASPSP": {
"type": "skill",
"id": "skill_a_space_space",
"animation": "warrior_a_space_space",
"displacement": "left",
"clear_window": true,
},
"DSPSP": {
"type": "skill",
"id": "skill_d_space_space",
"animation": "warrior_a_space_space",
"displacement": "right",
"clear_window": true,
},
"AASP": {
"type": "skill",
"id": "skill_aa_space",
"animation": "warrior_a_space_space",
"displacement": "left",
"clear_window": true,
},
"ADSP": {
"type": "skill",
"id": "skill_ad_space",
"animation": "warrior_a_space_space",
"displacement": "right",
"clear_window": true,
},
"DASP": {
"type": "skill",
"id": "skill_da_space",
"animation": "warrior_a_space_space",
"displacement": "left",
"clear_window": true,
},
"DDSP": {
"type": "skill",
"id": "skill_dd_space",
"animation": "warrior_a_space_space",
"displacement": "right",
"clear_window": true,
},
"SSP": {
"type": "skill",
"id": "skill_s_projectile_1",
"animation": "warrior_s_projectile",
"projectile": true,
"energy_cost": 3,
"clear_window": false,
},
"SSPSP": {
"type": "skill",
"id": "skill_s_projectile_2",
"animation": "warrior_s_projectile",
"projectile": true,
"energy_cost": 2,
"clear_window": false,
},
"SSPSPSP": {
"type": "skill",
"id": "skill_s_projectile_3",
"animation": "warrior_s_projectile",
"projectile": true,
"energy_cost": 1,
"clear_window": false,
},
}
static func resolve(window: ComboWindow) -> Dictionary:
return resolve_pattern(window.get_contiguous_pattern())
static func resolve_pattern(pattern: String) -> Dictionary:
if not SKILLS.has(pattern):
return {}
return SKILLS[pattern].duplicate()

View File

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

View File

@@ -0,0 +1,42 @@
class_name PlayerProjectile
extends Node2D
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
const FRAME_COUNT := 4
const FRAME_TIME := 0.06
const LIFE_TIME := 1.2
var direction := Vector2.RIGHT
var speed := 360.0
var _age := 0.0
var _sprite: Sprite2D
func _init() -> void:
_create_sprite()
func _ready() -> void:
add_to_group("player_projectiles")
if _sprite == null:
_create_sprite()
func _create_sprite() -> void:
_sprite = Sprite2D.new()
_sprite.texture = EFFECT_TEXTURE
_sprite.hframes = 6
_sprite.vframes = 2
_sprite.centered = true
_sprite.scale = Vector2(2.0, 2.0)
add_child(_sprite)
func _process(delta: float) -> void:
_age += delta
position += direction.normalized() * speed * delta
_sprite.frame = int(_age / FRAME_TIME) % FRAME_COUNT
if direction.x < 0.0:
_sprite.flip_h = true
if _age >= LIFE_TIME:
queue_free()

View File

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

View File

@@ -3,10 +3,43 @@ extends Node2D
@onready var rhythm_conductor: Node = $RhythmConductor
@onready var rhythm_track: Control = $RhythmFeedback/RhythmTrack
@onready var rhythm_feedback_label: Label = $RhythmFeedback/JudgementLabel
@onready var player: Node = $Player
@onready var center_base: TextureRect = $RhythmFeedback/RhythmTrack/CenterBase
@onready var center_flash: TextureRect = $RhythmFeedback/RhythmTrack/CenterFlash
@onready var left_mover: TextureRect = $RhythmFeedback/RhythmTrack/LeftMover
@onready var right_mover: TextureRect = $RhythmFeedback/RhythmTrack/RightMover
@onready var combo_skill_label: Label = $RhythmFeedback/ComboSkillLabel
@onready var health_bar: ProgressBar = $RhythmFeedback/StatusBars/HealthBar
@onready var charge_bar: ProgressBar = $RhythmFeedback/StatusBars/ChargeBar
@onready var energy_segments: Array[Panel] = [
$RhythmFeedback/StatusBars/EnergyBar/Segment0,
$RhythmFeedback/StatusBars/EnergyBar/Segment1,
$RhythmFeedback/StatusBars/EnergyBar/Segment2,
$RhythmFeedback/StatusBars/EnergyBar/Segment3,
$RhythmFeedback/StatusBars/EnergyBar/Segment4,
$RhythmFeedback/StatusBars/EnergyBar/Segment5,
$RhythmFeedback/StatusBars/EnergyBar/Segment6,
$RhythmFeedback/StatusBars/EnergyBar/Segment7,
$RhythmFeedback/StatusBars/EnergyBar/Segment8,
$RhythmFeedback/StatusBars/EnergyBar/Segment9,
]
@onready var combo_slot_panels: Array[PanelContainer] = [
$RhythmFeedback/ComboWindow/Slot0,
$RhythmFeedback/ComboWindow/Slot1,
$RhythmFeedback/ComboWindow/Slot2,
$RhythmFeedback/ComboWindow/Slot3,
]
@onready var combo_key_labels: Array[Label] = [
$RhythmFeedback/ComboWindow/Slot0/Key,
$RhythmFeedback/ComboWindow/Slot1/Key,
$RhythmFeedback/ComboWindow/Slot2/Key,
$RhythmFeedback/ComboWindow/Slot3/Key,
]
var combo_clear_tween: Tween
var combo_clear_flash := 0.0
var charge_bar_ready := false
var charge_flash := 0.0
var track_center := Vector2.ZERO
var left_mover_start := Vector2.ZERO
@@ -21,12 +54,33 @@ func _ready() -> void:
_cache_rhythm_track_layout()
rhythm_conductor.action_judged.connect(_on_rhythm_action_judged)
rhythm_conductor.beat.connect(_on_rhythm_beat)
if player.has_signal("combo_window_changed"):
player.connect("combo_window_changed", _on_combo_window_changed)
if player.has_signal("combo_window_cleared"):
player.connect("combo_window_cleared", _on_combo_window_cleared)
if player.has_signal("skill_requested"):
player.connect("skill_requested", _on_skill_requested)
if player.has_signal("energy_changed"):
player.connect("energy_changed", _on_energy_changed)
if player.has_signal("health_changed"):
player.connect("health_changed", _on_health_changed)
if player.has_signal("charge_changed"):
player.connect("charge_changed", _on_charge_changed)
rhythm_feedback_label.text = "READY"
_on_combo_window_changed([])
if player.has_method("get_energy") and player.has_method("get_max_energy"):
_on_energy_changed(player.call("get_energy"), player.call("get_max_energy"))
if player.has_method("get_health") and player.has_method("get_max_health"):
_on_health_changed(player.call("get_health"), player.call("get_max_health"))
if player.has_method("get_charge") and player.has_method("get_max_charge") and player.has_method("is_charge_ready") and player.has_method("is_charge_active"):
_on_charge_changed(player.call("get_charge"), player.call("get_max_charge"), player.call("is_charge_ready"), player.call("is_charge_active"))
_update_rhythm_track(0.0)
func _process(delta: float) -> void:
_update_rhythm_track(delta)
_update_combo_clear_animation(delta)
_update_charge_bar_flash(delta)
if feedback_flash > 0.0:
feedback_flash = maxf(0.0, feedback_flash - delta * 4.0)
rhythm_feedback_label.scale = Vector2.ONE * (1.0 + feedback_flash * 0.18)
@@ -50,6 +104,92 @@ func _on_rhythm_beat(_position: int) -> void:
beat_flash = 1.0
func _on_combo_window_changed(slots: Array) -> void:
for index: int in range(combo_key_labels.size()):
var filled := index < slots.size()
var label := combo_key_labels[index]
var panel := combo_slot_panels[index]
label.text = str(slots[index]) if filled else "·"
label.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.32)
panel.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.48)
if filled:
_pulse_combo_slot(panel)
func _on_combo_window_cleared(_reason: String) -> void:
_play_combo_clear_animation()
func _on_skill_requested(skill_id: String) -> void:
combo_skill_label.text = _format_skill_name(skill_id)
func _on_energy_changed(current: int, maximum: int) -> void:
var filled_segments := clampi(current, 0, min(maximum, energy_segments.size()))
for index: int in range(energy_segments.size()):
var filled := index < filled_segments
var panel := energy_segments[index]
panel.modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.38)
func _on_health_changed(current: int, maximum: int) -> void:
health_bar.max_value = max(1, maximum)
health_bar.value = clampi(current, 0, maximum)
func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void:
charge_bar.max_value = maxf(0.01, maximum)
charge_bar.value = clampf(current, 0.0, maximum)
charge_bar_ready = ready and active
if charge_bar_ready:
return
charge_bar.modulate = Color(1.0, 1.0, 1.0, 1.0 if active or current > 0.0 else 0.45)
func _update_charge_bar_flash(delta: float) -> void:
if not charge_bar_ready:
charge_flash = 0.0
return
charge_flash = fmod(charge_flash + delta * 7.0, TAU)
var alpha := 0.62 + 0.38 * absf(sin(charge_flash))
charge_bar.modulate = Color(1.0, 1.0, 1.0, alpha)
func _play_combo_clear_animation() -> void:
if combo_clear_tween != null and combo_clear_tween.is_valid():
combo_clear_tween.kill()
combo_clear_flash = 1.0
for panel: PanelContainer in combo_slot_panels:
panel.scale = Vector2(1.16, 1.16)
panel.modulate = Color(1.0, 1.0, 1.0, 1.0)
func _update_combo_clear_animation(delta: float) -> void:
if combo_clear_flash <= 0.0:
return
combo_clear_flash = maxf(0.0, combo_clear_flash - delta * 5.0)
var eased := combo_clear_flash * combo_clear_flash
for panel: PanelContainer in combo_slot_panels:
panel.scale = Vector2.ONE * (1.0 + 0.16 * eased)
panel.modulate = Color(1.0, 1.0, 1.0, 0.48 + 0.52 * eased)
if combo_clear_flash <= 0.0:
_restore_empty_combo_slots()
func _pulse_combo_slot(panel: PanelContainer) -> void:
var tween := create_tween()
panel.scale = Vector2(1.08, 1.08)
tween.tween_property(panel, "scale", Vector2.ONE, 0.09)
func _restore_empty_combo_slots() -> void:
for index: int in range(combo_slot_panels.size()):
combo_slot_panels[index].modulate = Color(1.0, 1.0, 1.0, 0.48)
combo_slot_panels[index].scale = Vector2.ONE
combo_key_labels[index].text = "·"
combo_key_labels[index].modulate = Color(1.0, 1.0, 1.0, 0.32)
func _update_rhythm_track(delta: float) -> void:
beat_flash = maxf(0.0, beat_flash - delta * 8.0)
var progress := 0.0
@@ -88,16 +228,110 @@ func _set_control_center(control: Control, center: Vector2, size: Vector2) -> vo
func _format_action_name(action_name: String) -> String:
match action_name:
"left":
return "LEFT"
"right":
return "RIGHT"
"jump":
return "JUMP"
"w":
return "W"
"a":
return "A"
"d":
return "D"
"s":
return "S"
"space":
return "SP"
"skill_w":
return "W"
"skill_wa":
return "W+A"
"skill_wd":
return "W+D"
"skill_s":
return "S"
"skill_a":
return "A"
"skill_d":
return "D"
"skill_aa":
return "A+A"
"skill_dd":
return "D+D"
"skill_aaa":
return "A+A+A"
"skill_ddd":
return "D+D+D"
"skill_a_space":
return "A+SP"
"skill_d_space":
return "D+SP"
"skill_a_space_space":
return "A+SP+SP"
"skill_d_space_space":
return "D+SP+SP"
"skill_aa_space":
return "A+A+SP"
"skill_ad_space":
return "A+D+SP"
"skill_da_space":
return "D+A+SP"
"skill_dd_space":
return "D+D+SP"
"skill_s_projectile_1":
return "S+SP"
"skill_s_projectile_2":
return "S+SP+SP"
"skill_s_projectile_3":
return "S+SP+SP+SP"
_:
return action_name.to_upper()
func _format_skill_name(skill_id: String) -> String:
match skill_id:
"skill_w":
return "W"
"skill_wa":
return "W+A"
"skill_wd":
return "W+D"
"skill_s":
return "S"
"skill_a":
return "A"
"skill_d":
return "D"
"skill_aa":
return "A+A"
"skill_dd":
return "D+D"
"skill_aaa":
return "A+A+A"
"skill_ddd":
return "D+D+D"
"skill_a_space":
return "A+SP"
"skill_d_space":
return "D+SP"
"skill_a_space_space":
return "A+SP+SP"
"skill_d_space_space":
return "D+SP+SP"
"skill_aa_space":
return "A+A+SP"
"skill_ad_space":
return "A+D+SP"
"skill_da_space":
return "D+A+SP"
"skill_dd_space":
return "D+D+SP"
"skill_s_projectile_1":
return "S+SP"
"skill_s_projectile_2":
return "S+SP+SP"
"skill_s_projectile_3":
return "S+SP+SP+SP"
_:
return skill_id.to_upper()
func _format_signed_ms(seconds: float) -> String:
if is_inf(seconds):
return "-- ms"

View File

@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://brx0c2va3831p"]
[ext_resource type="PackedScene" uid="uid://cs0rhloanh2u4" path="res://scenes/ground/ground.tscn" id="1_ground"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="2_player"]
[ext_resource type="PackedScene" uid="uid://cs3s5wy1melul" path="res://scenes/characters/player.tscn" id="2_player"]
[ext_resource type="Script" uid="uid://3n4nkejauoim" path="res://scenes/main/main.gd" id="3_main_script"]
[ext_resource type="Script" uid="uid://brh83qp8flq5u" path="res://scenes/rhythm/rhythm_conductor.gd" id="4_rhythm_script"]
[ext_resource type="AudioStream" uid="uid://di5ceecn088rk" path="res://assets/audio/song.ogg" id="5_song"]
@@ -11,6 +11,172 @@
[ext_resource type="Texture2D" uid="uid://dbmdivnpjf48l" path="res://assets/ui/rhythm/blue_ball.png" id="9_blue_ball"]
[ext_resource type="Texture2D" uid="uid://ewr8k3lwpcna" path="res://assets/ui/rhythm/yellow_ball.png" id="10_yellow_ball"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a8run"]
content_margin_left = 6.0
content_margin_top = 4.0
content_margin_right = 6.0
content_margin_bottom = 4.0
bg_color = Color(0.04, 0.07, 0.09, 0.82)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.43, 0.78, 0.88, 0.95)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ouso4"]
content_margin_left = 6.0
content_margin_top = 4.0
content_margin_right = 6.0
content_margin_bottom = 4.0
bg_color = Color(0.04, 0.07, 0.09, 0.82)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.43, 0.78, 0.88, 0.95)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_blune"]
content_margin_left = 6.0
content_margin_top = 4.0
content_margin_right = 6.0
content_margin_bottom = 4.0
bg_color = Color(0.04, 0.07, 0.09, 0.82)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.43, 0.78, 0.88, 0.95)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_th5th"]
content_margin_left = 6.0
content_margin_top = 4.0
content_margin_right = 6.0
content_margin_bottom = 4.0
bg_color = Color(0.04, 0.07, 0.09, 0.82)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.43, 0.78, 0.88, 0.95)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7smn1"]
bg_color = Color(0.12, 0.08, 0.08, 0.86)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6, 0.12, 0.16, 0.95)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_raeie"]
bg_color = Color(0.86, 0.11, 0.18, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hxu8e"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nvumn"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ou6is"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_necax"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_r4lks"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pg34l"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m4h2d"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p8abn"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_s17dp"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_37hlw"]
bg_color = Color(0.18, 0.66, 0.95, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.66, 0.92, 1, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_charge_bg"]
bg_color = Color(0.08, 0.07, 0.12, 0.86)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.42, 0.36, 0.75, 0.9)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_charge_fill"]
bg_color = Color(0.92, 0.72, 0.25, 1)
[node name="Main" type="Node2D" unique_id=596674982]
script = ExtResource("3_main_script")
@@ -190,3 +356,195 @@ theme_override_font_sizes/font_size = 24
text = "READY"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ComboWindow" type="HBoxContainer" parent="RhythmFeedback" unique_id=1940360666]
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -148.0
offset_top = 222.0
offset_right = 148.0
offset_bottom = 282.0
pivot_offset = Vector2(148, 30)
theme_override_constants/separation = 10
[node name="Slot0" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=181099068]
modulate = Color(1, 1, 1, 0.45)
custom_minimum_size = Vector2(64, 56)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_a8run")
[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot0" unique_id=1605416584]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.94, 0.98, 1, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 26
text = "·"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Slot1" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=1398681506]
modulate = Color(1, 1, 1, 0.45)
custom_minimum_size = Vector2(64, 56)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_ouso4")
[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot1" unique_id=1841250488]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.94, 0.98, 1, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 26
text = "·"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Slot2" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=22762864]
modulate = Color(1, 1, 1, 0.45)
custom_minimum_size = Vector2(64, 56)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_blune")
[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot2" unique_id=470444619]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.94, 0.98, 1, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 26
text = "·"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Slot3" type="PanelContainer" parent="RhythmFeedback/ComboWindow" unique_id=669931458]
modulate = Color(1, 1, 1, 0.45)
custom_minimum_size = Vector2(64, 56)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_th5th")
[node name="Key" type="Label" parent="RhythmFeedback/ComboWindow/Slot3" unique_id=1939775423]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.94, 0.98, 1, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 26
text = "·"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ComboSkillLabel" type="Label" parent="RhythmFeedback" unique_id=246366341]
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
offset_left = -240.0
offset_top = 286.0
offset_right = 240.0
offset_bottom = 322.0
theme_override_colors/font_color = Color(1, 0.84, 0.26, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 18
horizontal_alignment = 1
vertical_alignment = 1
[node name="StatusBars" type="VBoxContainer" parent="RhythmFeedback" unique_id=1850079775]
offset_left = 24.0
offset_top = 9.0
offset_right = 294.0
offset_bottom = 69.0
theme_override_constants/separation = 8
[node name="HealthBar" type="ProgressBar" parent="RhythmFeedback/StatusBars" unique_id=562194184]
custom_minimum_size = Vector2(270, 18)
layout_mode = 2
theme_override_styles/background = SubResource("StyleBoxFlat_7smn1")
theme_override_styles/fill = SubResource("StyleBoxFlat_raeie")
value = 100.0
show_percentage = false
[node name="EnergyBar" type="HBoxContainer" parent="RhythmFeedback/StatusBars" unique_id=353280285]
custom_minimum_size = Vector2(270, 16)
layout_mode = 2
theme_override_constants/separation = 4
[node name="Segment0" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1721101704]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_hxu8e")
[node name="Segment1" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=2071238510]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_nvumn")
[node name="Segment2" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=820288176]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_ou6is")
[node name="Segment3" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1809879636]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_necax")
[node name="Segment4" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=205364545]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_r4lks")
[node name="Segment5" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1414251865]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_pg34l")
[node name="Segment6" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1626363537]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_m4h2d")
[node name="Segment7" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1577127808]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_p8abn")
[node name="Segment8" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1597873707]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_s17dp")
[node name="Segment9" type="Panel" parent="RhythmFeedback/StatusBars/EnergyBar" unique_id=1260417702]
modulate = Color(1, 1, 1, 0.38)
custom_minimum_size = Vector2(23, 16)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_37hlw")
[node name="ChargeBar" type="ProgressBar" parent="RhythmFeedback/StatusBars" unique_id=674131167]
modulate = Color(1, 1, 1, 0.45)
custom_minimum_size = Vector2(270, 10)
layout_mode = 2
theme_override_styles/background = SubResource("StyleBoxFlat_charge_bg")
theme_override_styles/fill = SubResource("StyleBoxFlat_charge_fill")
max_value = 1.1
show_percentage = false

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()