feat: add second-cycle route decision

This commit is contained in:
2026-08-13 03:27:27 +08:00
parent 7941227721
commit 3620e5a7cf
7 changed files with 1020 additions and 18 deletions
+600 -18
View File
@@ -62,6 +62,37 @@ const THIRD_BATTLE_PHASE_DURATIONS := {
"EQ-061": [1.10, 0.70, 0.90, 0.90, 0.90, 0.90, 1.20],
"recruit": [1.10, 0.80, 0.90, 0.80, 0.80, 1.10, 1.50],
}
const FOURTH_BATTLE_PHASE_DURATIONS := {
"R6-01:guarded": [1.10, 0.80, 1.00, 0.90, 1.00, 1.10, 1.50],
"R6-01:tempo": [1.00, 0.70, 0.80, 0.80, 0.80, 0.90, 1.40],
"R6-01:full_line": [1.00, 0.80, 0.90, 0.90, 0.90, 1.00, 1.50],
"R6-02:guarded": [1.20, 1.00, 1.10, 1.00, 1.10, 1.10, 1.50],
"R6-02:tempo": [1.00, 0.60, 0.80, 0.70, 0.70, 0.90, 1.30],
"R6-02:full_line": [1.00, 0.70, 0.90, 0.80, 0.80, 1.00, 1.60],
"R6-03:guarded": [1.10, 0.90, 1.00, 1.00, 1.00, 1.20, 1.60],
"R6-03:tempo": [1.00, 0.70, 0.90, 0.80, 0.80, 1.00, 1.50],
"R6-03:full_line": [1.00, 0.80, 0.90, 0.90, 0.90, 1.00, 1.50],
}
const ROUTES := {
"R6-01": {
"name_zh": "风墙长堤", "name_en": "Windwall Causeway",
"pressure_zh": "队长正面粉碎", "pressure_en": "Captain front crush",
"exposure_zh": "低暴露", "exposure_en": "Low exposure",
"reward": 4,
},
"R6-02": {
"name_zh": "钟晶疾道", "name_en": "Bellglass Run",
"pressure_zh": "敌方速度爆发", "pressure_en": "Enemy tempo burst",
"exposure_zh": "中暴露", "exposure_en": "Medium exposure",
"reward": 7,
},
"R6-03": {
"name_zh": "双辉裂谷", "name_en": "Twinflare Ravine",
"pressure_zh": "分裂后排打击", "pressure_en": "Split rear strike",
"exposure_zh": "高暴露", "exposure_en": "High exposure",
"reward": 10,
},
}
const FIRST_VICTORY_COIN := 20
const RETAIN_ITEM_COST := 12
const RECRUIT_REAR_COST := 14
@@ -118,7 +149,7 @@ const CANDIDATES := {
},
}
enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT }
enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT, ROUTE_BOARD, ROUTE_REVIEW, FOURTH_BATTLE, SECOND_CYCLE_RESULT }
const COLOR_INK := Color("#10182c")
const COLOR_INDIGO := Color("#172745")
@@ -172,6 +203,12 @@ var retained_item := ""
var added_rear := ""
var third_battle_elapsed := 0.0
var third_result_time := 0.0
var rear_hp := 100
var route_choice := ""
var route_committed := false
var route_reward_committed := false
var fourth_battle_elapsed := 0.0
var fourth_result_time := 0.0
var animation_library: Dictionary = {}
var victory_clock := 0.0
var active_battle_feedback: Dictionary = {}
@@ -290,6 +327,8 @@ func animation_texture(character: String, state: String, elapsed: float) -> Text
func front_animation_state() -> String:
if page == AppPage.FOURTH_BATTLE and selected_build_profile() == "tempo" and route_choice == "R6-02" and battle_phase == 1:
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1:
@@ -305,6 +344,8 @@ func front_animation_state() -> String:
func rear_animation_state() -> String:
if page == AppPage.FOURTH_BATTLE and battle_phase == 3:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
if page == AppPage.THIRD_BATTLE and battle_phase == 3:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
match battle_phase:
@@ -316,7 +357,7 @@ func rear_animation_state() -> String:
func added_rear_animation_state() -> String:
if page == AppPage.THIRD_BATTLE and battle_phase == 4:
if page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] and battle_phase == 4:
return "draw_release" if added_rear == "Farstring" else "lantern_heal"
if battle_phase == 0:
return "advance"
@@ -324,6 +365,20 @@ func added_rear_animation_state() -> String:
func enemy_animation_state() -> String:
if page == AppPage.FOURTH_BATTLE:
if selected_build_profile() == "tempo" and route_choice == "R6-02" and battle_phase == 1:
return "hit"
match battle_phase:
0:
return "advance"
1:
return "telegraph"
2:
return "attack"
3, 4, 5:
return "hit"
6:
return "death"
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
return "hit"
if page == AppPage.THIRD_BATTLE:
@@ -377,7 +432,7 @@ func render() -> void:
projectile = null
for child in display_root.get_children():
child.free()
var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT] else YARD_BACKGROUND
var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.FOURTH_BATTLE, AppPage.SECOND_CYCLE_RESULT] else YARD_BACKGROUND
add_background(background_texture)
if portrait_layout():
render_portrait_notice()
@@ -405,6 +460,14 @@ func render() -> void:
render_battle()
AppPage.SESSION_RESULT:
render_session_result()
AppPage.ROUTE_BOARD:
render_route_board()
AppPage.ROUTE_REVIEW:
render_route_review()
AppPage.FOURTH_BATTLE:
render_battle()
AppPage.SECOND_CYCLE_RESULT:
render_second_cycle_result()
if restart_confirmation:
render_restart_confirmation()
@@ -613,7 +676,8 @@ func render_battle() -> void:
add_full_wash(Color(0.015, 0.025, 0.055, 0.1))
var second_cycle := page == AppPage.SECOND_BATTLE
var third_cycle := page == AppPage.THIRD_BATTLE
var battle_title := tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))
var fourth_cycle := page == AppPage.FOURTH_BATTLE
var battle_title := 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
var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0)
@@ -625,12 +689,14 @@ func render_battle() -> void:
enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84)
front_sprite = actor_at(animation_texture(front_choice, front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor")
rear_sprite = actor_at(animation_texture(rear_choice, rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor")
if third_cycle and not added_rear.is_empty():
if (third_cycle or fourth_cycle) and not added_rear.is_empty():
added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor")
enemy_sprite = actor_at(animation_texture("Riftblade", enemy_animation_state(), battle_clock), enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor")
add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), front_hp, 100, candidate_name(front_choice))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 130 if third_cycle else 100, tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade"))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 130 if third_cycle else (120 if fourth_cycle else 100), tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade")))
if 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 event := battle_event_text()
var ribbon := PanelContainer.new()
ribbon.name = "BattleEvent"
@@ -639,15 +705,15 @@ func render_battle() -> void:
ribbon.add_theme_stylebox_override("panel", lacquer_style(Color(0.04, 0.07, 0.13, 0.94), COLOR_GOLD, 2))
ribbon.add_child(centered_label(event, 12, COLOR_WHITE))
display_root.add_child(ribbon)
if second_cycle or (third_cycle and not retained_item.is_empty()):
var visible_item := retained_item if third_cycle else adaptation_choice
if second_cycle or ((third_cycle or fourth_cycle) and not retained_item.is_empty()):
var visible_item := retained_item if (third_cycle or fourth_cycle) else adaptation_choice
var loan := centered_label(("▣ " if visible_item == "EQ-002" else "◷ ") + adaptation_name(visible_item), 11, COLOR_GOLD_LIGHT)
loan.name = "ActiveAdaptation"
loan.position = Vector2(size.x * 0.02, 116)
loan.size = Vector2(size.x * 0.23, 48)
loan.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
display_root.add_child(loan)
elif third_cycle:
elif third_cycle or fourth_cycle:
var company := centered_label(tr_text("◆ 三人小队\n弓手 + 治疗", "◆ THREE-PERSON LINE\nArcher + Healer"), 11, COLOR_GOLD_LIGHT)
company.name = "ActiveCompany"
company.position = Vector2(size.x * 0.02, 116)
@@ -655,7 +721,10 @@ func render_battle() -> void:
company.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
display_root.add_child(company)
if third_cycle and battle_phase == 1 and retained_item != "EQ-061":
if fourth_cycle and battle_phase == 1:
var cue_x := size.x * (0.20 if route_choice == "R6-03" else 0.61)
add_signal_label("!", Vector2(cue_x, size.y * 0.31), COLOR_CRIMSON)
elif third_cycle and battle_phase == 1 and retained_item != "EQ-061":
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif battle_phase == 1 and not third_cycle and (not second_cycle or adaptation_choice == "EQ-002"):
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
@@ -663,7 +732,7 @@ func render_battle() -> void:
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif battle_phase == 3 and rear_choice == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
elif third_cycle and battle_phase == 4 and added_rear == "Farstring":
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)
render_battle_feedback(actor_width, actor_height)
update_battle_feedback_visual()
@@ -879,10 +948,152 @@ func render_session_result() -> void:
var facts := centered_label(tr_text("金币 %d | 小队 %d 人\n前排生命 %d / 100 | 用时 %.2f 秒" % [coin, company_size, front_hp, third_result_time], "Coin %d | Company %d\nFront HP %d / 100 | Time %.2f s" % [coin, company_size, front_hp, third_result_time]), 12, Color("#6d5523"))
facts.name = "SessionResultFacts"
box.add_child(facts)
box.add_child(game_button("ReplayExperienceButton", tr_text("用另一条路线重新开始", "Restart with another route"), request_restart, true))
box.add_child(game_button("OpenRouteBoardButton", tr_text("查看下一轮三条路线", "Compare the next three routes"), open_route_board, true))
box.add_child(game_button("SessionResultHomeButton", tr_text("回到主页", "Home"), go_home, false))
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")
var build_panel := PanelContainer.new()
build_panel.name = "RouteBuildSummary"
build_panel.position = Vector2(16, 72)
build_panel.size = Vector2(size.x * 0.30, 86)
build_panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.03, 0.055, 0.10, 0.94), COLOR_GOLD, 2))
display_root.add_child(build_panel)
var build_copy := VBoxContainer.new()
build_panel.add_child(build_copy)
build_copy.add_child(game_label(tr_text("上一轮留下的构筑", "BUILD INHERITED FROM LAST CYCLE"), 9, COLOR_TEAL))
build_copy.add_child(game_label(build_summary_text(), 12, COLOR_WHITE))
var board := PanelContainer.new()
board.name = "RouteMap"
board.position = Vector2(size.x * 0.32, 72)
board.size = Vector2(size.x * 0.66, size.y - 88)
board.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.82), COLOR_GOLD, 2))
display_root.add_child(board)
var shell := VBoxContainer.new()
shell.add_theme_constant_override("separation", 3)
board.add_child(shell)
shell.add_child(game_label(tr_text("三条路线都能前进,但当前构筑的应对不同", "All routes advance, but current coverage differs"), 15, COLOR_WHITE))
shell.add_child(game_label(route_board_guidance(), 9, COLOR_TEAL))
var route_map := Control.new()
route_map.name = "RouteNodeMap"
route_map.custom_minimum_size = Vector2(0, 142)
route_map.size_flags_vertical = Control.SIZE_EXPAND_FILL
shell.add_child(route_map)
var map_width: float = board.size.x - 22.0
var node_size := Vector2(150, 108)
var node_positions := [
Vector2(6, 24),
Vector2((map_width - node_size.x) * 0.5, 6),
Vector2(map_width - node_size.x - 6, 24),
]
var route_path := Line2D.new()
route_path.name = "RoutePath"
route_path.width = 7.0
route_path.default_color = Color(0.69, 0.52, 0.24, 0.82)
route_path.joint_mode = Line2D.LINE_JOINT_ROUND
route_path.begin_cap_mode = Line2D.LINE_CAP_ROUND
route_path.end_cap_mode = Line2D.LINE_CAP_ROUND
for route_position in node_positions:
route_path.add_point(route_position + node_size * 0.5)
route_map.add_child(route_path)
for index in range(3):
var id: String = ["R6-01", "R6-02", "R6-03"][index]
var node := route_button(id)
node.position = node_positions[index]
node.size = node_size
route_map.add_child(node)
var review := game_button("ReviewRouteButton", tr_text("查看所选路线并确认", "Review selected route"), open_route_review, true)
review.disabled = route_choice.is_empty()
review.custom_minimum_size.y = 48
shell.add_child(review)
func route_button(id: String) -> Button:
var selected := route_choice == id
var button := Button.new()
button.name = "Route_%s" % id.replace("-", "_")
button.text = "◆ %s ◆\n%s · %s\n%s\n%s" % [route_name(id), route_exposure(id), tr_text("+%d 金币" % route_reward(id), "+%d Coin" % route_reward(id)), route_pressure(id), route_coverage_label(id)]
button.focus_mode = Control.FOCUS_ALL
button.custom_minimum_size = Vector2(150, 108)
button.add_theme_font_size_override("font_size", scaled(9))
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
button.add_theme_stylebox_override("normal", route_node_style(selected, false, coverage_color(id)))
button.add_theme_stylebox_override("hover", route_node_style(selected, true, coverage_color(id)))
button.add_theme_stylebox_override("pressed", route_node_style(true, true, coverage_color(id)))
button.add_theme_stylebox_override("focus", route_node_style(true, true, coverage_color(id)))
button.pressed.connect(func(): choose_route(id), CONNECT_DEFERRED)
return button
func render_route_review() -> void:
add_full_wash(Color(0.01, 0.025, 0.06, 0.38))
render_hud(tr_text("路线确认 · %s" % route_name(route_choice), "Route Review · %s" % route_name(route_choice)))
var panel := PanelContainer.new()
panel.name = "RouteReviewPanel"
panel.position = Vector2(size.x * 0.12, 76)
panel.size = Vector2(size.x * 0.76, size.y - 96)
panel.add_theme_stylebox_override("panel", parchment_style())
display_root.add_child(panel)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 6)
panel.add_child(box)
box.add_child(centered_label("◆ " + route_name(route_choice) + " ◆", 22, COLOR_INK))
box.add_child(centered_label(tr_text("已知压力:%s\n暴露:%s 奖励:+%d 金币" % [route_pressure(route_choice), route_exposure(route_choice), route_reward(route_choice)], "Known pressure: %s\nExposure: %s Reward: +%d Coin" % [route_pressure(route_choice), route_exposure(route_choice), route_reward(route_choice)]), 12, Color("#4c402d")))
var coverage := centered_label(route_coverage_reason(route_choice), 12, Color("#6d5523"))
coverage.name = "RouteReviewCoverage"
box.add_child(coverage)
box.add_child(centered_label(tr_text("确认后锁定当前%s;路线入场不花金币。" % build_summary_short(), "Commit freezes the current %s; route entry costs no Coin." % build_summary_short()), 10, Color("#4c402d")))
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
box.add_child(actions)
var cancel := game_button("CancelRouteReviewButton", tr_text("返回比较", "Back to compare"), cancel_route_review, false)
cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(cancel)
var commit := game_button("CommitRouteButton", tr_text("锁定路线并进入战斗", "Commit route and enter battle"), commit_route, true)
commit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(commit)
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")
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"
result.position = Vector2(size.x * 0.56, 76)
result.size = Vector2(size.x * 0.41, size.y - 98)
result.add_theme_stylebox_override("panel", parchment_style())
display_root.add_child(result)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 5)
result.add_child(box)
box.add_child(centered_label("◆ " + route_result_headline() + " ◆", 17, COLOR_INK))
var explanation := centered_label(route_result_explanation(), 10, Color("#4c402d"))
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"))
facts.name = "SecondCycleFacts"
box.add_child(facts)
box.add_child(game_button("ReplaySecondCycleButton", tr_text("重新开始完整体验", "Restart the full experience"), request_restart, true))
box.add_child(game_button("SecondCycleHomeButton", tr_text("回到主页", "Home"), go_home, false))
func render_restart_confirmation() -> void:
var wash := ColorRect.new()
wash.name = "RestartWash"
@@ -984,7 +1195,7 @@ func depart() -> void:
func advance_battle_phase() -> void:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] or not battle_playing:
return
commit_phase_impact(battle_phase, false)
battle_phase += 1
@@ -997,6 +1208,15 @@ func advance_battle_phase() -> void:
page = AppPage.SESSION_RESULT
render()
return
if page == AppPage.FOURTH_BATTLE:
if battle_phase == 7:
battle_playing = false
victory_events += 1
fourth_result_time = fourth_battle_elapsed
commit_route_reward()
page = AppPage.SECOND_CYCLE_RESULT
render()
return
match battle_phase:
6:
battle_playing = false
@@ -1011,6 +1231,18 @@ func advance_battle_phase() -> void:
func phase_impact_time(phase: int) -> float:
if page == AppPage.FOURTH_BATTLE:
match phase:
1:
return 3.0 / 12.0 if selected_build_profile() == "tempo" and route_choice == "R6-02" else -1.0
2:
return 4.0 / 12.0
3, 4:
return 2.0 / 7.0
5:
return 3.0 / 10.0
6:
return 5.0 / 10.0
if page == AppPage.THIRD_BATTLE:
match phase:
1:
@@ -1048,6 +1280,11 @@ func phase_impact_time(phase: int) -> float:
func phase_duration(phase: int) -> float:
if page == AppPage.FOURTH_BATTLE:
var fourth_key := "%s:%s" % [route_choice, selected_build_profile()]
if FOURTH_BATTLE_PHASE_DURATIONS.has(fourth_key):
var fourth_durations: Array = FOURTH_BATTLE_PHASE_DURATIONS[fourth_key]
return float(fourth_durations[phase]) if phase >= 0 and phase < fourth_durations.size() else 0.0
if page == AppPage.THIRD_BATTLE:
var route := "recruit" if not added_rear.is_empty() else retained_item
if THIRD_BATTLE_PHASE_DURATIONS.has(route):
@@ -1069,6 +1306,9 @@ func commit_phase_impact(phase: int, refresh_runtime := true) -> void:
var qualifier_zh := ""
var qualifier_en := ""
var tier := "medium"
if page == AppPage.FOURTH_BATTLE:
commit_fourth_phase_impact(phase, refresh_runtime)
return
if page == AppPage.THIRD_BATTLE:
commit_third_phase_impact(phase, refresh_runtime)
return
@@ -1293,6 +1533,129 @@ func commit_third_phase_impact(phase: int, refresh_runtime := true) -> void:
render()
func commit_fourth_phase_impact(phase: int, refresh_runtime := true) -> void:
if phase_impact_committed.get(phase, false):
return
var profile := selected_build_profile()
var target := ""
var delta := 0
var qualifier_zh := ""
var qualifier_en := ""
var tier := "medium"
match phase:
1:
if route_choice != "R6-02" or profile != "tempo":
return
var previous_enemy_hp := enemy_hp
enemy_hp = 90
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "晨钟抢速"
qualifier_en = "tempo counter"
play_cue("hit")
2:
if route_choice == "R6-03":
var previous_rear_hp := rear_hp
rear_hp = 65
target = "rear"
delta = rear_hp - previous_rear_hp
qualifier_zh = "后排分击"
qualifier_en = "rear split"
else:
var previous_front_hp := front_hp
if route_choice == "R6-01":
front_hp = 90 if profile == "guarded" else (60 if profile == "tempo" else 70)
else:
front_hp = 70
target = "front"
delta = front_hp - previous_front_hp
qualifier_zh = "胫甲抗碎" if route_choice == "R6-01" and profile == "guarded" else ("速度爆发" if route_choice == "R6-02" else "正面粉碎")
qualifier_en = "brace answered" if route_choice == "R6-01" and profile == "guarded" else ("tempo burst" if route_choice == "R6-02" else "front crush")
play_cue("hit")
3:
if rear_choice == "Farstring":
var previous_enemy_hp := enemy_hp
enemy_hp = mini(enemy_hp, 72)
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "弓手命中"
qualifier_en = "Archer hit"
else:
if route_choice == "R6-03":
var previous_rear_hp := rear_hp
rear_hp = 88 if profile == "full_line" else mini(100, rear_hp + 12)
target = "rear"
delta = rear_hp - previous_rear_hp
qualifier_zh = "后排自救"
qualifier_en = "rear recovery"
else:
var previous_front_hp := front_hp
front_hp = mini(100, front_hp + 12)
target = "front"
delta = front_hp - previous_front_hp
qualifier_zh = "治疗"
qualifier_en = "healing"
rear_events += 1
play_cue("role")
4:
if not added_rear.is_empty():
if added_rear == "Farstring":
var previous_enemy_hp := enemy_hp
enemy_hp = mini(enemy_hp, 72)
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "新弓手命中"
qualifier_en = "new Archer hit"
else:
if route_choice == "R6-03":
var previous_rear_hp := rear_hp
rear_hp = 88
target = "rear"
delta = rear_hp - previous_rear_hp
qualifier_zh = "双后排恢复"
qualifier_en = "rear-line recovery"
else:
var previous_front_hp := front_hp
front_hp = mini(100, front_hp + 12)
target = "front"
delta = front_hp - previous_front_hp
qualifier_zh = "新治疗支援"
qualifier_en = "new Healer support"
rear_events += 1
play_cue("role")
else:
var previous_enemy_hp := enemy_hp
enemy_hp = mini(enemy_hp, 44)
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "构筑反击"
qualifier_en = "build counter"
play_cue("hit")
5:
var previous_enemy_hp := enemy_hp
enemy_hp = 28
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "路线突破"
qualifier_en = "route break"
play_cue("hit")
6:
var previous_enemy_hp := enemy_hp
enemy_hp = 0
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "路线守卫击破"
qualifier_en = "warden defeated"
tier = "large"
play_cue("victory")
_:
return
phase_impact_committed[phase] = true
emit_battle_feedback(phase, target, delta, qualifier_zh, qualifier_en, tier)
if refresh_runtime and page == AppPage.FOURTH_BATTLE:
render()
func emit_battle_feedback(phase: int, target: String, delta: int, qualifier_zh: String, qualifier_en: String, tier: String) -> void:
active_battle_feedback = {
"phase": phase,
@@ -1326,7 +1689,7 @@ func expire_battle_feedback() -> void:
func _process(delta: float) -> void:
if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT, AppPage.GUILD_RETURN, AppPage.SESSION_RESULT]:
if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT, AppPage.GUILD_RETURN, AppPage.SESSION_RESULT, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.SECOND_CYCLE_RESULT]:
if front_choice.is_empty() or rear_choice.is_empty():
return
victory_clock += delta
@@ -1334,7 +1697,7 @@ func _process(delta: float) -> void:
victory_front_sprite.texture = animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock)
victory_rear_sprite.texture = animation_texture(rear_choice, "idle", victory_clock)
return
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing or restart_confirmation:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] or not battle_playing or restart_confirmation:
return
var feedback_was_active := not active_battle_feedback.is_empty()
battle_clock += delta
@@ -1342,6 +1705,8 @@ func _process(delta: float) -> void:
second_battle_elapsed += delta
elif page == AppPage.THIRD_BATTLE:
third_battle_elapsed += delta
elif page == AppPage.FOURTH_BATTLE:
fourth_battle_elapsed += delta
var impact_time := phase_impact_time(battle_phase)
if impact_time >= 0.0 and battle_clock >= impact_time:
commit_phase_impact(battle_phase)
@@ -1363,7 +1728,7 @@ func _process(delta: float) -> void:
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
added_rear_sprite.texture = animation_texture(added_rear, added_rear_animation_state(), battle_clock)
enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock)
var projectile_actor_is_active := (battle_phase == 3 and rear_choice == "Farstring") or (page == AppPage.THIRD_BATTLE and battle_phase == 4 and added_rear == "Farstring")
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 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
@@ -1471,6 +1836,70 @@ func begin_third_battle() -> void:
render()
func open_route_board() -> void:
if page != AppPage.SESSION_RESULT or not guild_committed:
return
route_choice = ""
route_committed = false
route_reward_committed = false
page = AppPage.ROUTE_BOARD
play_cue("confirm")
render()
func choose_route(id: String) -> void:
if page != AppPage.ROUTE_BOARD or route_committed or not ROUTES.has(id):
return
route_choice = id
play_cue("select")
render()
func open_route_review() -> void:
if page != AppPage.ROUTE_BOARD or route_choice.is_empty() or route_committed:
return
page = AppPage.ROUTE_REVIEW
play_cue("confirm")
render()
func cancel_route_review() -> void:
if page != AppPage.ROUTE_REVIEW or route_committed:
return
page = AppPage.ROUTE_BOARD
play_cue("select")
render()
func commit_route() -> void:
if page != AppPage.ROUTE_REVIEW or route_choice.is_empty() or route_committed or not ROUTES.has(route_choice):
return
route_committed = true
route_reward_committed = false
battle_phase = 0
battle_clock = 0.0
battle_playing = true
front_hp = 100
rear_hp = 100
enemy_hp = 120
guard_events = 0
rear_events = 0
phase_impact_committed.clear()
reset_battle_feedback()
fourth_battle_elapsed = 0.0
fourth_result_time = 0.0
page = AppPage.FOURTH_BATTLE
play_cue("depart")
render()
func commit_route_reward() -> void:
if route_reward_committed or not route_committed or not ROUTES.has(route_choice):
return
route_reward_committed = true
coin += route_reward(route_choice)
func request_restart() -> void:
restart_confirmation = true
render()
@@ -1521,6 +1950,12 @@ func clear_experience() -> void:
added_rear = ""
third_battle_elapsed = 0.0
third_result_time = 0.0
rear_hp = 100
route_choice = ""
route_committed = false
route_reward_committed = false
fourth_battle_elapsed = 0.0
fourth_result_time = 0.0
phase_impact_committed.clear()
reset_battle_feedback()
victory_clock = 0.0
@@ -1550,6 +1985,8 @@ func toggle_mute() -> void:
func battle_event_text() -> String:
if page == AppPage.FOURTH_BATTLE:
return fourth_battle_event_text()
if page == AppPage.THIRD_BATTLE:
return third_battle_event_text()
if page == AppPage.SECOND_BATTLE:
@@ -1614,6 +2051,135 @@ func third_battle_event_text() -> String:
return ""
func fourth_battle_event_text() -> String:
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")
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")
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")
3:
return tr_text("原弓手从后排反击", "The original Archer counters") if rear_choice == "Farstring" else tr_text("原治疗修复受损阵线", "The original Healer restores the damaged line")
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")
5:
return tr_text("小队突破路线守卫的防线", "The Company breaks the route defense")
6:
return tr_text("路线守卫被击败,奖励已结算", "Route Warden defeated; reward settled")
return ""
func selected_build_profile() -> String:
if not added_rear.is_empty():
return "full_line"
return "guarded" if retained_item == "EQ-002" else "tempo"
func route_name(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["name_zh"] if locale == "zh_CN" else ROUTES[id]["name_en"]
func route_pressure(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["pressure_zh"] if locale == "zh_CN" else ROUTES[id]["pressure_en"]
func route_exposure(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["exposure_zh"] if locale == "zh_CN" else ROUTES[id]["exposure_en"]
func route_reward(id: String) -> int:
return int(ROUTES[id]["reward"]) if ROUTES.has(id) else 0
func route_coverage_state(id: String) -> String:
var profile := selected_build_profile()
if id == "R6-01":
return "covered" if profile == "guarded" else ("partial" if profile == "full_line" else "gap")
if id == "R6-02":
return "covered" if profile == "tempo" else ("partial" if profile == "full_line" else "gap")
if id == "R6-03":
return "covered" if profile == "full_line" else "gap"
return "gap"
func route_coverage_label(id: String) -> String:
match route_coverage_state(id):
"covered":
return tr_text("应对充分", "COVERED")
"partial":
return tr_text("部分应对", "PARTIAL")
return tr_text("存在缺口", "GAP")
func route_coverage_reason(id: String) -> String:
var profile := selected_build_profile()
if id == "R6-01":
if profile == "guarded":
return tr_text("应对充分:钉阵胫甲直接降低正面粉碎。", "Covered: Frontpin directly reduces the front crush.")
if profile == "full_line":
return tr_text("部分应对:三人阵线能治疗,但不能减少首次重击。", "Partial: the full line heals after impact but cannot reduce it.")
return tr_text("存在缺口:晨钟加快行动,但不会减少正面伤害。", "Gap: Early Bell adds speed but does not reduce front damage.")
if id == "R6-02":
if profile == "tempo":
return tr_text("应对充分:晨钟让前排抢在速度爆发之前行动。", "Covered: Early Bell acts before the tempo burst.")
if profile == "full_line":
return tr_text("部分应对:完整后排能恢复,但不能打断爆发。", "Partial: the full rear line recovers but cannot interrupt the burst.")
return tr_text("存在缺口:钉阵胫甲抗重击,但不改变敌方先手。", "Gap: Frontpin resists force but does not answer enemy initiative.")
if profile == "full_line":
return tr_text("应对充分:弓手与治疗分担后排打击和恢复。", "Covered: Archer and Healer divide rear damage and recovery.")
return tr_text("存在缺口:两件前排装备都无法保护被标记的后排。", "Gap: neither front item protects the marked rear.")
func coverage_color(id: String) -> Color:
match route_coverage_state(id):
"covered":
return COLOR_TEAL
"partial":
return COLOR_GOLD
return COLOR_CRIMSON
func build_summary_short() -> String:
match selected_build_profile():
"guarded":
return tr_text("钉阵两人小队", "Frontpin two-person line")
"tempo":
return tr_text("晨钟两人小队", "Early Bell two-person line")
return tr_text("弓手 + 治疗三人小队", "Archer + Healer three-person line")
func build_summary_text() -> String:
var company_size := 3 if selected_build_profile() == "full_line" else 2
return tr_text("金币 %d | 小队 %d 人\n%s" % [coin, company_size, build_summary_short()], "Coin %d | Company %d\n%s" % [coin, company_size, build_summary_short()])
func route_board_guidance() -> String:
if route_choice.is_empty():
return tr_text("没有预选或推荐。先比较压力、奖励和当前应对。", "Nothing is preselected or recommended. Compare pressure, reward and coverage.")
return tr_text("已选择%s;确认前仍可切换。" % route_name(route_choice), "%s selected; you can still switch before review." % route_name(route_choice))
func route_result_headline() -> String:
return tr_text("%s在%s的作用" % [build_summary_short(), route_name(route_choice)], "%s on %s" % [build_summary_short(), route_name(route_choice)])
func route_result_explanation() -> String:
return tr_text("压力:%s\n%s\n结果来自上一轮构筑与本轮压力;奖励只结算一次。" % [route_pressure(route_choice), route_coverage_reason(route_choice)], "Pressure: %s\n%s\nThe inherited build and route pressure caused this result; reward settled once." % [route_pressure(route_choice), route_coverage_reason(route_choice)])
func adaptation_name(id: String) -> String:
return tr_text("钉阵胫甲", "Frontpin Greaves") if id == "EQ-002" else tr_text("晨钟", "Early Bell")
@@ -1733,8 +2299,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 enemy_base_position
var target_scale := 1.0 if target == "front" else 0.82
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 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),
@@ -1790,6 +2356,8 @@ func update_battle_feedback_visual() -> void:
var recoil := sin(battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW * PI) * 8.0
if active_battle_feedback["target"] == "front" and front_sprite != null and is_instance_valid(front_sprite):
front_sprite.position.x = front_base_position.x - recoil
elif active_battle_feedback["target"] == "rear" and rear_sprite != null and is_instance_valid(rear_sprite):
rear_sprite.position.x = rear_base_position.x - recoil
elif active_battle_feedback["target"] == "enemy" and enemy_sprite != null and is_instance_valid(enemy_sprite):
enemy_sprite.position.x = enemy_base_position.x + recoil
@@ -1904,6 +2472,20 @@ func candidate_style(selected: bool, hover: bool, slot_color: Color) -> StyleBox
return style
func route_node_style(selected: bool, hover: bool, route_color: Color) -> StyleBoxFlat:
var fill := Color(0.03, 0.07, 0.12, 0.94 if not hover else 1.0)
var border := COLOR_GOLD_LIGHT if selected else route_color
var style := lacquer_style(fill, COLOR_WHITE if hover else border, 4 if selected else 2)
style.corner_radius_top_left = 42
style.corner_radius_top_right = 42
style.corner_radius_bottom_left = 42
style.corner_radius_bottom_right = 42
style.shadow_color = Color(0.01, 0.015, 0.03, 0.78)
style.shadow_size = 8 if selected else 5
style.shadow_offset = Vector2(0, 3)
return style
func dashed_slot_style(border: Color) -> StyleBoxFlat:
var style := lacquer_style(Color(0.035, 0.06, 0.12, 0.58), border, 2)
style.corner_radius_top_left = 28
@@ -0,0 +1,142 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const STATES := ["board_guarded", "board_tempo", "board_full_line", "review_split", "battle_crush", "battle_tempo", "battle_rear", "result_covered", "result_gap"]
const VIEWPORT_SIZE := Vector2i(844, 390)
var output_root := ""
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
output_root = OS.get_environment("ABG_P9_0R6_CAPTURE_ROOT")
if output_root.is_empty():
output_root = "/tmp/abg-p9-0r6-preview"
DirAccess.make_dir_recursive_absolute(output_root)
var viewport := SubViewport.new()
viewport.size = VIEWPORT_SIZE
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var game := MainScene.instantiate()
game.auto_boot = false
game.audio_enabled = false
viewport.add_child(game)
await settle(12)
game.locale = "zh_CN"
game.text_scale = 1.0
game.reduced_motion = false
game.muted = true
for index in range(STATES.size()):
apply_state(game, STATES[index])
game.render()
await settle(7)
verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE)), STATES[index])
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var path := output_root.path_join("%02d-%s.png" % [index + 1, STATES[index]])
var error := image.save_png(path)
if error != OK:
failures.append("capture %s failed with %s" % [STATES[index], error])
game.queue_free()
viewport.queue_free()
await settle(120)
if failures.is_empty():
print("ABG_P9_0R6_PREVIEW_CAPTURE_OK frames=9 viewport=844x390 locale=zh_CN min_touch=44 root=%s" % output_root)
call_deferred("finish", 0)
else:
for failure in failures:
push_error(failure)
call_deferred("finish", 1)
func apply_state(game: Control, state_name: String) -> void:
var profile := "guarded"
if state_name in ["board_tempo", "battle_tempo", "result_covered"]:
profile = "tempo"
elif state_name in ["board_full_line", "review_split", "battle_rear"]:
profile = "full_line"
prepare_profile(game, profile)
match state_name:
"board_guarded", "board_tempo", "board_full_line":
game.page = game.AppPage.ROUTE_BOARD
"review_split":
game.route_choice = "R6-03"
game.page = game.AppPage.ROUTE_REVIEW
"battle_crush":
prepare_battle(game, "R6-01", 2, 90, 100, 120)
game.emit_battle_feedback(2, "front", -10, "胫甲抗碎", "brace answered", "medium")
game.battle_feedback_clock = 0.16
"battle_tempo":
prepare_battle(game, "R6-02", 1, 100, 100, 90)
game.emit_battle_feedback(1, "enemy", -30, "晨钟抢速", "tempo counter", "medium")
game.battle_feedback_clock = 0.16
"battle_rear":
prepare_battle(game, "R6-03", 2, 100, 65, 120)
game.emit_battle_feedback(2, "rear", -35, "后排分击", "rear split", "medium")
game.battle_feedback_clock = 0.16
"result_covered":
prepare_result(game, "R6-02", 15, 82, 100, 6.00)
"result_gap":
prepare_profile(game, "guarded")
prepare_result(game, "R6-03", 18, 100, 65, 7.80)
func prepare_profile(game: Control, profile: String) -> void:
game.clear_experience()
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.adaptation_choice = "EQ-061" if profile == "tempo" else "EQ-002"
game.adaptation_committed = true
game.guild_committed = true
game.guild_choice = "recruit_rear" if profile == "full_line" else "retain_item"
game.retained_item = "" if profile == "full_line" else game.adaptation_choice
game.added_rear = "Kindhand" if profile == "full_line" else ""
game.coin = 6 if profile == "full_line" else 8
func prepare_battle(game: Control, route_id: String, phase: int, front_health: int, rear_health: int, enemy_health: int) -> void:
game.page = game.AppPage.FOURTH_BATTLE
game.route_choice = route_id
game.route_committed = true
game.battle_playing = false
game.battle_phase = phase
game.battle_clock = 0.22
game.front_hp = front_health
game.rear_hp = rear_health
game.enemy_hp = enemy_health
func prepare_result(game: Control, route_id: String, result_coin: int, front_health: int, rear_health: int, result_time: float) -> void:
game.page = game.AppPage.SECOND_CYCLE_RESULT
game.route_choice = route_id
game.route_committed = true
game.route_reward_committed = true
game.coin = result_coin
game.front_hp = front_health
game.rear_hp = rear_health
game.enemy_hp = 0
game.fourth_result_time = result_time
func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void:
if node is BaseButton and node.is_visible_in_tree():
var rect: Rect2 = node.get_global_rect()
if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5:
failures.append("%s button %s outside viewport: %s" % [label, node.name, rect])
if rect.size.x < 44.0 or rect.size.y < 44.0:
failures.append("%s button %s below 44px: %s" % [label, node.name, rect.size])
for child in node.get_children():
verify_interactive_bounds(child, bounds, label)
func finish(exit_code: int) -> void:
quit(exit_code)
func settle(frames: int) -> void:
for frame in range(frames):
await process_frame
@@ -0,0 +1 @@
uid://b1yjd1e35374d
+119
View File
@@ -0,0 +1,119 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var game: Control
var touch_count := 0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
game = MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(10)
game.locale = "zh_CN"
game.muted = true
prepare_real_session_result()
await settle(6)
await tap("OpenRouteBoardButton")
expect(game.page == game.AppPage.ROUTE_BOARD, "session result touch opens route board")
await tap("Route_R6_01")
await tap("Route_R6_03")
expect(game.route_choice == "R6-03", "touch revises route selection")
await tap("ReviewRouteButton")
expect(game.page == game.AppPage.ROUTE_REVIEW, "touch opens route review")
await tap("CancelRouteReviewButton")
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice == "R6-03", "touch cancel preserves selection")
await tap("Route_R6_02")
await tap("ReviewRouteButton")
await tap("CommitRouteButton")
expect(game.page == game.AppPage.FOURTH_BATTLE and game.route_committed, "touch commits route and starts battle")
await wait_for_page(game.AppPage.SECOND_CYCLE_RESULT, 720)
expect(game.route_choice == "R6-02" and game.coin == 15, "route reward reaches exact Coin")
expect(find_control(game, "SecondCycleFacts") != null, "touch route renders causal result")
game.queue_free()
game = null
await settle(90)
if failures.is_empty():
print("ABG_P9_0R6_ROUTE_TOUCH_OK touch=%d fallback=0 direct=0 revise=1 review=1 cancel=1 commit=1 battle=1 result=1" % touch_count)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func prepare_real_session_result() -> void:
game.front_choice = "Hearthguard"
game.rear_choice = "Kindhand"
game.adaptation_choice = "EQ-061"
game.adaptation_committed = true
game.guild_choice = "retain_item"
game.guild_committed = true
game.retained_item = "EQ-061"
game.coin = 8
game.front_hp = 82
game.enemy_hp = 0
game.third_result_time = 6.60
game.page = game.AppPage.SESSION_RESULT
game.render()
func wait_for_page(target_page: int, max_frames: int) -> void:
var previous_scale := Engine.time_scale
Engine.time_scale = 12.0
for frame in range(max_frames):
if game.page == target_page:
break
await process_frame
Engine.time_scale = previous_scale
expect(game.page == target_page, "automatic fourth battle reaches result")
await settle(6)
func tap(node_name: String) -> void:
var control := find_control(game, node_name)
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
failures.append("unavailable touch target %s" % node_name)
return
var position := root.get_final_transform() * control.get_global_rect().get_center()
var down := InputEventScreenTouch.new()
down.index = 0
down.position = position
down.pressed = true
Input.parse_input_event(down)
await process_frame
var up := InputEventScreenTouch.new()
up.index = 0
up.position = position
up.pressed = false
Input.parse_input_event(up)
touch_count += 1
await settle(7)
func find_control(node: Node, node_name: String) -> Control:
if node.name == node_name and node is Control and not node.is_queued_for_deletion():
return node
for child in node.get_children():
var found := find_control(child, node_name)
if found != null:
return found
return null
func settle(frames: int) -> void:
for frame in range(frames):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R6 route touch: %s" % message)
@@ -0,0 +1 @@
uid://cperqwuy044rx
@@ -0,0 +1,156 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const ROUTES := ["R6-01", "R6-02", "R6-03"]
const PROFILES := ["guarded", "tempo", "full_line"]
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var game := MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(10)
game.locale = "zh_CN"
game.muted = true
prepare_session_result(game, "guarded", "Farstring")
game.open_route_board()
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice.is_empty(), "route board opens unselected")
expect(find_control(game, "Route_R6_01") != null and find_control(game, "Route_R6_02") != null and find_control(game, "Route_R6_03") != null, "three stable routes render")
var review := find_control(game, "ReviewRouteButton") as BaseButton
expect(review != null and review.disabled, "review waits for one route")
game.choose_route("invalid")
expect(game.route_choice.is_empty(), "invalid route is rejected")
game.choose_route("R6-01")
game.choose_route("R6-03")
expect(game.route_choice == "R6-03" and game.coin == 8, "selection is reversible and does not mutate Coin")
game.open_route_review()
expect(game.page == game.AppPage.ROUTE_REVIEW, "selected route opens explicit review")
game.cancel_route_review()
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice == "R6-03", "cancel preserves selected route")
game.choose_route("R6-01")
game.open_route_review()
game.commit_route()
expect(game.page == game.AppPage.FOURTH_BATTLE and game.route_committed, "commit starts fourth battle")
expect(game.coin == 8 and not game.route_reward_committed, "route commit costs zero and does not prepay reward")
game.commit_route()
expect(game.coin == 8, "duplicate route commit is inert")
resolve_fourth_battle(game)
expect(game.page == game.AppPage.SECOND_CYCLE_RESULT, "fourth battle reaches second-cycle result")
expect(game.coin == 12 and game.route_reward_committed, "R6-01 reward adds 4 Coin exactly once")
game.commit_route_reward()
expect(game.coin == 12, "duplicate reward commit is inert")
for profile in PROFILES:
for route_id in ROUTES:
for original_rear in ["Farstring", "Kindhand"]:
prepare_session_result(game, profile, original_rear)
game.open_route_board()
expect(game.route_coverage_state(route_id) == expected_coverage(route_id, profile), "%s/%s coverage matches contract" % [route_id, profile])
game.choose_route(route_id)
game.open_route_review()
var coin_before: int = game.coin
game.commit_route()
var declared_time := resolve_fourth_battle(game)
var expected := expected_result(route_id, profile, original_rear)
expect(game.front_hp == expected["front"], "%s/%s/%s front HP" % [route_id, profile, original_rear])
expect(game.rear_hp == expected["rear"], "%s/%s/%s rear HP" % [route_id, profile, original_rear])
expect(is_equal_approx(declared_time, expected["time"]), "%s/%s declared time" % [route_id, profile])
expect(game.coin == coin_before + game.route_reward(route_id), "%s reward commits once" % route_id)
expect(game.enemy_hp == 0 and game.page == game.AppPage.SECOND_CYCLE_RESULT, "%s/%s clears" % [route_id, profile])
expect(find_control(game, "SecondCycleFacts") != null, "%s result facts render" % route_id)
game.request_restart()
game.confirm_restart()
expect(game.page == game.AppPage.TITLE, "confirmed Restart returns to Title")
expect(game.route_choice.is_empty() and not game.route_committed and not game.route_reward_committed and game.coin == 0, "Restart clears both cycles")
game.queue_free()
await settle(90)
if failures.is_empty():
print("ABG_P9_0R6_SECOND_CYCLE_OK routes=3 profiles=3 cells=18 unselected=true revision=true review_cancel=true commit_once=true reward=4,7,10 times=7.40,6.40,7.00,8.00,6.00,6.80,7.80,6.70 cleanup=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func prepare_session_result(game: Control, profile: String, original_rear: String) -> void:
game.clear_experience()
game.front_choice = "Hearthguard"
game.rear_choice = original_rear
game.adaptation_choice = "EQ-002" if profile != "tempo" else "EQ-061"
game.adaptation_committed = true
game.guild_committed = true
game.guild_choice = "recruit_rear" if profile == "full_line" else "retain_item"
game.retained_item = "" if profile == "full_line" else game.adaptation_choice
game.added_rear = ("Kindhand" if original_rear == "Farstring" else "Farstring") if profile == "full_line" else ""
game.coin = 6 if profile == "full_line" else 8
game.front_hp = 82 if profile == "full_line" else (90 if profile == "guarded" else 70)
game.enemy_hp = 0
game.third_result_time = 7.00 if profile == "full_line" else (7.60 if profile == "guarded" else 6.60)
game.page = game.AppPage.SESSION_RESULT
game.render()
func resolve_fourth_battle(game: Control) -> float:
var declared_time := 0.0
for phase in range(7):
var duration: float = game.phase_duration(phase)
declared_time += duration
game.fourth_battle_elapsed += duration
game.advance_battle_phase()
return declared_time
func expected_coverage(route_id: String, profile: String) -> String:
if route_id == "R6-01":
return "covered" if profile == "guarded" else ("partial" if profile == "full_line" else "gap")
if route_id == "R6-02":
return "covered" if profile == "tempo" else ("partial" if profile == "full_line" else "gap")
return "covered" if profile == "full_line" else "gap"
func expected_result(route_id: String, profile: String, original_rear: String) -> Dictionary:
var result := {"front": 100, "rear": 100, "time": 0.0}
match route_id:
"R6-01":
result["front"] = 90 if profile == "guarded" else (60 if profile == "tempo" else 70)
if original_rear == "Kindhand" or profile == "full_line":
result["front"] = mini(100, int(result["front"]) + 12)
result["time"] = 7.40 if profile == "guarded" else (6.40 if profile == "tempo" else 7.00)
"R6-02":
result["front"] = 70
if original_rear == "Kindhand" or profile == "full_line":
result["front"] = 82
result["time"] = 8.00 if profile == "guarded" else (6.00 if profile == "tempo" else 6.80)
"R6-03":
result["rear"] = 88 if profile == "full_line" else (77 if original_rear == "Kindhand" else 65)
result["time"] = 7.80 if profile == "guarded" else (6.70 if profile == "tempo" else 7.00)
return result
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.0R6 second cycle: %s" % message)
@@ -0,0 +1 @@
uid://t3rb07jstkti