From 073ff690ea5a27cb47ee1b563c3eebd6cde9e788 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Thu, 13 Aug 2026 02:05:57 +0800 Subject: [PATCH] feat: complete first expedition session --- runtime/main.gd | 428 +++++++++++++++++- .../tests/p9_0r5_first_session_arc_test.gd | 149 ++++++ .../p9_0r5_first_session_arc_test.gd.uid | 1 + runtime/tests/p9_0r5_full_route_touch_test.gd | 106 +++++ .../tests/p9_0r5_full_route_touch_test.gd.uid | 1 + runtime/tests/p9_0r5_preview_capture_test.gd | 129 ++++++ .../tests/p9_0r5_preview_capture_test.gd.uid | 1 + 7 files changed, 797 insertions(+), 18 deletions(-) create mode 100644 runtime/tests/p9_0r5_first_session_arc_test.gd create mode 100644 runtime/tests/p9_0r5_first_session_arc_test.gd.uid create mode 100644 runtime/tests/p9_0r5_full_route_touch_test.gd create mode 100644 runtime/tests/p9_0r5_full_route_touch_test.gd.uid create mode 100644 runtime/tests/p9_0r5_preview_capture_test.gd create mode 100644 runtime/tests/p9_0r5_preview_capture_test.gd.uid diff --git a/runtime/main.gd b/runtime/main.gd index fe4ba553..460d3b41 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -57,7 +57,14 @@ const SECOND_BATTLE_PHASE_DURATIONS := { "EQ-002": [1.35, 1.00, 1.10, 1.05, 1.30, 1.25], "EQ-061": [1.20, 0.85, 0.95, 0.90, 1.00, 1.10], } +const THIRD_BATTLE_PHASE_DURATIONS := { + "EQ-002": [1.20, 0.80, 1.00, 1.00, 1.10, 1.10, 1.40], + "EQ-061": [1.10, 0.70, 0.90, 0.90, 0.90, 0.90, 1.20], + "recruit": [1.10, 0.80, 0.90, 0.80, 0.80, 1.10, 1.50], +} const FIRST_VICTORY_COIN := 20 +const RETAIN_ITEM_COST := 12 +const RECRUIT_REAR_COST := 14 const BATTLE_FEEDBACK_DURATION := 0.68 const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24 @@ -111,7 +118,7 @@ const CANDIDATES := { }, } -enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT } +enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT } const COLOR_INK := Color("#10182c") const COLOR_INDIGO := Color("#172745") @@ -159,6 +166,12 @@ var adaptation_choice := "" var adaptation_committed := false var second_battle_elapsed := 0.0 var second_result_time := 0.0 +var guild_choice := "" +var guild_committed := false +var retained_item := "" +var added_rear := "" +var third_battle_elapsed := 0.0 +var third_result_time := 0.0 var animation_library: Dictionary = {} var victory_clock := 0.0 var active_battle_feedback: Dictionary = {} @@ -168,6 +181,7 @@ var battle_feedback_history: Array[Dictionary] = [] var display_root: Control var front_sprite: TextureRect var rear_sprite: TextureRect +var added_rear_sprite: TextureRect var enemy_sprite: TextureRect var battle_feedback_label: Label var battle_feedback_vfx: TextureRect @@ -178,6 +192,7 @@ var victory_enemy_sprite: TextureRect var projectile: Control var front_base_position := Vector2.ZERO var rear_base_position := Vector2.ZERO +var added_rear_base_position := Vector2.ZERO var enemy_base_position := Vector2.ZERO var audio_player: AudioStreamPlayer var sfx_playback: AudioStreamGeneratorPlayback @@ -275,6 +290,8 @@ func animation_texture(character: String, state: String, elapsed: float) -> Text func front_animation_state() -> String: + if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1: + return "basic_attack" if front_choice == "Hearthguard" else "evade_counter" if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1: return "basic_attack" if front_choice == "Hearthguard" else "evade_counter" match battle_phase: @@ -288,6 +305,8 @@ func front_animation_state() -> String: func rear_animation_state() -> String: + if page == AppPage.THIRD_BATTLE and battle_phase == 3: + return "draw_release" if rear_choice == "Farstring" else "lantern_heal" match battle_phase: 0: return "advance" @@ -296,7 +315,29 @@ func rear_animation_state() -> String: return "idle" +func added_rear_animation_state() -> String: + if page == AppPage.THIRD_BATTLE and battle_phase == 4: + return "draw_release" if added_rear == "Farstring" else "lantern_heal" + if battle_phase == 0: + return "advance" + return "idle" + + func enemy_animation_state() -> String: + if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1: + return "hit" + if page == AppPage.THIRD_BATTLE: + match battle_phase: + 0: + return "advance" + 1: + return "telegraph" + 2: + return "attack" + 3, 4, 5: + return "hit" + 6: + return "death" if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1: return "hit" match battle_phase: @@ -326,6 +367,7 @@ func render() -> void: render_pending = false front_sprite = null rear_sprite = null + added_rear_sprite = null enemy_sprite = null battle_feedback_label = null battle_feedback_vfx = null @@ -335,7 +377,7 @@ func render() -> void: projectile = null for child in display_root.get_children(): child.free() - var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT] else YARD_BACKGROUND + var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT] else YARD_BACKGROUND add_background(background_texture) if portrait_layout(): render_portrait_notice() @@ -357,6 +399,12 @@ func render() -> void: render_battle() AppPage.SECOND_RESULT: render_second_result() + AppPage.GUILD_RETURN: + render_guild_return() + AppPage.THIRD_BATTLE: + render_battle() + AppPage.SESSION_RESULT: + render_session_result() if restart_confirmation: render_restart_confirmation() @@ -564,20 +612,25 @@ func candidate_button(profession: String) -> Button: func render_battle() -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.1)) var second_cycle := page == AppPage.SECOND_BATTLE - render_hud(tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins")) + var third_cycle := page == AppPage.THIRD_BATTLE + var battle_title := tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins")) + render_hud(battle_title) var world_bottom := size.y - 58.0 var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0) var actor_width: float = actor_height var contact_reached := battle_phase > 0 or reduced_motion front_base_position = Vector2(size.x * (0.39 if contact_reached else 0.18), world_bottom - actor_height) - rear_base_position = Vector2(size.x * (0.24 if contact_reached else 0.07), world_bottom - actor_height * 0.92) + rear_base_position = Vector2(size.x * (0.26 if contact_reached else 0.08), world_bottom - actor_height * 0.92) + added_rear_base_position = Vector2(size.x * (0.12 if contact_reached else -0.06), world_bottom - actor_height * 0.86) enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84) front_sprite = actor_at(animation_texture(front_choice, front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor") rear_sprite = actor_at(animation_texture(rear_choice, rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor") + if third_cycle and not added_rear.is_empty(): + added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor") enemy_sprite = actor_at(animation_texture("Riftblade", enemy_animation_state(), battle_clock), enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor") add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), front_hp, 100, candidate_name(front_choice)) - add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 100, tr_text("裂影刃手", "Riftblade")) + add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 130 if third_cycle else 100, tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade")) var event := battle_event_text() var ribbon := PanelContainer.new() ribbon.name = "BattleEvent" @@ -586,21 +639,32 @@ func render_battle() -> void: ribbon.add_theme_stylebox_override("panel", lacquer_style(Color(0.04, 0.07, 0.13, 0.94), COLOR_GOLD, 2)) ribbon.add_child(centered_label(event, 12, COLOR_WHITE)) display_root.add_child(ribbon) - if second_cycle: - var loan := centered_label(("▣ " if adaptation_choice == "EQ-002" else "◷ ") + adaptation_name(adaptation_choice), 11, COLOR_GOLD_LIGHT) + if second_cycle or (third_cycle and not retained_item.is_empty()): + var visible_item := retained_item if third_cycle else adaptation_choice + var loan := centered_label(("▣ " if visible_item == "EQ-002" else "◷ ") + adaptation_name(visible_item), 11, COLOR_GOLD_LIGHT) loan.name = "ActiveAdaptation" loan.position = Vector2(size.x * 0.02, 116) loan.size = Vector2(size.x * 0.23, 48) loan.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2)) display_root.add_child(loan) + elif third_cycle: + var company := centered_label(tr_text("◆ 三人小队\n弓手 + 治疗", "◆ THREE-PERSON LINE\nArcher + Healer"), 11, COLOR_GOLD_LIGHT) + company.name = "ActiveCompany" + company.position = Vector2(size.x * 0.02, 116) + company.size = Vector2(size.x * 0.23, 48) + company.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2)) + display_root.add_child(company) - if battle_phase == 1 and (not second_cycle or adaptation_choice == "EQ-002"): + if third_cycle and battle_phase == 1 and retained_item != "EQ-061": + add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) + elif battle_phase == 1 and not third_cycle and (not second_cycle or adaptation_choice == "EQ-002"): add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) elif second_cycle and adaptation_choice == "EQ-061" and battle_phase == 2: add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) - elif battle_phase == 3: - if rear_choice == "Farstring": - projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT) + elif battle_phase == 3 and rear_choice == "Farstring": + projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT) + elif third_cycle and battle_phase == 4 and added_rear == "Farstring": + projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT) render_battle_feedback(actor_width, actor_height) update_battle_feedback_visual() @@ -719,11 +783,106 @@ func render_second_result() -> void: var facts := centered_label(tr_text("前排生命 %d / 100\n战斗用时 %.2f 秒\n金币保留 %d" % [front_hp, second_result_time, coin], "Front HP %d / 100\nBattle time %.2f s\nCoin kept %d" % [front_hp, second_result_time, coin]), 13, Color("#6d5523")) facts.name = "SecondResultFacts" box.add_child(facts) - var replay := game_button("ReplayExperienceButton", tr_text("换一种选择重玩", "Replay with another choice"), request_restart, true) - box.add_child(replay) + var return_button := game_button("OpenGuildReturnButton", tr_text("带着经验返回公会", "Return to the Guild"), open_guild_return, true) + box.add_child(return_button) box.add_child(game_button("SecondResultHomeButton", tr_text("回到主页", "Home"), go_home, false)) +func render_guild_return() -> void: + add_full_wash(Color(0.012, 0.028, 0.06, 0.16)) + render_hud(tr_text("公会庭院 · 第三次远征准备", "Guild Yard · Prepare the third expedition")) + var actor_height: float = clampf(size.y * 0.29, 98.0, 178.0) + actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(size.x * 0.055, size.y - actor_height - 34), Vector2(actor_height, actor_height), "GuildFront") + actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.19, size.y - actor_height * 0.9 - 34), Vector2(actor_height * 0.9, actor_height * 0.9), "GuildRear") + if guild_choice == "recruit_rear": + var preview_rear := missing_rear_profession() + actor_at(animation_texture(preview_rear, "idle", victory_clock), Vector2(size.x * 0.315, size.y - actor_height * 0.82 - 36), Vector2(actor_height * 0.82, actor_height * 0.82), "GuildRecruitPreview") + + var resources := PanelContainer.new() + resources.name = "GuildResources" + resources.position = Vector2(18, 74) + resources.size = Vector2(size.x * 0.35, 76) + resources.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2)) + display_root.add_child(resources) + var resource_copy := VBoxContainer.new() + resources.add_child(resource_copy) + resource_copy.add_child(game_label(tr_text("带回公会的远征资源", "EXPEDITION RESOURCES RETURNED"), 10, COLOR_TEAL)) + resource_copy.add_child(game_label(tr_text("金币 %d | 小队 2 人\n借用装备:%s" % [coin, adaptation_name(adaptation_choice)], "Coin %d | Company 2\nLoaned item: %s" % [coin, adaptation_name(adaptation_choice)]), 12, COLOR_WHITE)) + + var bench := PanelContainer.new() + bench.name = "PreparationBench" + bench.position = Vector2(size.x * 0.40, 72) + bench.size = Vector2(size.x * 0.58, size.y - 88) + bench.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.95), COLOR_GOLD, 2)) + display_root.add_child(bench) + var shell := VBoxContainer.new() + shell.add_theme_constant_override("separation", 5) + bench.add_child(shell) + shell.add_child(game_label(tr_text("队长更强:把金币花在哪?", "THE CAPTAIN IS STRONGER: where should Coin go?"), 16, COLOR_WHITE)) + shell.add_child(game_label(guild_choice_guidance(), 10, COLOR_TEAL)) + var choices := HBoxContainer.new() + choices.add_theme_constant_override("separation", 7) + shell.add_child(choices) + choices.add_child(guild_choice_button("retain_item")) + choices.add_child(guild_choice_button("recruit_rear")) + var depart_button := game_button("BeginThirdBattle", tr_text("确认花费并出发", "Confirm spend and depart"), begin_third_battle, true) + depart_button.disabled = guild_choice.is_empty() + depart_button.custom_minimum_size.y = 48 + shell.add_child(depart_button) + + +func guild_choice_button(id: String) -> Button: + var selected := guild_choice == id + var button := Button.new() + button.name = "GuildChoice_%s" % id + if id == "retain_item": + button.text = tr_text("▣ 保留%s · 12 金币\n小队保持 2 人|剩余 8 金币\n继续发挥已验证的装备效果" % adaptation_name(adaptation_choice), "▣ RETAIN %s · 12 Coin\nCompany stays 2 | 8 Coin left\nKeep the proven item effect" % adaptation_name(adaptation_choice)) + else: + button.text = tr_text("+ 招募%s · 14 金币\n小队扩至 3 人|剩余 6 金币\n归还借用装备,补齐弓手与治疗" % candidate_name(missing_rear_profession()), "+ RECRUIT %s · 14 Coin\nCompany grows to 3 | 6 Coin left\nReturn item; gain Archer and Healer" % candidate_name(missing_rear_profession())) + button.focus_mode = Control.FOCUS_ALL + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2(215, 112) + button.add_theme_font_size_override("font_size", scaled(10)) + button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE) + button.add_theme_stylebox_override("normal", candidate_style(selected, false, COLOR_TEAL)) + button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL)) + button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL)) + button.pressed.connect(func(): choose_guild_option(id), CONNECT_DEFERRED) + return button + + +func render_session_result() -> void: + add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) + render_hud(tr_text("首轮远征完成", "First Expedition Cycle Complete")) + var actor_height: float = clampf(size.y * 0.29, 98.0, 180.0) + actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.05, size.y - actor_height - 38), Vector2(actor_height, actor_height), "SessionFront") + actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.9 - 38), Vector2(actor_height * 0.9, actor_height * 0.9), "SessionRear") + if not added_rear.is_empty(): + actor_at(animation_texture(added_rear, "idle", victory_clock), Vector2(size.x * 0.30, size.y - actor_height * 0.84 - 38), Vector2(actor_height * 0.84, actor_height * 0.84), "SessionAddedRear") + actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.52 - 36), Vector2(actor_height * 0.52, actor_height * 0.52), "SessionEnemy") + var result := PanelContainer.new() + result.name = "SessionResultPanel" + result.position = Vector2(size.x * 0.57, 78) + result.size = Vector2(size.x * 0.40, size.y - 100) + result.add_theme_stylebox_override("panel", parchment_style()) + display_root.add_child(result) + var box := VBoxContainer.new() + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.add_theme_constant_override("separation", 5) + result.add_child(box) + var headline := tr_text("装备让两人小队守住优势", "THE ITEM KEPT A TWO-PERSON EDGE") if guild_choice == "retain_item" else tr_text("第三名成员补齐了远程与治疗", "A THIRD MEMBER COMPLETED THE REAR LINE") + box.add_child(centered_label("◆ " + headline + " ◆", 17, COLOR_INK)) + var explanation := centered_label(session_result_explanation(), 11, Color("#4c402d")) + explanation.name = "SessionResultExplanation" + box.add_child(explanation) + var company_size := 3 if not added_rear.is_empty() else 2 + var facts := centered_label(tr_text("金币 %d | 小队 %d 人\n前排生命 %d / 100 | 用时 %.2f 秒" % [coin, company_size, front_hp, third_result_time], "Coin %d | Company %d\nFront HP %d / 100 | Time %.2f s" % [coin, company_size, front_hp, third_result_time]), 12, Color("#6d5523")) + facts.name = "SessionResultFacts" + box.add_child(facts) + box.add_child(game_button("ReplayExperienceButton", tr_text("用另一条路线重新开始", "Restart with another route"), request_restart, true)) + box.add_child(game_button("SessionResultHomeButton", tr_text("回到主页", "Home"), go_home, false)) + + func render_restart_confirmation() -> void: var wash := ColorRect.new() wash.name = "RestartWash" @@ -825,11 +984,19 @@ func depart() -> void: func advance_battle_phase() -> void: - if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE] or not battle_playing: + if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing: return commit_phase_impact(battle_phase, false) battle_phase += 1 battle_clock = 0.0 + if page == AppPage.THIRD_BATTLE: + if battle_phase == 7: + battle_playing = false + victory_events += 1 + third_result_time = third_battle_elapsed + page = AppPage.SESSION_RESULT + render() + return match battle_phase: 6: battle_playing = false @@ -844,6 +1011,18 @@ func advance_battle_phase() -> void: func phase_impact_time(phase: int) -> float: + if page == AppPage.THIRD_BATTLE: + match phase: + 1: + return 3.0 / 12.0 if retained_item == "EQ-061" else -1.0 + 2: + return 4.0 / 12.0 + 3, 4: + return 2.0 / 7.0 + 5: + return 3.0 / 10.0 + 6: + return 5.0 / 10.0 if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061": match phase: 1: @@ -869,6 +1048,11 @@ func phase_impact_time(phase: int) -> float: func phase_duration(phase: int) -> float: + if page == AppPage.THIRD_BATTLE: + var route := "recruit" if not added_rear.is_empty() else retained_item + if THIRD_BATTLE_PHASE_DURATIONS.has(route): + var third_durations: Array = THIRD_BATTLE_PHASE_DURATIONS[route] + return float(third_durations[phase]) if phase >= 0 and phase < third_durations.size() else 0.0 if page == AppPage.SECOND_BATTLE and SECOND_BATTLE_PHASE_DURATIONS.has(adaptation_choice): var durations: Array = SECOND_BATTLE_PHASE_DURATIONS[adaptation_choice] return float(durations[phase]) if phase >= 0 and phase < durations.size() else 0.0 @@ -885,6 +1069,9 @@ func commit_phase_impact(phase: int, refresh_runtime := true) -> void: var qualifier_zh := "" var qualifier_en := "" var tier := "medium" + if page == AppPage.THIRD_BATTLE: + commit_third_phase_impact(phase, refresh_runtime) + return if page == AppPage.SECOND_BATTLE: commit_second_phase_impact(phase, refresh_runtime) return @@ -1010,6 +1197,102 @@ func commit_second_phase_impact(phase: int, refresh_runtime := true) -> void: render() +func commit_third_phase_impact(phase: int, refresh_runtime := true) -> void: + if phase_impact_committed.get(phase, false): + return + var target := "" + var delta := 0 + var qualifier_zh := "" + var qualifier_en := "" + var tier := "medium" + match phase: + 1: + if retained_item != "EQ-061": + return + var previous_enemy_hp := enemy_hp + enemy_hp = 102 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "晨钟先手" + qualifier_en = "Early Bell" + play_cue("hit") + 2: + var previous_front_hp := front_hp + front_hp = 90 if retained_item == "EQ-002" else 70 + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "装备抗推" if retained_item == "EQ-002" else "队长重击" + qualifier_en = "item resisted" if retained_item == "EQ-002" else "captain strike" + guard_events += 1 + play_cue("hit") + 3: + if rear_choice == "Farstring": + var previous_enemy_hp := enemy_hp + enemy_hp = 66 if added_rear.is_empty() else 94 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "弓手命中" + qualifier_en = "Archer hit" + else: + var previous_front_hp := front_hp + front_hp = mini(100, front_hp + 12) + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "治疗" + qualifier_en = "healing" + rear_events += 1 + play_cue("role") + 4: + if not added_rear.is_empty(): + if added_rear == "Farstring": + var previous_enemy_hp := enemy_hp + enemy_hp = 94 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "新弓手命中" + qualifier_en = "new Archer hit" + else: + var previous_front_hp := front_hp + front_hp = mini(100, front_hp + 12) + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "新治疗支援" + qualifier_en = "new Healer support" + rear_events += 1 + play_cue("role") + else: + var previous_enemy_hp := enemy_hp + enemy_hp = 44 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "装备连携" + qualifier_en = "item follow-up" + play_cue("hit") + 5: + var previous_enemy_hp := enemy_hp + enemy_hp = 32 if not added_rear.is_empty() else 26 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "三人连携" if not added_rear.is_empty() else "压制" + qualifier_en = "three-person combo" if not added_rear.is_empty() else "pressure" + play_cue("hit") + 6: + var previous_enemy_hp := enemy_hp + enemy_hp = 0 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "队长击破" + qualifier_en = "captain defeated" + tier = "large" + play_cue("victory") + _: + return + phase_impact_committed[phase] = true + emit_battle_feedback(phase, target, delta, qualifier_zh, qualifier_en, tier) + if refresh_runtime and page == AppPage.THIRD_BATTLE: + render() + + func emit_battle_feedback(phase: int, target: String, delta: int, qualifier_zh: String, qualifier_en: String, tier: String) -> void: active_battle_feedback = { "phase": phase, @@ -1043,7 +1326,7 @@ func expire_battle_feedback() -> void: func _process(delta: float) -> void: - if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT]: + if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT, AppPage.GUILD_RETURN, AppPage.SESSION_RESULT]: if front_choice.is_empty() or rear_choice.is_empty(): return victory_clock += delta @@ -1051,12 +1334,14 @@ func _process(delta: float) -> void: victory_front_sprite.texture = animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock) victory_rear_sprite.texture = animation_texture(rear_choice, "idle", victory_clock) return - if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE] or not battle_playing or restart_confirmation: + if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing or restart_confirmation: return var feedback_was_active := not active_battle_feedback.is_empty() battle_clock += delta if page == AppPage.SECOND_BATTLE: second_battle_elapsed += delta + elif page == AppPage.THIRD_BATTLE: + third_battle_elapsed += delta var impact_time := phase_impact_time(battle_phase) if impact_time >= 0.0 and battle_clock >= impact_time: commit_phase_impact(battle_phase) @@ -1070,13 +1355,18 @@ func _process(delta: float) -> void: if battle_phase == 0 and not reduced_motion: front_sprite.position.x = lerp(size.x * 0.13, size.x * 0.38, progress) rear_sprite.position.x = lerp(size.x * 0.03, size.x * 0.20, progress) + if added_rear_sprite != null and is_instance_valid(added_rear_sprite): + added_rear_sprite.position.x = lerp(-added_rear_sprite.size.x, size.x * 0.10, progress) enemy_sprite.position.x = lerp(size.x * 0.82, size.x * 0.57, progress) front_sprite.texture = animation_texture(front_choice, front_animation_state(), battle_clock) rear_sprite.texture = animation_texture(rear_choice, rear_animation_state(), battle_clock) + if added_rear_sprite != null and is_instance_valid(added_rear_sprite): + added_rear_sprite.texture = animation_texture(added_rear, added_rear_animation_state(), battle_clock) enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock) - if projectile != null and is_instance_valid(projectile) and battle_phase == 3 and rear_choice == "Farstring" and not reduced_motion: + var projectile_actor_is_active := (battle_phase == 3 and rear_choice == "Farstring") or (page == AppPage.THIRD_BATTLE and battle_phase == 4 and added_rear == "Farstring") + if projectile != null and is_instance_valid(projectile) and projectile_actor_is_active and not reduced_motion: var projectile_start := 0.08 - var projectile_end := phase_impact_time(3) + 0.10 + var projectile_end := phase_impact_time(battle_phase) + 0.10 var projectile_progress := clampf((battle_clock - projectile_start) / (projectile_end - projectile_start), 0.0, 1.0) projectile.visible = battle_clock >= projectile_start and battle_clock <= projectile_end projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, projectile_progress) @@ -1128,6 +1418,59 @@ func begin_second_battle() -> void: render() +func open_guild_return() -> void: + if page != AppPage.SECOND_RESULT or not adaptation_committed: + return + guild_choice = "" + guild_committed = false + retained_item = "" + added_rear = "" + page = AppPage.GUILD_RETURN + play_cue("confirm") + render() + + +func choose_guild_option(id: String) -> void: + if page != AppPage.GUILD_RETURN or guild_committed or id not in ["retain_item", "recruit_rear"]: + return + var cost := RETAIN_ITEM_COST if id == "retain_item" else RECRUIT_REAR_COST + if coin < cost: + return + guild_choice = id + play_cue("select") + render() + + +func begin_third_battle() -> void: + if page != AppPage.GUILD_RETURN or guild_committed or guild_choice.is_empty(): + return + var cost := RETAIN_ITEM_COST if guild_choice == "retain_item" else RECRUIT_REAR_COST + if coin < cost: + return + guild_committed = true + coin -= cost + if guild_choice == "retain_item": + retained_item = adaptation_choice + added_rear = "" + else: + retained_item = "" + added_rear = missing_rear_profession() + battle_phase = 0 + battle_clock = 0.0 + battle_playing = true + front_hp = 100 + enemy_hp = 130 + guard_events = 0 + rear_events = 0 + phase_impact_committed.clear() + reset_battle_feedback() + third_battle_elapsed = 0.0 + third_result_time = 0.0 + page = AppPage.THIRD_BATTLE + play_cue("depart") + render() + + func request_restart() -> void: restart_confirmation = true render() @@ -1172,6 +1515,12 @@ func clear_experience() -> void: adaptation_committed = false second_battle_elapsed = 0.0 second_result_time = 0.0 + guild_choice = "" + guild_committed = false + retained_item = "" + added_rear = "" + third_battle_elapsed = 0.0 + third_result_time = 0.0 phase_impact_committed.clear() reset_battle_feedback() victory_clock = 0.0 @@ -1201,6 +1550,8 @@ func toggle_mute() -> void: func battle_event_text() -> String: + if page == AppPage.THIRD_BATTLE: + return third_battle_event_text() if page == AppPage.SECOND_BATTLE: return second_battle_event_text() match battle_phase: @@ -1242,6 +1593,27 @@ func second_battle_event_text() -> String: return "" +func third_battle_event_text() -> String: + match battle_phase: + 0: + return tr_text("小队向裂隙队长推进…", "The squad advances on the Rift Captain…") + 1: + return tr_text("晨钟先手:前排抢在队长之前行动", "Early Bell: the front acts before the captain") if retained_item == "EQ-061" else tr_text("队长蓄力,准备撕开前排", "The captain gathers force against the front") + 2: + return tr_text("钉阵胫甲守住阵线,重击只造成 -10", "Frontpin holds the line; the strike deals only -10") if retained_item == "EQ-002" else tr_text("队长重击前排;现在需要后排支援", "The captain hits the front; rear support is needed") + 3: + return tr_text("原后排弓手命中队长", "The original Archer hits the captain") if rear_choice == "Farstring" else tr_text("原后排治疗为前排恢复 +12", "The original Healer restores +12 to the front") + 4: + if not added_rear.is_empty(): + return tr_text("新弓手补上远程伤害", "The new Archer adds ranged damage") if added_rear == "Farstring" else tr_text("新治疗补上 +12 恢复", "The new Healer adds +12 recovery") + return tr_text("保留装备继续放大两人连携", "The retained item amplifies the two-person follow-up") + 5: + return tr_text("三人阵线合力压制队长", "The three-person line overwhelms the captain") if not added_rear.is_empty() else tr_text("两人阵线守住装备优势", "The two-person line keeps its item advantage") + 6: + return tr_text("裂隙队长被击败!", "Rift Captain defeated!") + return "" + + func adaptation_name(id: String) -> String: return tr_text("钉阵胫甲", "Frontpin Greaves") if id == "EQ-002" else tr_text("晨钟", "Early Bell") @@ -1266,6 +1638,26 @@ func second_result_explanation() -> String: return tr_text("晨钟让前排在敌人之前造成 -28,\n但前排完整承受了 -30 重击。", "Early Bell dealt -28 before the enemy acted,\nbut the front took the full -30 strike.") +func missing_rear_profession() -> String: + return "Kindhand" if rear_choice == "Farstring" else "Farstring" + + +func guild_choice_guidance() -> String: + if guild_choice.is_empty(): + return tr_text("没有预选。保留已验证的装备,或用更多金币补齐后排。", "Nothing is preselected. Keep the proven item, or spend more to complete the rear line.") + if guild_choice == "retain_item": + return tr_text("已选择保留装备;剩余 8 金币、小队 2 人。出发前仍可切换。", "Retain selected: 8 Coin, Company 2. You can still switch before departure.") + return tr_text("已选择招募%s;剩余 6 金币、小队 3 人。出发前仍可切换。" % candidate_name(missing_rear_profession()), "Recruit %s selected: 6 Coin, Company 3. You can still switch." % candidate_name(missing_rear_profession())) + + +func session_result_explanation() -> String: + if guild_choice == "retain_item": + if retained_item == "EQ-002": + return tr_text("你把金币用于保留钉阵胫甲。\n队伍仍是两人,但队长重击从 -30 降为 -10。", "You spent Coin to retain Frontpin.\nThe line stayed at two, but the captain strike fell from -30 to -10.") + return tr_text("你把金币用于保留晨钟。\n队伍仍是两人,但先手让战斗更快结束。", "You spent Coin to retain Early Bell.\nThe line stayed at two, but initiative ended the battle faster.") + return tr_text("你归还借用装备并招募了%s。\n弓手与治疗都行动:阵线更完整,但花费更多。" % candidate_name(added_rear), "You returned the loan and recruited %s.\nArcher and Healer both acted: a fuller line at greater cost." % candidate_name(added_rear)) + + func candidate_name(profession: String) -> String: if profession.is_empty() or not CANDIDATES.has(profession): return "" diff --git a/runtime/tests/p9_0r5_first_session_arc_test.gd b/runtime/tests/p9_0r5_first_session_arc_test.gd new file mode 100644 index 00000000..e8804c39 --- /dev/null +++ b/runtime/tests/p9_0r5_first_session_arc_test.gd @@ -0,0 +1,149 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + var game := MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle(10) + game.locale = "zh_CN" + game.muted = true + + prepare_second_result(game, "Hearthguard", "Farstring", "EQ-002") + game.open_guild_return() + expect(game.page == game.AppPage.GUILD_RETURN, "second result returns to Guild") + expect(game.coin == 20 and game.guild_choice.is_empty(), "Guild opens with 20 Coin and no preselection") + expect(find_control(game, "GuildChoice_retain_item") != null and find_control(game, "GuildChoice_recruit_rear") != null, "both Guild responses render") + var depart := find_control(game, "BeginThirdBattle") as BaseButton + expect(depart != null and depart.disabled, "third departure waits for a choice") + game.choose_guild_option("invalid") + expect(game.guild_choice.is_empty(), "invalid Guild choice is rejected") + game.coin = 11 + game.choose_guild_option("retain_item") + expect(game.guild_choice.is_empty() and game.coin == 11, "insufficient Coin cannot select an unaffordable response") + game.coin = 20 + game.choose_guild_option("retain_item") + expect(game.coin == 20, "selection does not spend Coin") + game.choose_guild_option("recruit_rear") + expect(game.guild_choice == "recruit_rear" and game.coin == 20, "Guild choice can be revised before commit") + game.choose_guild_option("retain_item") + game.coin = 10 + game.begin_third_battle() + expect(game.page == game.AppPage.GUILD_RETURN and not game.guild_committed and game.coin == 10, "commit rechecks Coin without partial mutation") + game.coin = 20 + game.begin_third_battle() + expect(game.guild_committed and game.coin == 8, "retain commit spends 12 Coin exactly once") + expect(game.retained_item == "EQ-002" and game.added_rear.is_empty(), "retain route keeps item and has no added rear") + game.begin_third_battle() + expect(game.coin == 8, "duplicate departure cannot spend twice") + var safe_time := resolve_third_battle(game) + expect(game.page == game.AppPage.SESSION_RESULT, "retain route reaches session result") + expect(game.front_hp == 90 and game.enemy_hp == 0, "Frontpin Farstring route ends at 90 HP and wins") + expect(is_equal_approx(safe_time, 7.60), "Frontpin route declares 7.60 seconds") + expect(find_control(game, "SessionResultFacts") != null, "session result renders exact facts") + + game.clear_experience() + prepare_second_result(game, "Wayrunner", "Kindhand", "EQ-002") + game.open_guild_return() + game.choose_guild_option("retain_item") + game.begin_third_battle() + resolve_third_battle(game) + expect(game.front_hp == 100 and game.coin == 8, "Frontpin Kindhand route heals to 100 and keeps 8 Coin") + + game.clear_experience() + prepare_second_result(game, "Hearthguard", "Farstring", "EQ-061") + game.open_guild_return() + game.choose_guild_option("retain_item") + game.begin_third_battle() + var fast_time := resolve_third_battle(game) + expect(game.front_hp == 70 and game.coin == 8, "Early Bell Farstring route accepts 70 HP and keeps 8 Coin") + expect(is_equal_approx(fast_time, 6.60) and fast_time < safe_time, "Early Bell route declares 6.60 seconds and is faster") + + game.clear_experience() + prepare_second_result(game, "Wayrunner", "Kindhand", "EQ-061") + game.open_guild_return() + game.choose_guild_option("retain_item") + game.begin_third_battle() + resolve_third_battle(game) + expect(game.front_hp == 82, "Early Bell Kindhand route heals 70 to 82") + game.go_home() + expect(game.page == game.AppPage.TITLE and game.coin == 0 and game.added_rear.is_empty(), "Home clears the full three-battle session") + + for original_rear in ["Farstring", "Kindhand"]: + game.clear_experience() + prepare_second_result(game, "Hearthguard", original_rear, "EQ-002") + game.open_guild_return() + game.choose_guild_option("recruit_rear") + game.begin_third_battle() + expect(game.coin == 6 and game.retained_item.is_empty(), "recruit route spends 14 Coin and returns the loan") + expect(game.added_rear == ("Kindhand" if original_rear == "Farstring" else "Farstring"), "recruit route adds the missing rear role") + var recruit_time := resolve_third_battle(game) + expect(game.front_hp == 82 and game.enemy_hp == 0, "both recruit formations end at 82 HP and win") + expect(is_equal_approx(recruit_time, 7.00), "recruit route declares 7.00 seconds") + expect(game.battle_feedback_history.size() == 5, "recruit route emits exactly five HP mutations") + + game.request_restart() + game.confirm_restart() + expect(game.page == game.AppPage.TITLE, "confirmed Restart returns to Title") + expect(game.coin == 0 and game.guild_choice.is_empty() and game.retained_item.is_empty() and game.added_rear.is_empty(), "Restart clears the full three-battle session") + game.queue_free() + await settle(90) + if failures.is_empty(): + print("ABG_P9_0R5_FIRST_SESSION_OK unselected=true revision=true spend_once=true coin=8,6 company=2,3 hp=90,100,70,82 times=7.60,6.60,7.00 routes=6 cleanup=true") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func prepare_second_result(game: Control, front: String, rear: String, item: String) -> void: + game.front_choice = front + game.rear_choice = rear + game.coin = 20 + game.first_reward_committed = true + game.adaptation_choice = item + game.adaptation_committed = true + game.front_hp = 90 if item == "EQ-002" else 70 + game.enemy_hp = 0 + game.second_result_time = 7.05 if item == "EQ-002" else 6.00 + game.page = game.AppPage.SECOND_RESULT + game.render() + + +func resolve_third_battle(game: Control) -> float: + var declared_time := 0.0 + for phase in range(7): + var duration: float = game.phase_duration(phase) + declared_time += duration + game.third_battle_elapsed += duration + game.advance_battle_phase() + return declared_time + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R5 first session: %s" % message) diff --git a/runtime/tests/p9_0r5_first_session_arc_test.gd.uid b/runtime/tests/p9_0r5_first_session_arc_test.gd.uid new file mode 100644 index 00000000..60297d27 --- /dev/null +++ b/runtime/tests/p9_0r5_first_session_arc_test.gd.uid @@ -0,0 +1 @@ +uid://np4iuwyg5noo diff --git a/runtime/tests/p9_0r5_full_route_touch_test.gd b/runtime/tests/p9_0r5_full_route_touch_test.gd new file mode 100644 index 00000000..725a7490 --- /dev/null +++ b/runtime/tests/p9_0r5_full_route_touch_test.gd @@ -0,0 +1,106 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var game: Control +var touch_count := 0 +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + game = MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle(10) + game.locale = "zh_CN" + game.muted = true + game.page = game.AppPage.TITLE + game.render() + await settle(6) + + await tap("StartAdventure") + await tap("Candidate_Hearthguard") + await tap("Candidate_Farstring") + await tap("DepartButton") + expect(game.page == game.AppPage.BATTLE, "Title route reaches first battle") + await wait_for_page(game.AppPage.VICTORY, 720) + await tap("ContinueAdaptationButton") + await tap("Adaptation_EQ_002") + await tap("BeginSecondBattle") + await wait_for_page(game.AppPage.SECOND_RESULT, 720) + await tap("OpenGuildReturnButton") + await tap("GuildChoice_retain_item") + await tap("GuildChoice_recruit_rear") + expect(game.guild_choice == "recruit_rear" and game.coin == 20, "touch revises Guild choice before spend") + await tap("BeginThirdBattle") + await wait_for_page(game.AppPage.SESSION_RESULT, 720) + expect(game.coin == 6 and game.added_rear == "Kindhand", "complete touch route commits recruit consequence") + expect(find_control(game, "SessionResultFacts") != null, "complete touch route renders session result") + + game.queue_free() + game = null + await settle(90) + if failures.is_empty(): + print("ABG_P9_0R5_FULL_ROUTE_TOUCH_OK touch=%d fallback=0 direct=0 title=1 battles=3 revise=1 result=1" % touch_count) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func wait_for_page(target_page: int, max_frames: int) -> void: + var previous_scale := Engine.time_scale + Engine.time_scale = 12.0 + for frame in range(max_frames): + if game.page == target_page: + break + await process_frame + Engine.time_scale = previous_scale + expect(game.page == target_page, "automatic battle reaches page %d" % target_page) + await settle(6) + + +func tap(node_name: String) -> void: + var control := find_control(game, node_name) + if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled): + failures.append("unavailable touch target %s" % node_name) + return + var position := root.get_final_transform() * control.get_global_rect().get_center() + var down := InputEventScreenTouch.new() + down.index = 0 + down.position = position + down.pressed = true + Input.parse_input_event(down) + await process_frame + var up := InputEventScreenTouch.new() + up.index = 0 + up.position = position + up.pressed = false + Input.parse_input_event(up) + touch_count += 1 + await settle(7) + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R5 full route touch: %s" % message) diff --git a/runtime/tests/p9_0r5_full_route_touch_test.gd.uid b/runtime/tests/p9_0r5_full_route_touch_test.gd.uid new file mode 100644 index 00000000..78dd98e9 --- /dev/null +++ b/runtime/tests/p9_0r5_full_route_touch_test.gd.uid @@ -0,0 +1 @@ +uid://dbk0oeqjwclhu diff --git a/runtime/tests/p9_0r5_preview_capture_test.gd b/runtime/tests/p9_0r5_preview_capture_test.gd new file mode 100644 index 00000000..6aa0d4e2 --- /dev/null +++ b/runtime/tests/p9_0r5_preview_capture_test.gd @@ -0,0 +1,129 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") +const STATES := ["guild_unselected", "guild_recruit_selected", "third_retain", "third_recruit_action", "result_retain", "result_recruit"] +const VIEWPORT_SIZE := Vector2i(844, 390) + +var output_root := "" +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + output_root = OS.get_environment("ABG_P9_0R5_CAPTURE_ROOT") + if output_root.is_empty(): + output_root = "/tmp/abg-p9-0r5-preview" + DirAccess.make_dir_recursive_absolute(output_root) + var viewport := SubViewport.new() + viewport.size = VIEWPORT_SIZE + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var game := MainScene.instantiate() + game.auto_boot = false + game.audio_enabled = false + viewport.add_child(game) + await settle(12) + game.locale = "zh_CN" + game.text_scale = 1.0 + game.reduced_motion = false + game.muted = true + for index in range(STATES.size()): + apply_state(game, STATES[index]) + game.render() + await settle(7) + verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE)), STATES[index]) + await RenderingServer.frame_post_draw + var image := viewport.get_texture().get_image() + var path := output_root.path_join("%02d-%s.png" % [index + 1, STATES[index]]) + var error := image.save_png(path) + if error != OK: + failures.append("capture %s failed with %s" % [STATES[index], error]) + game.queue_free() + viewport.queue_free() + await settle(120) + if failures.is_empty(): + print("ABG_P9_0R5_PREVIEW_CAPTURE_OK frames=6 viewport=844x390 locale=zh_CN min_touch=44 root=%s" % output_root) + call_deferred("finish", 0) + else: + for failure in failures: + push_error(failure) + call_deferred("finish", 1) + + +func apply_state(game: Control, state_name: String) -> void: + game.clear_experience() + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.coin = 20 + game.first_reward_committed = true + game.adaptation_choice = "EQ-002" + game.adaptation_committed = true + match state_name: + "guild_unselected": + game.page = game.AppPage.GUILD_RETURN + "guild_recruit_selected": + game.page = game.AppPage.GUILD_RETURN + game.guild_choice = "recruit_rear" + "third_retain": + prepare_third(game, "retain_item", "EQ-002", "", 2, 90, 130) + game.emit_battle_feedback(2, "front", -10, "装备抗推", "item resisted", "medium") + game.battle_feedback_clock = 0.16 + "third_recruit_action": + prepare_third(game, "recruit_rear", "", "Kindhand", 4, 82, 94) + game.emit_battle_feedback(4, "front", 12, "新治疗支援", "new Healer support", "medium") + game.battle_feedback_clock = 0.16 + "result_retain": + game.page = game.AppPage.SESSION_RESULT + game.guild_choice = "retain_item" + game.guild_committed = true + game.retained_item = "EQ-002" + game.coin = 8 + game.front_hp = 90 + game.enemy_hp = 0 + game.third_result_time = 7.60 + "result_recruit": + game.page = game.AppPage.SESSION_RESULT + game.guild_choice = "recruit_rear" + game.guild_committed = true + game.added_rear = "Kindhand" + game.coin = 6 + game.front_hp = 82 + game.enemy_hp = 0 + game.third_result_time = 7.00 + + +func prepare_third(game: Control, choice: String, item: String, recruit: String, phase: int, front_health: int, enemy_health: int) -> void: + game.page = game.AppPage.THIRD_BATTLE + game.guild_choice = choice + game.guild_committed = true + game.retained_item = item + game.added_rear = recruit + game.coin = 8 if choice == "retain_item" else 6 + game.battle_playing = false + game.battle_phase = phase + game.battle_clock = 0.22 + game.front_hp = front_health + game.enemy_hp = enemy_health + + +func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void: + if node is BaseButton and node.is_visible_in_tree(): + var rect: Rect2 = node.get_global_rect() + if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5: + failures.append("%s button %s outside viewport: %s" % [label, node.name, rect]) + if rect.size.x < 44.0 or rect.size.y < 44.0: + failures.append("%s button %s below 44px: %s" % [label, node.name, rect.size]) + for child in node.get_children(): + verify_interactive_bounds(child, bounds, label) + + +func finish(exit_code: int) -> void: + quit(exit_code) + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame diff --git a/runtime/tests/p9_0r5_preview_capture_test.gd.uid b/runtime/tests/p9_0r5_preview_capture_test.gd.uid new file mode 100644 index 00000000..2fa34e31 --- /dev/null +++ b/runtime/tests/p9_0r5_preview_capture_test.gd.uid @@ -0,0 +1 @@ +uid://bckp8wvil4snr