diff --git a/runtime/main.gd b/runtime/main.gd index 460d3b41..d6fb73b8 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -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 diff --git a/runtime/tests/p9_0r6_preview_capture_test.gd b/runtime/tests/p9_0r6_preview_capture_test.gd new file mode 100644 index 00000000..1be114bb --- /dev/null +++ b/runtime/tests/p9_0r6_preview_capture_test.gd @@ -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 diff --git a/runtime/tests/p9_0r6_preview_capture_test.gd.uid b/runtime/tests/p9_0r6_preview_capture_test.gd.uid new file mode 100644 index 00000000..2005c71d --- /dev/null +++ b/runtime/tests/p9_0r6_preview_capture_test.gd.uid @@ -0,0 +1 @@ +uid://b1yjd1e35374d diff --git a/runtime/tests/p9_0r6_route_touch_test.gd b/runtime/tests/p9_0r6_route_touch_test.gd new file mode 100644 index 00000000..22264981 --- /dev/null +++ b/runtime/tests/p9_0r6_route_touch_test.gd @@ -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) diff --git a/runtime/tests/p9_0r6_route_touch_test.gd.uid b/runtime/tests/p9_0r6_route_touch_test.gd.uid new file mode 100644 index 00000000..21bd793d --- /dev/null +++ b/runtime/tests/p9_0r6_route_touch_test.gd.uid @@ -0,0 +1 @@ +uid://cperqwuy044rx diff --git a/runtime/tests/p9_0r6_second_cycle_route_test.gd b/runtime/tests/p9_0r6_second_cycle_route_test.gd new file mode 100644 index 00000000..dc5bd481 --- /dev/null +++ b/runtime/tests/p9_0r6_second_cycle_route_test.gd @@ -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) diff --git a/runtime/tests/p9_0r6_second_cycle_route_test.gd.uid b/runtime/tests/p9_0r6_second_cycle_route_test.gd.uid new file mode 100644 index 00000000..bdf5d226 --- /dev/null +++ b/runtime/tests/p9_0r6_second_cycle_route_test.gd.uid @@ -0,0 +1 @@ +uid://t3rb07jstkti