feat(runtime): render real Guild formation

This commit is contained in:
2026-08-13 23:19:45 +08:00
parent f44093a32f
commit bbad4e2df8
3 changed files with 445 additions and 89 deletions
+246 -87
View File
@@ -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),
+2 -2
View File
@@ -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",
@@ -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)