diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d05f856 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.godot/ diff --git a/assets/art/characters/player_punch.png b/assets/art/characters/player_punch.png new file mode 100644 index 0000000..c9a4660 Binary files /dev/null and b/assets/art/characters/player_punch.png differ diff --git a/assets/art/characters/player_punch.png.import b/assets/art/characters/player_punch.png.import new file mode 100644 index 0000000..38ad26c --- /dev/null +++ b/assets/art/characters/player_punch.png.import @@ -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 diff --git a/assets/art/characters/warrior_man_sheet.png b/assets/art/characters/warrior_man_sheet.png new file mode 100644 index 0000000..517536a Binary files /dev/null and b/assets/art/characters/warrior_man_sheet.png differ diff --git a/assets/art/characters/warrior_man_sheet.png.import b/assets/art/characters/warrior_man_sheet.png.import new file mode 100644 index 0000000..28aaebe --- /dev/null +++ b/assets/art/characters/warrior_man_sheet.png.import @@ -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 diff --git a/assets/art/characters/warrior_woman_sheet.png b/assets/art/characters/warrior_woman_sheet.png new file mode 100644 index 0000000..8b68489 Binary files /dev/null and b/assets/art/characters/warrior_woman_sheet.png differ diff --git a/assets/art/characters/warrior_woman_sheet.png.import b/assets/art/characters/warrior_woman_sheet.png.import new file mode 100644 index 0000000..d2846ad --- /dev/null +++ b/assets/art/characters/warrior_woman_sheet.png.import @@ -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 diff --git a/assets/art/effects/effect_hp_mp_sheet.png b/assets/art/effects/effect_hp_mp_sheet.png new file mode 100644 index 0000000..f33543e Binary files /dev/null and b/assets/art/effects/effect_hp_mp_sheet.png differ diff --git a/assets/art/effects/effect_hp_mp_sheet.png.import b/assets/art/effects/effect_hp_mp_sheet.png.import new file mode 100644 index 0000000..218a159 --- /dev/null +++ b/assets/art/effects/effect_hp_mp_sheet.png.import @@ -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 diff --git a/assets/art/effects/effect_sheet.png b/assets/art/effects/effect_sheet.png new file mode 100644 index 0000000..4f94b21 Binary files /dev/null and b/assets/art/effects/effect_sheet.png differ diff --git a/assets/art/effects/effect_sheet.png.import b/assets/art/effects/effect_sheet.png.import new file mode 100644 index 0000000..16fdc40 --- /dev/null +++ b/assets/art/effects/effect_sheet.png.import @@ -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 diff --git a/project.godot b/project.godot index 4e5c034..84931c9 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scenes/characters/character.gd b/scenes/characters/character.gd index 9f434f9..713ec9d 100644 --- a/scenes/characters/character.gd +++ b/scenes/characters/character.gd @@ -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) diff --git a/scenes/characters/player.gd b/scenes/characters/player.gd index b263fb4..77106a5 100644 --- a/scenes/characters/player.gd +++ b/scenes/characters/player.gd @@ -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"} diff --git a/scenes/characters/player.tscn b/scenes/characters/player.tscn index 5d5a322..a12ddd0 100644 --- a/scenes/characters/player.tscn +++ b/scenes/characters/player.tscn @@ -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 diff --git a/scenes/combat/combo_window.gd b/scenes/combat/combo_window.gd new file mode 100644 index 0000000..dae5b1c --- /dev/null +++ b/scenes/combat/combo_window.gd @@ -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) diff --git a/scenes/combat/combo_window.gd.uid b/scenes/combat/combo_window.gd.uid new file mode 100644 index 0000000..6087c7a --- /dev/null +++ b/scenes/combat/combo_window.gd.uid @@ -0,0 +1 @@ +uid://dtguxwnh02f6g diff --git a/scenes/combat/input_resolver.gd b/scenes/combat/input_resolver.gd new file mode 100644 index 0000000..843654b --- /dev/null +++ b/scenes/combat/input_resolver.gd @@ -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() diff --git a/scenes/combat/input_resolver.gd.uid b/scenes/combat/input_resolver.gd.uid new file mode 100644 index 0000000..54d5078 --- /dev/null +++ b/scenes/combat/input_resolver.gd.uid @@ -0,0 +1 @@ +uid://cyhq381jiyo42 diff --git a/scenes/combat/player_projectile.gd b/scenes/combat/player_projectile.gd new file mode 100644 index 0000000..a9cdfac --- /dev/null +++ b/scenes/combat/player_projectile.gd @@ -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() diff --git a/scenes/combat/player_projectile.gd.uid b/scenes/combat/player_projectile.gd.uid new file mode 100644 index 0000000..28a1942 --- /dev/null +++ b/scenes/combat/player_projectile.gd.uid @@ -0,0 +1 @@ +uid://bwjk27wxb6p20 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 10528f7..034a5d5 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -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" diff --git a/scenes/main/main.tscn b/scenes/main/main.tscn index 142b9a2..930c335 100644 --- a/scenes/main/main.tscn +++ b/scenes/main/main.tscn @@ -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 diff --git a/tests/test_combo_hud.gd b/tests/test_combo_hud.gd new file mode 100644 index 0000000..133d32c --- /dev/null +++ b/tests/test_combo_hud.gd @@ -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) diff --git a/tests/test_combo_hud.gd.uid b/tests/test_combo_hud.gd.uid new file mode 100644 index 0000000..25fedde --- /dev/null +++ b/tests/test_combo_hud.gd.uid @@ -0,0 +1 @@ +uid://sp5kvov8rll3 diff --git a/tests/test_combo_window.gd b/tests/test_combo_window.gd new file mode 100644 index 0000000..ed46a03 --- /dev/null +++ b/tests/test_combo_window.gd @@ -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) diff --git a/tests/test_combo_window.gd.uid b/tests/test_combo_window.gd.uid new file mode 100644 index 0000000..ce306f2 --- /dev/null +++ b/tests/test_combo_window.gd.uid @@ -0,0 +1 @@ +uid://bti6vtxunhyq5 diff --git a/tests/test_player_air_attack.gd b/tests/test_player_air_attack.gd new file mode 100644 index 0000000..3c6d597 --- /dev/null +++ b/tests/test_player_air_attack.gd @@ -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) diff --git a/tests/test_player_air_attack.gd.uid b/tests/test_player_air_attack.gd.uid new file mode 100644 index 0000000..0e3d2c1 --- /dev/null +++ b/tests/test_player_air_attack.gd.uid @@ -0,0 +1 @@ +uid://de8rucje4ky17 diff --git a/tests/test_player_combo_input.gd b/tests/test_player_combo_input.gd new file mode 100644 index 0000000..e8fec61 --- /dev/null +++ b/tests/test_player_combo_input.gd @@ -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) diff --git a/tests/test_player_combo_input.gd.uid b/tests/test_player_combo_input.gd.uid new file mode 100644 index 0000000..e6b66ba --- /dev/null +++ b/tests/test_player_combo_input.gd.uid @@ -0,0 +1 @@ +uid://cv8ly1jk7ksoo diff --git a/tests/test_player_scale.gd b/tests/test_player_scale.gd index e1aa373..92a00f4 100644 --- a/tests/test_player_scale.gd +++ b/tests/test_player_scale.gd @@ -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()