diff --git a/runtime/compat/p9_0_root_facade.gd b/runtime/compat/p9_0_root_facade.gd index fda3c7b4..6987f45b 100644 --- a/runtime/compat/p9_0_root_facade.gd +++ b/runtime/compat/p9_0_root_facade.gd @@ -468,6 +468,91 @@ func added_rear_animation_state() -> String: return "idle" +func visible_member_ids() -> Array[String]: + var members := session_state.current_member_ids() + if members.is_empty(): + for member_id in [front_choice, rear_choice, added_rear]: + if not member_id.is_empty() and member_id not in members: + members.append(member_id) + return members + + +func visible_member_id(index: int) -> String: + var members := visible_member_ids() + return members[index] if index >= 0 and index < members.size() else "" + + +func visible_member_slot(member_id: String) -> String: + var index := visible_member_ids().find(member_id) + return ["front", "rear", "added"][index] if index >= 0 and index < 3 else "" + + +func support_member_ids() -> Array[String]: + var members := visible_member_ids() + return members.slice(1) if members.size() > 1 else [] + + +func profession_action_state(member_id: String) -> String: + match member_id: + "Hearthguard": return "basic_attack" + "Wayrunner": return "evade_counter" + "Farstring": return "draw_release" + "Kindhand": return "lantern_heal" + return "idle" + + +func profession_defense_state(member_id: String) -> String: + if member_id == "Hearthguard": + return "guard" + if member_id == "Wayrunner": + return "evade_counter" + return "idle" + + +func later_battle_animation_state(member_id: String) -> String: + var lead_id := visible_member_id(0) + var supports := support_member_ids() + if battle_phase == 0: + return "advance" + if page == AppPage.THIRD_BATTLE: + if battle_phase == 1 and retained_item == "EQ-061" and member_id == lead_id: + return profession_action_state(member_id) + if battle_phase == 2 and member_id == lead_id: + return profession_defense_state(member_id) + if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]: + return profession_action_state(member_id) + if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]: + return profession_action_state(member_id) + if battle_phase == 5 and member_id == lead_id: + return profession_action_state(member_id) + if page == AppPage.FOURTH_BATTLE: + if battle_phase == 1 and selected_build_profile() == "tempo" and route_choice == "R6-02" and member_id == lead_id: + return profession_action_state(member_id) + if battle_phase == 2 and member_id == (session_state.marked_member_id() if route_choice == "R6-03" else lead_id): + return profession_defense_state(member_id) + if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]: + return profession_action_state(member_id) + if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]: + return profession_action_state(member_id) + if battle_phase == 5 and member_id == lead_id: + return profession_action_state(member_id) + if page == AppPage.FIFTH_BATTLE: + var pending_supports := supports.duplicate() + if trained_profession() == "Farstring": + pending_supports.erase(trained_profession()) + if battle_phase == 1 and trained_profession() == "Farstring" and member_id == trained_profession(): + return profession_action_state(member_id) + if battle_phase == 2 and member_id == lead_id: + return profession_defense_state(member_id) + if battle_phase == 3 and member_id == session_state.marked_member_id(): + return profession_defense_state(member_id) + if battle_phase == 4 and not pending_supports.is_empty() and member_id == pending_supports[0]: + return profession_action_state(member_id) + if battle_phase == 5 and pending_supports.size() > 1 and member_id == pending_supports[1]: + return profession_action_state(member_id) + return "idle" + + func enemy_animation_state() -> String: if page == AppPage.FIFTH_BATTLE: match battle_phase: @@ -1192,6 +1277,11 @@ func render_battle() -> void: var third_cycle := page == AppPage.THIRD_BATTLE var fourth_cycle := page == AppPage.FOURTH_BATTLE var fifth_cycle := page == AppPage.FIFTH_BATTLE + var later_cycle := third_cycle or fourth_cycle or fifth_cycle + var members := visible_member_ids() + var visible_front := visible_member_id(0) + var visible_rear := visible_member_id(1) + var visible_third := visible_member_id(2) var battle_title := tr_text("第五次远征 · 双压关隘", "Fifth Expedition · Twin-Pressure Pass") if fifth_cycle else (tr_text("第四次远征 · %s" % route_name(route_choice), "Fourth Expedition · %s" % route_name(route_choice)) if fourth_cycle else (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 @@ -1202,16 +1292,20 @@ func render_battle() -> void: 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 or fourth_cycle or fifth_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") + front_sprite = actor_at(animation_texture(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor") + rear_sprite = actor_at(animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor") + front_sprite.set_meta("member_id", visible_front) + rear_sprite.set_meta("member_id", visible_rear) + if later_cycle and members.size() > 2: + added_rear_sprite = actor_at(animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor") + added_rear_sprite.set_meta("member_id", visible_third) 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("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), session_state.member_health(visible_front), 100, candidate_name(visible_front)) add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 140 if fifth_cycle else (130 if third_cycle else (120 if fourth_cycle else 100)), tr_text("双压守卫", "Twin Warden") if fifth_cycle else (tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade")))) if fifth_cycle or (fourth_cycle and route_choice == "R6-03"): - add_health_bar("RearHealth", Vector2(22, 101), Vector2(size.x * 0.20, 12), rear_hp, 100, candidate_name(rear_choice)) + var marked_id := session_state.marked_member_id() + add_health_bar("RearHealth", Vector2(22, 101), Vector2(size.x * 0.20, 12), session_state.member_health(marked_id), 100, candidate_name(marked_id)) var event := battle_event_text() var ribbon := PanelContainer.new() ribbon.name = "BattleEvent" @@ -1252,14 +1346,13 @@ func render_battle() -> void: 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 and rear_choice == "Farstring": + elif not later_cycle and 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 or fourth_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) - elif fifth_cycle and battle_phase == 4 and rear_choice == "Farstring" and trained_member != "rear": - projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT) - elif fifth_cycle and battle_phase == 5 and added_rear == "Farstring" and trained_member != "added": - projectile = add_signal_label("➤", Vector2(size.x * 0.42, size.y * 0.53), COLOR_GOLD_LIGHT) + elif later_cycle: + for member_id in members: + if later_battle_animation_state(member_id) == "draw_release": + projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT) + break render_battle_feedback(actor_width, actor_height) update_battle_feedback_visual() @@ -1395,11 +1488,18 @@ 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 preview_order := guild_preview_order() + var preview_names := ["GuildFront", "GuildRear", "GuildRecruitPreview"] + for index in range(preview_order.size()): + var member_id: String = preview_order[index] + var actor_scale: float = [1.0, 0.9, 0.82][index] + var actor := actor_at( + animation_texture(member_id, "idle", victory_clock), + Vector2(size.x * (0.055 + 0.135 * index), size.y - actor_height * actor_scale - 34), + Vector2(actor_height * actor_scale, actor_height * actor_scale), + preview_names[index] + ) + actor.set_meta("member_id", member_id) var resources := PanelContainer.new() resources.name = "GuildResources" @@ -1441,7 +1541,9 @@ func guild_choice_button(id: String) -> Button: 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())) + var recruit_id := session_service.mapped_guild_recruit() + var position := guild_recruit_position(recruit_id) + button.text = tr_text("+ 招募%s · 14 金币\n小队扩至 3 人|剩余 6 金币\n加入%s,系统按职责重新编队" % [candidate_name(recruit_id), position], "+ RECRUIT %s · 14 Coin\nCompany grows to 3 | 6 Coin left\nJoins as %s; system reforms by role" % [candidate_name(recruit_id), position]) button.focus_mode = Control.FOCUS_ALL button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.custom_minimum_size = Vector2(215, 112) @@ -1458,10 +1560,7 @@ 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") + render_visible_party("Session", Vector2(size.x * 0.05, size.y - actor_height - 38), actor_height) 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" @@ -1479,7 +1578,8 @@ func render_session_result() -> void: 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")) + var lead_id := visible_member_id(0) + var facts := centered_label(tr_text("金币 %d | 小队 %d 人\n先行者%s %d / 100 | 用时 %.2f 秒" % [coin, company_size, candidate_name(lead_id), session_state.member_health(lead_id), third_result_time], "Coin %d | Company %d\nLead %s %d / 100 | Time %.2f s" % [coin, company_size, candidate_name(lead_id), session_state.member_health(lead_id), third_result_time]), 12, Color("#6d5523")) facts.name = "SessionResultFacts" box.add_child(facts) box.add_child(game_button("OpenRouteBoardButton", tr_text("查看下一轮三条路线", "Compare the next three routes"), open_route_board, true)) @@ -1490,10 +1590,7 @@ func render_route_board() -> void: add_full_wash(Color(0.015, 0.03, 0.07, 0.25)) render_hud(tr_text("第二轮 · 选择下一条路线", "Second Cycle · Choose the next route")) var actor_height: float = clampf(size.y * 0.22, 82.0, 138.0) - actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(20, size.y - actor_height - 24), Vector2(actor_height, actor_height), "RouteFront") - actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(100, size.y - actor_height * 0.90 - 24), Vector2(actor_height * 0.90, actor_height * 0.90), "RouteRear") - if not added_rear.is_empty(): - actor_at(animation_texture(added_rear, "idle", victory_clock), Vector2(176, size.y - actor_height * 0.84 - 24), Vector2(actor_height * 0.84, actor_height * 0.84), "RouteAddedRear") + render_visible_party("Route", Vector2(20, size.y - actor_height - 24), actor_height, 80.0) var build_panel := PanelContainer.new() build_panel.name = "RouteBuildSummary" build_panel.position = Vector2(16, 72) @@ -1601,10 +1698,7 @@ func render_second_cycle_result() -> void: add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) render_hud(tr_text("第二轮路线完成", "Second-Cycle Route Complete")) var actor_height: float = clampf(size.y * 0.28, 96.0, 176.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 - 36), Vector2(actor_height, actor_height), "CycleResultFront") - actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.90 - 36), Vector2(actor_height * 0.90, actor_height * 0.90), "CycleResultRear") - 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 - 36), Vector2(actor_height * 0.84, actor_height * 0.84), "CycleResultAddedRear") + render_visible_party("CycleResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height) actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "CycleResultEnemy") var result := PanelContainer.new() result.name = "SecondCycleResultPanel" @@ -1621,7 +1715,9 @@ func render_second_cycle_result() -> void: explanation.name = "SecondCycleExplanation" 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 人 | 前排 %d 后排 %d | %.2f 秒" % [route_reward(route_choice), coin, company_size, front_hp, rear_hp, fourth_result_time], "Reward +%d Coin | Current %d\nCompany %d | Front %d Rear %d | %.2f s" % [route_reward(route_choice), coin, company_size, front_hp, rear_hp, fourth_result_time]), 11, Color("#6d5523")) + var lead_id := visible_member_id(0) + var marked_id := session_state.marked_member_id() + var facts := centered_label(tr_text("奖励 +%d 金币 | 当前金币 %d\n先行%s %d 标记%s %d | %.2f 秒" % [route_reward(route_choice), coin, candidate_name(lead_id), session_state.member_health(lead_id), candidate_name(marked_id), session_state.member_health(marked_id), fourth_result_time], "Reward +%d Coin | Current %d\nLead %s %d Marked %s %d | %.2f s" % [route_reward(route_choice), coin, candidate_name(lead_id), session_state.member_health(lead_id), candidate_name(marked_id), session_state.member_health(marked_id), fourth_result_time]), 11, Color("#6d5523")) facts.name = "SecondCycleFacts" box.add_child(facts) box.add_child(game_button("OpenTrainingButton", tr_text("使用历练印记培养一名成员", "Train one member with the Veteran Mark"), open_training, true)) @@ -1639,7 +1735,8 @@ func render_training() -> void: var slot: String = slots[index] var profession := member_profession(slot) var actor_x := 18.0 + slot_width * float(index) + (slot_width - actor_height) * 0.5 - actor_at(animation_texture(profession, "idle", victory_clock), Vector2(actor_x, 82), Vector2(actor_height, actor_height), "TrainingActor_%s" % slot) + var actor := actor_at(animation_texture(profession, "idle", victory_clock), Vector2(actor_x, 82), Vector2(actor_height, actor_height), "TrainingActor_%s" % slot) + actor.set_meta("member_id", profession) var choice := training_button(slot) choice.position = Vector2(20.0 + slot_width * float(index), 205) choice.size = Vector2(slot_width - 8.0, 118) @@ -1671,6 +1768,7 @@ func training_button(slot: String) -> Button: var selected := training_choice == slot var button := Button.new() button.name = "Training_%s" % slot.capitalize() + button.set_meta("member_id", profession) button.text = "%s · %s\nLv.1 → Lv.2\n%s" % [member_slot_label(slot), candidate_name(profession), training_effect_short(slot)] button.focus_mode = Control.FOCUS_ALL button.custom_minimum_size = Vector2(146, 108) @@ -1718,10 +1816,7 @@ func render_third_cycle_result() -> void: add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) render_hud(tr_text("第三轮成长完成", "Third-Cycle Growth Complete")) var actor_height: float = clampf(size.y * 0.28, 96.0, 176.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 - 36), Vector2(actor_height, actor_height), "GrowthResultFront") - actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.90 - 36), Vector2(actor_height * 0.90, actor_height * 0.90), "GrowthResultRear") - 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 - 36), Vector2(actor_height * 0.84, actor_height * 0.84), "GrowthResultAddedRear") + render_visible_party("GrowthResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height) actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "GrowthResultEnemy") var result := PanelContainer.new() result.name = "ThirdCycleResultPanel" @@ -1738,7 +1833,9 @@ func render_third_cycle_result() -> void: explanation.name = "ThirdCycleExplanation" box.add_child(explanation) var company_size := member_slots().size() - var facts := centered_label(tr_text("奖励 +%d 金币 | 当前金币 %d\n小队 %d 人 | 前排 %d 后排 %d | %.2f 秒" % [FIFTH_BATTLE_REWARD, coin, company_size, front_hp, rear_hp, fifth_result_time], "Reward +%d Coin | Current %d\nCompany %d | Front %d Rear %d | %.2f s" % [FIFTH_BATTLE_REWARD, coin, company_size, front_hp, rear_hp, fifth_result_time]), 11, Color("#6d5523")) + var lead_id := visible_member_id(0) + var marked_id := session_state.marked_member_id() + var facts := centered_label(tr_text("奖励 +%d 金币 | 当前金币 %d\n先行%s %d 标记%s %d | %.2f 秒" % [FIFTH_BATTLE_REWARD, coin, candidate_name(lead_id), session_state.member_health(lead_id), candidate_name(marked_id), session_state.member_health(marked_id), fifth_result_time], "Reward +%d Coin | Current %d\nLead %s %d Marked %s %d | %.2f s" % [FIFTH_BATTLE_REWARD, coin, candidate_name(lead_id), session_state.member_health(lead_id), candidate_name(marked_id), session_state.member_health(marked_id), fifth_result_time]), 11, Color("#6d5523")) facts.name = "ThirdCycleFacts" box.add_child(facts) var levels := centered_label(member_levels_summary(), 10, Color("#6d5523")) @@ -2129,13 +2226,8 @@ func commit_phase_impact(phase: int, refresh_runtime := true) -> void: func legacy_feedback_target(member_id: String) -> String: if member_id in ["enemy", "front", "rear", "added"]: return member_id - if member_id == session_state.lead_id(): - return "front" - if member_id == session_state.marked_member_id(): - return "rear" - if member_id == added_rear: - return "added" - return member_id + var slot := visible_member_slot(member_id) + return slot if not slot.is_empty() else member_id func commit_second_phase_impact(phase: int, refresh_runtime := true) -> void: @@ -2219,14 +2311,20 @@ func _process(delta: float) -> void: 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) + var later_cycle := page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE] + var visible_front := visible_member_id(0) + var visible_rear := visible_member_id(1) + var visible_third := visible_member_id(2) + front_sprite.texture = animation_texture(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock) + rear_sprite.texture = animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else 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) + added_rear_sprite.texture = animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock) enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock) - var projectile_actor_is_active := (battle_phase == 3 and rear_choice == "Farstring") or (page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] and battle_phase == 4 and added_rear == "Farstring") - if page == AppPage.FIFTH_BATTLE: - projectile_actor_is_active = (battle_phase == 1 and trained_profession() == "Farstring") or (battle_phase == 4 and rear_choice == "Farstring" and trained_member != "rear") or (battle_phase == 5 and added_rear == "Farstring" and trained_member != "added") + var projectile_actor_is_active := false + for member_id in visible_member_ids(): + if (later_battle_animation_state(member_id) if later_cycle else (rear_animation_state() if member_id == visible_rear else front_animation_state())) == "draw_release": + projectile_actor_is_active = true + break 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(battle_phase) + 0.10 @@ -2530,18 +2628,21 @@ func second_battle_event_text() -> String: func third_battle_event_text() -> String: + var lead_id := visible_member_id(0) + var second_id := visible_member_id(1) + var third_id := visible_member_id(2) 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") + return tr_text("晨钟先手:%s抢在队长之前行动" % candidate_name(lead_id), "Early Bell: %s acts before the captain" % candidate_name(lead_id)) if retained_item == "EQ-061" else tr_text("队长蓄力,准备攻击先行者%s" % candidate_name(lead_id), "The captain gathers force against lead %s" % candidate_name(lead_id)) 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") + return tr_text("钉阵胫甲让%s守住阵线,重击只造成 -10" % candidate_name(lead_id), "Frontpin lets %s hold; the strike deals only -10" % candidate_name(lead_id)) if retained_item == "EQ-002" else tr_text("队长重击%s;现在需要队友支援" % candidate_name(lead_id), "The captain hits %s; support is needed" % candidate_name(lead_id)) 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") + return support_action_text(second_id, lead_id, "第二位", "second position") 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") + if not third_id.is_empty(): + return support_action_text(third_id, lead_id, "第三位", "third position") 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") @@ -2551,23 +2652,27 @@ func third_battle_event_text() -> String: func fourth_battle_event_text() -> String: + var lead_id := visible_member_id(0) + var marked_id := session_state.marked_member_id() + var second_id := visible_member_id(1) + var third_id := visible_member_id(2) match battle_phase: 0: return tr_text("小队进入%s…" % route_name(route_choice), "The Company enters %s…" % route_name(route_choice)) 1: if route_choice == "R6-01": - return tr_text("路线守卫蓄力:正面粉碎即将命中前排", "The Warden charges a front crush") + return tr_text("路线守卫蓄力:正面粉碎即将命中%s" % candidate_name(lead_id), "The Warden charges a front crush against %s" % candidate_name(lead_id)) if route_choice == "R6-02": return tr_text("晨钟抢到先手" if selected_build_profile() == "tempo" else "敌方速度爆发抢先行动", "Early Bell seizes initiative" if selected_build_profile() == "tempo" else "Enemy tempo bursts first") - return tr_text("双辉标记锁定了原后排", "Twinflare marks the original rear") + return tr_text("双辉标记锁定了%s" % candidate_name(marked_id), "Twinflare marks %s" % candidate_name(marked_id)) 2: if route_choice == "R6-03": - return tr_text("分裂打击越过前排,命中后排 -35", "The split strike bypasses the front and hits rear -35") - return tr_text("钉阵胫甲把粉碎降为 -10" if route_choice == "R6-01" and selected_build_profile() == "guarded" else "路线压力命中前排", "Frontpin reduces the crush to -10" if route_choice == "R6-01" and selected_build_profile() == "guarded" else "Route pressure hits the front") + return tr_text("分裂打击越过%s,命中%s -35" % [candidate_name(lead_id), candidate_name(marked_id)], "The split strike bypasses %s and hits %s for -35" % [candidate_name(lead_id), candidate_name(marked_id)]) + return tr_text("钉阵胫甲把%s受到的粉碎降为 -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "路线压力命中%s" % candidate_name(lead_id), "Frontpin reduces %s's crush to -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "Route pressure hits %s" % candidate_name(lead_id)) 3: - return tr_text("原弓手从后排反击", "The original Archer counters") if rear_choice == "Farstring" else tr_text("原治疗修复受损阵线", "The original Healer restores the damaged line") + return support_action_text(second_id, marked_id if route_choice == "R6-03" else lead_id, "第二位", "second position") 4: - return tr_text("第三名成员补上第二种后排能力", "The third member adds the missing rear response") if not added_rear.is_empty() else tr_text("保留构筑发动第二次反击", "The retained build follows up") + return support_action_text(third_id, marked_id if route_choice == "R6-03" else lead_id, "第三位", "third position") if not third_id.is_empty() else tr_text("保留构筑发动第二次反击", "The retained build follows up") 5: return tr_text("小队突破路线守卫的防线", "The Company breaks the route defense") 6: @@ -2576,30 +2681,47 @@ func fourth_battle_event_text() -> String: func fifth_battle_event_text() -> String: + var lead_id := visible_member_id(0) + var marked_id := session_state.marked_member_id() + var supports := support_member_ids() + var first_support := supports[0] if not supports.is_empty() else "" + var second_support := supports[1] if supports.size() > 1 else "" match battle_phase: 0: return tr_text("Lv.2 成员带队进入双压关隘…", "The Lv.2 member leads into Twin-Pressure Pass…") 1: - return tr_text("历练弓手提前射击,但本场仍只行动一次", "The trained Archer fires early, but still acts only once") if trained_profession() == "Farstring" else tr_text("双压守卫同时锁定前排与原后排", "The Twin Warden marks the front and original rear") + return tr_text("历练弓手%s提前射击,但本场仍只行动一次" % candidate_name(trained_profession()), "Trained Archer %s fires early, but still acts only once" % candidate_name(trained_profession())) if trained_profession() == "Farstring" else tr_text("双压守卫同时锁定%s与%s" % [candidate_name(lead_id), candidate_name(marked_id)], "The Twin Warden marks %s and %s" % [candidate_name(lead_id), candidate_name(marked_id)]) 2: - return tr_text("前排历练额外减少 20 伤害", "Front training removes 20 more damage") if trained_member == "front" else tr_text("前排承受第一道正面重压", "The front absorbs the first pressure") + return tr_text("%s的历练额外减少 20 伤害" % candidate_name(lead_id), "%s's training removes 20 more damage" % candidate_name(lead_id)) if trained_profession() == lead_id else tr_text("%s承受第一道正面重压" % candidate_name(lead_id), "%s absorbs the first pressure" % candidate_name(lead_id)) 3: - return tr_text("分裂压力越过阵线,原后排受到 -40", "Split pressure bypasses the line; original rear takes -40") + return tr_text("分裂压力越过阵线,%s受到 -40" % candidate_name(marked_id), "Split pressure bypasses the line; %s takes -40" % candidate_name(marked_id)) 4: - if trained_member == "rear" and rear_choice == "Farstring": + if trained_profession() == first_support and first_support == "Farstring": return tr_text("历练弓手已在第一步行动,本步不重复攻击", "The trained Archer already acted; no duplicate attack") - return tr_text("原后排用 +25 强疗恢复", "The original rear restores +25 with trained healing") if rear_choice == "Kindhand" and trained_member == "rear" else (tr_text("原后排恢复 +12", "The original rear restores +12") if rear_choice == "Kindhand" else tr_text("原弓手完成本场唯一射击", "The original Archer takes its one shot")) + return support_action_text(first_support, marked_id, "第二位", "second position", trained_profession() == first_support) 5: - if not added_rear.is_empty(): - if trained_member == "added" and added_rear == "Farstring": + if not second_support.is_empty(): + if trained_profession() == second_support and second_support == "Farstring": return tr_text("历练弓手已提前行动,不会重复射击", "The trained Archer acted early and does not fire twice") - return tr_text("增援治疗用 +25 强疗恢复", "The added Healer restores +25") if added_rear == "Kindhand" and trained_member == "added" else (tr_text("增援治疗恢复 +12", "The added Healer restores +12") if added_rear == "Kindhand" else tr_text("增援弓手完成本场唯一射击", "The added Archer takes its one shot")) + return support_action_text(second_support, marked_id, "第三位", "third position", trained_profession() == second_support) return tr_text("两人小队发动保留构筑追击", "The two-person line triggers its retained-build follow-up") 6: return tr_text("双压守卫被击败;成长结果已验证", "Twin Warden defeated; growth result verified") return "" +func support_action_text(member_id: String, target_id: String, position_zh: String, position_en: String, trained := false) -> String: + match member_id: + "Wayrunner": + return tr_text("%s%s发动侧翼反击" % [position_zh, candidate_name(member_id)], "%s in %s launches a flank counter" % [candidate_name(member_id), position_en]) + "Farstring": + return tr_text("%s%s完成远程射击" % [position_zh, candidate_name(member_id)], "%s in %s fires a ranged shot" % [candidate_name(member_id), position_en]) + "Kindhand": + var amount := 25 if trained else 12 + return tr_text("%s%s为%s恢复 +%d" % [position_zh, candidate_name(member_id), candidate_name(target_id), amount], "%s in %s restores +%d to %s" % [candidate_name(member_id), position_en, amount, candidate_name(target_id)]) + return tr_text("%s%s完成职责行动" % [position_zh, candidate_name(member_id)], "%s in %s completes the role action" % [candidate_name(member_id), position_en]) + + func selected_build_profile() -> String: if not added_rear.is_empty(): return "full_line" @@ -2607,42 +2729,47 @@ func selected_build_profile() -> String: func member_slots() -> Array[String]: - var slots: Array[String] = ["front", "rear"] - if not added_rear.is_empty(): - slots.append("added") + var slots: Array[String] = [] + for index in range(visible_member_ids().size()): + slots.append(["front", "rear", "added"][index]) return slots func member_profession(slot: String) -> String: match slot: "front": - return front_choice + return visible_member_id(0) "rear": - return rear_choice + return visible_member_id(1) "added": - return added_rear + return visible_member_id(2) return "" func member_slot_label(slot: String) -> String: + var member_id := member_profession(slot) + var origin := tr_text("公会加入", "Guild recruit") if member_id == added_rear and not added_rear.is_empty() else tr_text("初始成员", "Starting member") match slot: "front": - return tr_text("前排", "Front") + return tr_text("先行位 · %s" % origin, "Lead · %s" % origin) "rear": - return tr_text("原后排", "Original rear") + return tr_text("第二位 · %s" % origin, "Second · %s" % origin) "added": - return tr_text("增援后排", "Added rear") + return tr_text("第三位 · %s" % origin, "Third · %s" % origin) return "" func member_level(slot: String) -> int: if slot not in member_slots(): return 0 - return 2 if training_committed and slot == trained_member else 1 + var member_id := member_profession(slot) + return int(session_state.member_levels.get(member_id, 2 if training_committed and slot == trained_member else 1)) func trained_profession() -> String: - return member_profession(trained_member) if training_committed else "" + if not training_committed: + return "" + return session_state.trained_member_id if not session_state.trained_member_id.is_empty() else member_profession(trained_member) func training_color(slot: String) -> Color: @@ -2842,8 +2969,17 @@ func later_preview_supports_pair() -> bool: return formation_commit_count > 0 or (front_choice in ["Hearthguard", "Wayrunner"] and rear_choice in ["Farstring", "Kindhand"]) -func missing_rear_profession() -> String: - return "Kindhand" if rear_choice == "Farstring" else "Farstring" +func guild_preview_order() -> Array[String]: + if guild_choice != "recruit_rear": + return visible_member_ids() + return session_service.ordered_with_addition(session_service.mapped_guild_recruit()) + + +func guild_recruit_position(recruit_id: String) -> String: + var index := session_service.ordered_with_addition(recruit_id).find(recruit_id) + var labels_zh := ["先行位", "第二位", "第三位"] + var labels_en := ["lead", "second", "third"] + return (labels_zh if locale == "zh_CN" else labels_en)[index] if index >= 0 and index < 3 else "" func guild_choice_guidance() -> String: @@ -2851,7 +2987,11 @@ func guild_choice_guidance() -> String: 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())) + var recruit_id := session_service.mapped_guild_recruit() + var order_names: Array[String] = [] + for member_id in guild_preview_order(): + order_names.append(candidate_name(member_id)) + return tr_text("已选择招募%s;她成为%s。新顺序:%s;原两人相对顺序不变。" % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " → ".join(order_names)], "Recruit %s selected as %s. New order: %s; the original pair keeps its relative order." % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " -> ".join(order_names)]) func session_result_explanation() -> String: @@ -2919,6 +3059,25 @@ func actor_at(texture: Texture2D, actor_position: Vector2, actor_size: Vector2, return actor +func render_visible_party(prefix: String, start_position: Vector2, actor_height: float, spacing := -1.0) -> Array[TextureRect]: + var actors: Array[TextureRect] = [] + var resolved_spacing := spacing if spacing >= 0.0 else actor_height * 0.88 + var node_suffixes := ["Front", "Rear", "AddedRear"] + for index in range(visible_member_ids().size()): + var member_id := visible_member_id(index) + var scale: float = [1.0, 0.90, 0.84][index] + var actor_size: float = actor_height * scale + var actor := actor_at( + animation_texture(member_id, "victory" if index == 0 and member_id == "Hearthguard" else "idle", victory_clock), + start_position + Vector2(resolved_spacing * index, actor_height - actor_size), + Vector2(actor_size, actor_size), + prefix + node_suffixes[index] + ) + actor.set_meta("member_id", member_id) + actors.append(actor) + return actors + + func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> TextureRect: var image := TextureRect.new() image.name = "BattleVFX" @@ -2937,8 +3096,8 @@ func render_battle_feedback(actor_width: float, actor_height: float) -> void: return var target: String = active_battle_feedback["target"] var delta: int = int(active_battle_feedback["delta"]) - var target_position := front_base_position if target == "front" else (rear_base_position if target == "rear" else enemy_base_position) - var target_scale := 1.0 if target == "front" else (0.90 if target == "rear" else 0.82) + var target_position := front_base_position if target == "front" else (rear_base_position if target == "rear" else (added_rear_base_position if target == "added" else enemy_base_position)) + var target_scale := 1.0 if target == "front" else (0.90 if target == "rear" else (0.86 if target == "added" else 0.82)) var feedback_width := clampf(size.x * 0.20, 170.0, 260.0) battle_feedback_origin = Vector2( clampf(target_position.x + actor_width * target_scale * 0.5 - feedback_width * 0.5, 12.0, size.x - feedback_width - 12.0), diff --git a/runtime/domain/p9_1/session_service.gd b/runtime/domain/p9_1/session_service.gd index a78f9304..cf54be67 100644 --- a/runtime/domain/p9_1/session_service.gd +++ b/runtime/domain/p9_1/session_service.gd @@ -194,7 +194,7 @@ func commit_guild_response() -> Dictionary: var cost := 12 if state.guild_response == "retain_loan" else 14 if state.coin < cost: return _reject(Contracts.INSUFFICIENT_COIN) - var mapped_recruit := _mapped_guild_recruit() if state.guild_response == "add_recruit" else "" + var mapped_recruit := mapped_guild_recruit() if state.guild_response == "add_recruit" else "" if state.guild_response == "add_recruit" and (mapped_recruit.is_empty() or mapped_recruit in state.field_order): return _reject(Contracts.INVALID_ID) state.guild_committed = true @@ -427,7 +427,7 @@ func _domain_context(compat_context: Dictionary = {}) -> Dictionary: "trained_slot": compat_context.get("trained_slot", state.trained_member), } -func _mapped_guild_recruit() -> String: +func mapped_guild_recruit() -> String: var key := "%s:%s" % [state.front_choice + ":" + state.rear_choice, state.adaptation_choice] return { "Hearthguard:Wayrunner:EQ-002": "Farstring", diff --git a/runtime/tests/p9_1/real_formation_ui_test.gd b/runtime/tests/p9_1/real_formation_ui_test.gd new file mode 100644 index 00000000..88c038e1 --- /dev/null +++ b/runtime/tests/p9_1/real_formation_ui_test.gd @@ -0,0 +1,197 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") +const VIEWPORT_SIZE := Vector2i(844, 390) + +var failures: Array[String] = [] +var capture_root := "" +var viewport: SubViewport + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + capture_root = OS.get_environment("ABG_P9_1_FORMATION_CAPTURE_ROOT") + if not capture_root.is_empty(): + DirAccess.make_dir_recursive_absolute(capture_root) + 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(10) + game.locale = "zh_CN" + game.muted = true + game.boot_verified = true + game.page = game.AppPage.MAIN_MENU + game.render() + + start_pair(game, "Farstring", "Kindhand") + resolve_battle(game, 6) + expect(game.page == game.AppPage.VICTORY, "exceptional pair wins battle one") + game.open_adaptation() + game.choose_adaptation("EQ-002") + game.begin_second_battle() + resolve_battle(game, 6) + expect(game.page == game.AppPage.SECOND_RESULT, "exceptional pair reaches the Guild handoff") + + game.open_guild_return() + expect(game.facade.session_service.mapped_guild_recruit() == "Hearthguard", "Guild domain maps Hearthguard for Farstring and Kindhand") + var recruit_button := find_control(game, "GuildChoice_recruit_rear") as Button + expect(recruit_button != null and recruit_button.text.contains(game.candidate_name("Hearthguard")), "unselected Guild option names Hearthguard") + expect(recruit_button != null and recruit_button.text.contains("先行位"), "unselected Guild option previews Hearthguard as lead") + game.choose_guild_option("recruit_rear") + expect(game.facade.guild_preview_order() == ["Hearthguard", "Farstring", "Kindhand"], "selected Guild preview uses post-recruit order") + expect(game.facade.guild_choice_guidance().contains("艾连") and game.facade.guild_choice_guidance().contains("艾连 → 妮娅 → 赛布尔"), "Guild guidance names recruit and complete order") + expect(member_meta(game, "GuildFront") == "Hearthguard", "Guild preview puts the recruit at lead") + expect(member_meta(game, "GuildRear") == "Farstring", "Guild preview moves Farstring to second") + expect(member_meta(game, "GuildRecruitPreview") == "Kindhand", "Guild preview keeps Kindhand after Farstring") + await capture_state("01-guild-reformation") + + game.begin_third_battle() + expect(game.facade.session_state.field_order == ["Hearthguard", "Farstring", "Kindhand"], "Guild commit produces the previewed order") + expect(game.added_rear == "Hearthguard", "stable origin records Hearthguard as the Guild recruit") + expect(member_meta(game, "FrontActor") == "Hearthguard", "battle three lead actor is Hearthguard") + expect(member_meta(game, "RearActor") == "Farstring", "battle three second actor is Farstring") + expect(member_meta(game, "AddedRearActor") == "Kindhand", "battle three third actor is Kindhand") + expect(control_text(game, "FrontHealth").contains("艾连"), "lead health bar names Hearthguard") + game.advance_battle_phase() + await capture_state("02-battle-three") + + game.advance_battle_phase() + game.commit_phase_impact(2) + expect(game.active_battle_feedback.get("target", "") == "front", "Hearthguard damage feedback lands on the current lead") + resolve_remaining_battle(game, 2, 7) + expect(game.page == game.AppPage.SESSION_RESULT, "re-formed line reaches battle-three result") + + game.open_route_board() + expect(member_meta(game, "RouteFront") == "Hearthguard", "route board keeps Hearthguard at lead") + expect(member_meta(game, "RouteRear") == "Farstring", "route board keeps Farstring second") + expect(member_meta(game, "RouteAddedRear") == "Kindhand", "route board keeps Kindhand third") + await capture_state("03-route-board") + game.choose_route("R6-03") + game.open_route_review() + game.commit_route() + game.advance_battle_phase() + game.advance_battle_phase() + game.commit_phase_impact(2) + expect(game.facade.session_state.marked_member_id() == "Kindhand", "route pressure retains Kindhand's stable identity") + expect(game.active_battle_feedback.get("target", "") == "added", "Kindhand feedback lands on her current third position") + resolve_remaining_battle(game, 2, 7) + expect(game.page == game.AppPage.SECOND_CYCLE_RESULT, "re-formed line reaches battle-four result") + + game.open_training() + expect(training_member(game, "Training_Front") == "Hearthguard", "lead training control maps Hearthguard") + expect(training_member(game, "Training_Rear") == "Farstring", "second training control maps Farstring") + expect(training_member(game, "Training_Added") == "Kindhand", "third training control maps Kindhand") + expect(member_meta(game, "TrainingActor_front") == "Hearthguard", "lead training actor matches its control") + expect(member_meta(game, "TrainingActor_rear") == "Farstring", "second training actor matches its control") + expect(member_meta(game, "TrainingActor_added") == "Kindhand", "third training actor matches its control") + await capture_state("04-training") + game.choose_training("front") + game.open_training_review() + game.commit_training() + resolve_battle(game, 7) + expect(game.page == game.AppPage.THIRD_CYCLE_RESULT, "re-formed line reaches the final result") + expect(member_meta(game, "GrowthResultFront") == "Hearthguard", "final result keeps Hearthguard at lead") + expect(member_meta(game, "GrowthResultRear") == "Farstring", "final result keeps Farstring second") + expect(member_meta(game, "GrowthResultAddedRear") == "Kindhand", "final result keeps Kindhand third") + await capture_state("05-final-result") + + game.clear_experience() + game.page = game.AppPage.MAIN_MENU + game.render() + start_pair(game, "Hearthguard", "Farstring") + expect(game.facade.visible_member_ids() == ["Hearthguard", "Farstring"], "standard pair remains a two-person line") + expect(member_meta(game, "FrontActor") == "Hearthguard" and member_meta(game, "RearActor") == "Farstring", "standard pair actor mapping is unchanged") + expect(find_control(game, "AddedRearActor") == null, "standard pair has no third actor") + + game.queue_free() + viewport.queue_free() + await settle(90) + if failures.is_empty(): + print("ABG_P9_1_REAL_FORMATION_UI_OK preview=HG,FS,KH battle=HG,FS,KH feedback=lead,third training=HG,FS,KH standard=HG,FS") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func start_pair(game: Control, first: String, second: String) -> void: + game.start_new_game() + game.open_recruit_selection() + game.toggle_recruit(first) + game.toggle_recruit(second) + game.review_automatic_formation() + game.commit_formation() + + +func resolve_battle(game: Control, phase_count: int) -> void: + for phase in range(phase_count): + game.advance_battle_phase() + + +func resolve_remaining_battle(game: Control, current_phase: int, phase_count: int) -> void: + for phase in range(current_phase, phase_count): + game.advance_battle_phase() + + +func training_member(game: Control, node_name: String) -> String: + var control := find_control(game, node_name) + return String(control.get_meta("member_id", "")) if control != null else "" + + +func capture_state(state_name: String) -> void: + if capture_root.is_empty(): + return + await settle(7) + await RenderingServer.frame_post_draw + var image := viewport.get_texture().get_image() + var error := image.save_png(capture_root.path_join(state_name + ".png")) + if error != OK: + failures.append("P9.1 real formation UI: capture %s failed with %s" % [state_name, error]) + + +func member_meta(game: Control, node_name: String) -> String: + var control := find_control(game, node_name) + return String(control.get_meta("member_id", "")) if control != null else "" + + +func control_text(game: Control, node_name: String) -> String: + var control := find_control(game, node_name) + return collect_text(control) if control != null else "" + + +func collect_text(node: Node) -> String: + var parts: Array[String] = [] + if node is Label or node is Button: + parts.append(node.text) + for child in node.get_children(): + parts.append(collect_text(child)) + return "\n".join(parts) + + +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.1 real formation UI: %s" % message)