From b7d50af403d3fe5d3fcc92943bc21d1d1748ae3d Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Thu, 13 Aug 2026 05:50:11 +0800 Subject: [PATCH] feat(runtime): add individual growth cycle --- .../PHASE9_0R7_INDIVIDUAL_GROWTH_CONTRACT.md | 9 +- runtime/main.gd | 538 +++++++++++++++++- runtime/tests/p9_0r7_growth_touch_test.gd | 125 ++++ runtime/tests/p9_0r7_growth_touch_test.gd.uid | 1 + .../tests/p9_0r7_individual_growth_test.gd | 151 +++++ .../p9_0r7_individual_growth_test.gd.uid | 1 + runtime/tests/p9_0r7_preview_capture_test.gd | 165 ++++++ .../tests/p9_0r7_preview_capture_test.gd.uid | 1 + 8 files changed, 974 insertions(+), 17 deletions(-) create mode 100644 runtime/tests/p9_0r7_growth_touch_test.gd create mode 100644 runtime/tests/p9_0r7_growth_touch_test.gd.uid create mode 100644 runtime/tests/p9_0r7_individual_growth_test.gd create mode 100644 runtime/tests/p9_0r7_individual_growth_test.gd.uid create mode 100644 runtime/tests/p9_0r7_preview_capture_test.gd create mode 100644 runtime/tests/p9_0r7_preview_capture_test.gd.uid diff --git a/docs/runtime/PHASE9_0R7_INDIVIDUAL_GROWTH_CONTRACT.md b/docs/runtime/PHASE9_0R7_INDIVIDUAL_GROWTH_CONTRACT.md index e3ef6ae1..ab50fdbd 100644 --- a/docs/runtime/PHASE9_0R7_INDIVIDUAL_GROWTH_CONTRACT.md +++ b/docs/runtime/PHASE9_0R7_INDIVIDUAL_GROWTH_CONTRACT.md @@ -52,12 +52,15 @@ reward = +8 Coin at verified result Ordered consequences: -1. A trained Farstring performs one early shot before enemy pressure. +1. A trained Farstring moves its one normal shot before enemy pressure; it + does not gain a second attack. 2. Front crush deals `-45`; retained `EQ-002` reduces it to `-30`; trained front reduces the already-derived damage by another `20`. 3. Split pressure hits the original rear for `-40`. -4. Original rear acts once. -5. Added rear acts once when present; otherwise the retained build follows up. +4. Original rear acts once here unless its trained Farstring shot already + moved to step 1. +5. Added rear acts once here when present unless its trained Farstring shot + already moved to step 1; otherwise the retained build follows up. 6. Route guard reaches `0` and reward commits once. Kindhand healing targets the damaged original rear. A normal Kindhand restores diff --git a/runtime/main.gd b/runtime/main.gd index d6fb73b8..9cd40829 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -73,6 +73,10 @@ const FOURTH_BATTLE_PHASE_DURATIONS := { "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 FIFTH_BATTLE_PHASE_DURATIONS := { + "normal": [1.10, 0.90, 1.10, 1.00, 1.00, 1.00, 1.30], + "farstring": [0.90, 0.60, 0.90, 0.90, 0.90, 0.90, 1.30], +} const ROUTES := { "R6-01": { "name_zh": "风墙长堤", "name_en": "Windwall Causeway", @@ -96,6 +100,7 @@ const ROUTES := { const FIRST_VICTORY_COIN := 20 const RETAIN_ITEM_COST := 12 const RECRUIT_REAR_COST := 14 +const FIFTH_BATTLE_REWARD := 8 const BATTLE_FEEDBACK_DURATION := 0.68 const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24 @@ -149,7 +154,7 @@ const CANDIDATES := { }, } -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 } +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, TRAINING, TRAINING_REVIEW, FIFTH_BATTLE, THIRD_CYCLE_RESULT } const COLOR_INK := Color("#10182c") const COLOR_INDIGO := Color("#172745") @@ -209,6 +214,12 @@ var route_committed := false var route_reward_committed := false var fourth_battle_elapsed := 0.0 var fourth_result_time := 0.0 +var training_choice := "" +var training_committed := false +var trained_member := "" +var fifth_reward_committed := false +var fifth_battle_elapsed := 0.0 +var fifth_result_time := 0.0 var animation_library: Dictionary = {} var victory_clock := 0.0 var active_battle_feedback: Dictionary = {} @@ -327,6 +338,8 @@ func animation_texture(character: String, state: String, elapsed: float) -> Text func front_animation_state() -> String: + if page == AppPage.FIFTH_BATTLE and battle_phase == 2: + return "guard" if front_choice == "Hearthguard" else "evade_counter" 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: @@ -344,6 +357,11 @@ func front_animation_state() -> String: func rear_animation_state() -> String: + if page == AppPage.FIFTH_BATTLE: + if battle_phase == 1 and trained_member == "rear" and rear_choice == "Farstring": + return "draw_release" + if battle_phase == 4 and not (trained_member == "rear" and rear_choice == "Farstring"): + return "draw_release" if rear_choice == "Farstring" else "lantern_heal" 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: @@ -357,6 +375,11 @@ func rear_animation_state() -> String: func added_rear_animation_state() -> String: + if page == AppPage.FIFTH_BATTLE: + if battle_phase == 1 and trained_member == "added" and added_rear == "Farstring": + return "draw_release" + if battle_phase == 5 and not (trained_member == "added" and added_rear == "Farstring"): + return "draw_release" if added_rear == "Farstring" else "lantern_heal" 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: @@ -365,6 +388,18 @@ func added_rear_animation_state() -> String: func enemy_animation_state() -> String: + if page == AppPage.FIFTH_BATTLE: + match battle_phase: + 0: + return "advance" + 1: + return "hit" if trained_profession() == "Farstring" else "telegraph" + 2, 3: + return "attack" + 4, 5: + return "hit" + 6: + return "death" if page == AppPage.FOURTH_BATTLE: if selected_build_profile() == "tempo" and route_choice == "R6-02" and battle_phase == 1: return "hit" @@ -432,7 +467,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, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.FOURTH_BATTLE, AppPage.SECOND_CYCLE_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, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.FIFTH_BATTLE, AppPage.THIRD_CYCLE_RESULT] else YARD_BACKGROUND add_background(background_texture) if portrait_layout(): render_portrait_notice() @@ -468,6 +503,14 @@ func render() -> void: render_battle() AppPage.SECOND_CYCLE_RESULT: render_second_cycle_result() + AppPage.TRAINING: + render_training() + AppPage.TRAINING_REVIEW: + render_training_review() + AppPage.FIFTH_BATTLE: + render_battle() + AppPage.THIRD_CYCLE_RESULT: + render_third_cycle_result() if restart_confirmation: render_restart_confirmation() @@ -677,7 +720,8 @@ func render_battle() -> void: var second_cycle := page == AppPage.SECOND_BATTLE var third_cycle := page == AppPage.THIRD_BATTLE 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"))) + var fifth_cycle := page == AppPage.FIFTH_BATTLE + var battle_title := tr_text("第五次远征 · 双压关隘", "Fifth Expedition · Twin-Pressure Pass") if fifth_cycle else (tr_text("第四次远征 · %s" % route_name(route_choice), "Fourth Expedition · %s" % route_name(route_choice)) if fourth_cycle else (tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins")))) render_hud(battle_title) var world_bottom := size.y - 58.0 var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0) @@ -689,13 +733,13 @@ 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 or fourth_cycle) and not added_rear.is_empty(): + if (third_cycle or fourth_cycle or fifth_cycle) and not added_rear.is_empty(): added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor") 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 (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("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 140 if fifth_cycle else (130 if third_cycle else (120 if fourth_cycle else 100)), tr_text("双压守卫", "Twin Warden") if fifth_cycle else (tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade")))) + if fifth_cycle or (fourth_cycle and route_choice == "R6-03"): add_health_bar("RearHealth", Vector2(22, 101), Vector2(size.x * 0.20, 12), rear_hp, 100, candidate_name(rear_choice)) var event := battle_event_text() var ribbon := PanelContainer.new() @@ -705,15 +749,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 or fourth_cycle) and not retained_item.is_empty()): - var visible_item := retained_item if (third_cycle or fourth_cycle) else adaptation_choice + if second_cycle or ((third_cycle or fourth_cycle or fifth_cycle) and not retained_item.is_empty()): + var visible_item := retained_item if (third_cycle or fourth_cycle or fifth_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 or fourth_cycle: + elif third_cycle or fourth_cycle or fifth_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) @@ -721,7 +765,14 @@ 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 fourth_cycle and battle_phase == 1: + if fifth_cycle and battle_phase == 1: + if trained_profession() == "Farstring": + projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.50), COLOR_GOLD_LIGHT) + else: + add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) + elif fifth_cycle and battle_phase == 3: + add_signal_label("!", Vector2(size.x * 0.20, size.y * 0.31), COLOR_CRIMSON) + elif 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": @@ -734,6 +785,10 @@ func render_battle() -> void: projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT) elif (third_cycle or fourth_cycle) and battle_phase == 4 and added_rear == "Farstring": projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT) + elif fifth_cycle and battle_phase == 4 and rear_choice == "Farstring" and trained_member != "rear": + projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT) + elif fifth_cycle and battle_phase == 5 and added_rear == "Farstring" and trained_member != "added": + projectile = add_signal_label("➤", Vector2(size.x * 0.42, size.y * 0.53), COLOR_GOLD_LIGHT) render_battle_feedback(actor_width, actor_height) update_battle_feedback_visual() @@ -1090,10 +1145,130 @@ func render_second_cycle_result() -> void: 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("OpenTrainingButton", tr_text("使用历练印记培养一名成员", "Train one member with the Veteran Mark"), open_training, true)) box.add_child(game_button("SecondCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)) +func render_training() -> void: + add_full_wash(Color(0.015, 0.03, 0.07, 0.24)) + render_hud(tr_text("第三轮 · 个人历练", "Third Cycle · Individual Training")) + var slots := member_slots() + var left_width := size.x * 0.65 + var actor_height: float = clampf(size.y * 0.28, 96.0, 144.0) + var slot_width: float = (left_width - 42.0) / float(slots.size()) + for index in range(slots.size()): + var slot: String = slots[index] + var profession := member_profession(slot) + var actor_x := 18.0 + slot_width * float(index) + (slot_width - actor_height) * 0.5 + actor_at(animation_texture(profession, "idle", victory_clock), Vector2(actor_x, 82), Vector2(actor_height, actor_height), "TrainingActor_%s" % slot) + var choice := training_button(slot) + choice.position = Vector2(20.0 + slot_width * float(index), 205) + choice.size = Vector2(slot_width - 8.0, 118) + display_root.add_child(choice) + + var panel := PanelContainer.new() + panel.name = "TrainingSummary" + panel.position = Vector2(size.x * 0.66, 76) + panel.size = Vector2(size.x * 0.32, 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("◆ " + tr_text("历练印记 × 1", "VETERAN MARK × 1") + " ◆", 18, COLOR_INK)) + box.add_child(centered_label(tr_text("本轮只能培养一名实际成员。\n等级属于个人,不会分享给全队。", "Train one actual member this cycle.\nLevels belong to individuals, not the Company."), 11, Color("#4c402d"))) + var guidance := centered_label(training_guidance(), 10, Color("#6d5523")) + guidance.name = "TrainingGuidance" + box.add_child(guidance) + var review := game_button("ReviewTrainingButton", tr_text("查看培养效果并确认", "Review the training effect"), open_training_review, true) + review.disabled = training_choice.is_empty() + box.add_child(review) + box.add_child(game_button("TrainingHomeButton", tr_text("回到主页", "Home"), go_home, false)) + + +func training_button(slot: String) -> Button: + var profession := member_profession(slot) + var selected := training_choice == slot + var button := Button.new() + button.name = "Training_%s" % slot.capitalize() + button.text = "%s · %s\nLv.1 → Lv.2\n%s" % [member_slot_label(slot), candidate_name(profession), training_effect_short(slot)] + button.focus_mode = Control.FOCUS_ALL + button.custom_minimum_size = Vector2(146, 108) + button.add_theme_font_size_override("font_size", scaled(10)) + button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE) + button.add_theme_stylebox_override("normal", route_node_style(selected, false, training_color(slot))) + button.add_theme_stylebox_override("hover", route_node_style(selected, true, training_color(slot))) + button.add_theme_stylebox_override("pressed", route_node_style(true, true, training_color(slot))) + button.add_theme_stylebox_override("focus", route_node_style(true, true, training_color(slot))) + button.pressed.connect(func(): choose_training(slot), CONNECT_DEFERRED) + return button + + +func render_training_review() -> void: + add_full_wash(Color(0.01, 0.025, 0.06, 0.38)) + render_hud(tr_text("培养确认 · %s" % candidate_name(member_profession(training_choice)), "Training Review · %s" % candidate_name(member_profession(training_choice)))) + var panel := PanelContainer.new() + panel.name = "TrainingReviewPanel" + 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", 7) + panel.add_child(box) + box.add_child(centered_label("◆ %s · Lv.2 ◆" % candidate_name(member_profession(training_choice)), 22, COLOR_INK)) + box.add_child(centered_label("%s · %s" % [member_slot_label(training_choice), candidate_role_short(member_profession(training_choice))], 13, Color("#4c402d"))) + var effect := centered_label(training_effect_detail(training_choice), 13, Color("#6d5523")) + effect.name = "TrainingReviewEffect" + box.add_child(effect) + box.add_child(centered_label(tr_text("确认后消耗唯一历练印记;其他成员保持 Lv.1。", "Commit consumes the only Veteran Mark; all other members stay Lv.1."), 10, Color("#4c402d"))) + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 8) + box.add_child(actions) + var cancel := game_button("CancelTrainingReviewButton", tr_text("返回改选", "Back to revise"), cancel_training_review, false) + cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(cancel) + var commit := game_button("CommitTrainingButton", tr_text("培养并进入第五战", "Train and enter battle five"), commit_training, true) + commit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(commit) + + +func render_third_cycle_result() -> void: + add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) + render_hud(tr_text("第三轮成长完成", "Third-Cycle Growth Complete")) + var actor_height: float = clampf(size.y * 0.28, 96.0, 176.0) + actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.05, size.y - actor_height - 36), Vector2(actor_height, actor_height), "GrowthResultFront") + actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.90 - 36), Vector2(actor_height * 0.90, actor_height * 0.90), "GrowthResultRear") + if not added_rear.is_empty(): + actor_at(animation_texture(added_rear, "idle", victory_clock), Vector2(size.x * 0.30, size.y - actor_height * 0.84 - 36), Vector2(actor_height * 0.84, actor_height * 0.84), "GrowthResultAddedRear") + actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "GrowthResultEnemy") + var result := PanelContainer.new() + result.name = "ThirdCycleResultPanel" + result.position = Vector2(size.x * 0.55, 76) + result.size = Vector2(size.x * 0.42, 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("◆ %s · Lv.2 ◆" % candidate_name(trained_profession()), 18, COLOR_INK)) + var explanation := centered_label(training_result_explanation(), 11, Color("#4c402d")) + explanation.name = "ThirdCycleExplanation" + box.add_child(explanation) + var company_size := member_slots().size() + var facts := centered_label(tr_text("奖励 +%d 金币 | 当前金币 %d\n小队 %d 人 | 前排 %d 后排 %d | %.2f 秒" % [FIFTH_BATTLE_REWARD, coin, company_size, front_hp, rear_hp, fifth_result_time], "Reward +%d Coin | Current %d\nCompany %d | Front %d Rear %d | %.2f s" % [FIFTH_BATTLE_REWARD, coin, company_size, front_hp, rear_hp, fifth_result_time]), 11, Color("#6d5523")) + facts.name = "ThirdCycleFacts" + box.add_child(facts) + var levels := centered_label(member_levels_summary(), 10, Color("#6d5523")) + levels.name = "MemberLevels" + box.add_child(levels) + box.add_child(game_button("ReplayGrowthButton", tr_text("重新开始完整体验", "Restart the full experience"), request_restart, true)) + box.add_child(game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)) + + func render_restart_confirmation() -> void: var wash := ColorRect.new() wash.name = "RestartWash" @@ -1195,7 +1370,7 @@ func depart() -> void: func advance_battle_phase() -> void: - if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] or not battle_playing: + if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE] or not battle_playing: return commit_phase_impact(battle_phase, false) battle_phase += 1 @@ -1217,6 +1392,15 @@ func advance_battle_phase() -> void: page = AppPage.SECOND_CYCLE_RESULT render() return + if page == AppPage.FIFTH_BATTLE: + if battle_phase == 7: + battle_playing = false + victory_events += 1 + fifth_result_time = fifth_battle_elapsed + commit_fifth_reward() + page = AppPage.THIRD_CYCLE_RESULT + render() + return match battle_phase: 6: battle_playing = false @@ -1231,6 +1415,16 @@ func advance_battle_phase() -> void: func phase_impact_time(phase: int) -> float: + if page == AppPage.FIFTH_BATTLE: + match phase: + 1: + return 2.0 / 7.0 if trained_profession() == "Farstring" else -1.0 + 2, 3: + return 4.0 / 12.0 + 4, 5: + return 2.0 / 7.0 + 6: + return 5.0 / 10.0 if page == AppPage.FOURTH_BATTLE: match phase: 1: @@ -1280,6 +1474,10 @@ func phase_impact_time(phase: int) -> float: func phase_duration(phase: int) -> float: + if page == AppPage.FIFTH_BATTLE: + var fifth_key := "farstring" if trained_profession() == "Farstring" else "normal" + var fifth_durations: Array = FIFTH_BATTLE_PHASE_DURATIONS[fifth_key] + return float(fifth_durations[phase]) if phase >= 0 and phase < fifth_durations.size() else 0.0 if page == AppPage.FOURTH_BATTLE: var fourth_key := "%s:%s" % [route_choice, selected_build_profile()] if FOURTH_BATTLE_PHASE_DURATIONS.has(fourth_key): @@ -1306,6 +1504,9 @@ func commit_phase_impact(phase: int, refresh_runtime := true) -> void: var qualifier_zh := "" var qualifier_en := "" var tier := "medium" + if page == AppPage.FIFTH_BATTLE: + commit_fifth_phase_impact(phase, refresh_runtime) + return if page == AppPage.FOURTH_BATTLE: commit_fourth_phase_impact(phase, refresh_runtime) return @@ -1656,6 +1857,112 @@ func commit_fourth_phase_impact(phase: int, refresh_runtime := true) -> void: render() +func commit_fifth_phase_impact(phase: int, refresh_runtime := true) -> void: + if phase_impact_committed.get(phase, false): + return + var target := "" + var delta := 0 + var qualifier_zh := "" + var qualifier_en := "" + var tier := "medium" + match phase: + 1: + if trained_profession() != "Farstring": + return + var previous_enemy_hp := enemy_hp + enemy_hp = 110 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "历练先射" + qualifier_en = "trained early shot" + rear_events += 1 + play_cue("hit") + 2: + var previous_front_hp := front_hp + var damage := 30 if retained_item == "EQ-002" else 45 + if trained_member == "front": + damage = maxi(0, damage - 20) + front_hp = maxi(0, front_hp - damage) + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "历练减伤" if trained_member == "front" else ("胫甲抗压" if retained_item == "EQ-002" else "正面重压") + qualifier_en = "trained mitigation" if trained_member == "front" else ("Frontpin braced" if retained_item == "EQ-002" else "front pressure") + guard_events += 1 + play_cue("hit") + 3: + var previous_rear_hp := rear_hp + rear_hp = 60 + target = "rear" + delta = rear_hp - previous_rear_hp + qualifier_zh = "后排分压" + qualifier_en = "rear split" + play_cue("hit") + 4: + if trained_member == "rear" and rear_choice == "Farstring": + return + if rear_choice == "Farstring": + var previous_enemy_hp := enemy_hp + enemy_hp = maxi(0, enemy_hp - 30) + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "原弓手命中" + qualifier_en = "original Archer" + else: + var previous_rear_hp := rear_hp + var healing := 25 if trained_member == "rear" else 12 + rear_hp = mini(100, rear_hp + healing) + target = "rear" + delta = rear_hp - previous_rear_hp + qualifier_zh = "历练强疗" if trained_member == "rear" else "后排治疗" + qualifier_en = "trained recovery" if trained_member == "rear" else "rear healing" + rear_events += 1 + play_cue("role") + 5: + if not added_rear.is_empty(): + if trained_member == "added" and added_rear == "Farstring": + return + if added_rear == "Farstring": + var previous_enemy_hp := enemy_hp + enemy_hp = maxi(0, enemy_hp - 30) + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "增援弓手命中" + qualifier_en = "added Archer" + else: + var previous_rear_hp := rear_hp + var healing := 25 if trained_member == "added" else 12 + rear_hp = mini(100, rear_hp + healing) + target = "rear" + delta = rear_hp - previous_rear_hp + qualifier_zh = "历练强疗" if trained_member == "added" else "增援治疗" + qualifier_en = "trained recovery" if trained_member == "added" else "added healing" + rear_events += 1 + play_cue("role") + else: + var previous_enemy_hp := enemy_hp + enemy_hp = maxi(0, enemy_hp - 40) + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "保留构筑追击" + qualifier_en = "retained build" + 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 = "Twin 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.FIFTH_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, @@ -1689,7 +1996,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, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.SECOND_CYCLE_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, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.THIRD_CYCLE_RESULT]: if front_choice.is_empty() or rear_choice.is_empty(): return victory_clock += delta @@ -1697,7 +2004,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, AppPage.FOURTH_BATTLE] or not battle_playing or restart_confirmation: + if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE] or not battle_playing or restart_confirmation: return var feedback_was_active := not active_battle_feedback.is_empty() battle_clock += delta @@ -1707,6 +2014,8 @@ func _process(delta: float) -> void: third_battle_elapsed += delta elif page == AppPage.FOURTH_BATTLE: fourth_battle_elapsed += delta + elif page == AppPage.FIFTH_BATTLE: + fifth_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) @@ -1729,6 +2038,8 @@ func _process(delta: float) -> void: 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 in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] and battle_phase == 4 and added_rear == "Farstring") + if page == AppPage.FIFTH_BATTLE: + projectile_actor_is_active = (battle_phase == 1 and trained_profession() == "Farstring") or (battle_phase == 4 and rear_choice == "Farstring" and trained_member != "rear") or (battle_phase == 5 and added_rear == "Farstring" and trained_member != "added") 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 @@ -1900,6 +2211,70 @@ func commit_route_reward() -> void: coin += route_reward(route_choice) +func open_training() -> void: + if page != AppPage.SECOND_CYCLE_RESULT or not route_committed or not route_reward_committed or training_committed: + return + training_choice = "" + trained_member = "" + page = AppPage.TRAINING + play_cue("confirm") + render() + + +func choose_training(slot: String) -> void: + if page != AppPage.TRAINING or training_committed or slot not in member_slots(): + return + training_choice = slot + play_cue("select") + render() + + +func open_training_review() -> void: + if page != AppPage.TRAINING or training_committed or training_choice not in member_slots(): + return + page = AppPage.TRAINING_REVIEW + play_cue("confirm") + render() + + +func cancel_training_review() -> void: + if page != AppPage.TRAINING_REVIEW or training_committed or training_choice not in member_slots(): + return + page = AppPage.TRAINING + play_cue("select") + render() + + +func commit_training() -> void: + if page != AppPage.TRAINING_REVIEW or training_committed or training_choice not in member_slots(): + return + training_committed = true + trained_member = training_choice + fifth_reward_committed = false + battle_phase = 0 + battle_clock = 0.0 + battle_playing = true + front_hp = 100 + rear_hp = 100 + enemy_hp = 140 + guard_events = 0 + rear_events = 0 + phase_impact_committed.clear() + reset_battle_feedback() + fifth_battle_elapsed = 0.0 + fifth_result_time = 0.0 + page = AppPage.FIFTH_BATTLE + play_cue("depart") + render() + + +func commit_fifth_reward() -> void: + if fifth_reward_committed or not training_committed or trained_member not in member_slots() or enemy_hp != 0: + return + fifth_reward_committed = true + coin += FIFTH_BATTLE_REWARD + + func request_restart() -> void: restart_confirmation = true render() @@ -1956,6 +2331,12 @@ func clear_experience() -> void: route_reward_committed = false fourth_battle_elapsed = 0.0 fourth_result_time = 0.0 + training_choice = "" + training_committed = false + trained_member = "" + fifth_reward_committed = false + fifth_battle_elapsed = 0.0 + fifth_result_time = 0.0 phase_impact_committed.clear() reset_battle_feedback() victory_clock = 0.0 @@ -1985,6 +2366,8 @@ func toggle_mute() -> void: func battle_event_text() -> String: + if page == AppPage.FIFTH_BATTLE: + return fifth_battle_event_text() if page == AppPage.FOURTH_BATTLE: return fourth_battle_event_text() if page == AppPage.THIRD_BATTLE: @@ -2076,12 +2459,139 @@ func fourth_battle_event_text() -> String: return "" +func fifth_battle_event_text() -> String: + match battle_phase: + 0: + return tr_text("Lv.2 成员带队进入双压关隘…", "The Lv.2 member leads into Twin-Pressure Pass…") + 1: + return tr_text("历练弓手提前射击,但本场仍只行动一次", "The trained Archer fires early, but still acts only once") if trained_profession() == "Farstring" else tr_text("双压守卫同时锁定前排与原后排", "The Twin Warden marks the front and original rear") + 2: + return tr_text("前排历练额外减少 20 伤害", "Front training removes 20 more damage") if trained_member == "front" else tr_text("前排承受第一道正面重压", "The front absorbs the first pressure") + 3: + return tr_text("分裂压力越过阵线,原后排受到 -40", "Split pressure bypasses the line; original rear takes -40") + 4: + if trained_member == "rear" and rear_choice == "Farstring": + return tr_text("历练弓手已在第一步行动,本步不重复攻击", "The trained Archer already acted; no duplicate attack") + return tr_text("原后排用 +25 强疗恢复", "The original rear restores +25 with trained healing") if rear_choice == "Kindhand" and trained_member == "rear" else (tr_text("原后排恢复 +12", "The original rear restores +12") if rear_choice == "Kindhand" else tr_text("原弓手完成本场唯一射击", "The original Archer takes its one shot")) + 5: + if not added_rear.is_empty(): + if trained_member == "added" and added_rear == "Farstring": + return tr_text("历练弓手已提前行动,不会重复射击", "The trained Archer acted early and does not fire twice") + return tr_text("增援治疗用 +25 强疗恢复", "The added Healer restores +25") if added_rear == "Kindhand" and trained_member == "added" else (tr_text("增援治疗恢复 +12", "The added Healer restores +12") if added_rear == "Kindhand" else tr_text("增援弓手完成本场唯一射击", "The added Archer takes its one shot")) + return tr_text("两人小队发动保留构筑追击", "The two-person line triggers its retained-build follow-up") + 6: + return tr_text("双压守卫被击败;成长结果已验证", "Twin Warden defeated; growth result verified") + 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 member_slots() -> Array[String]: + var slots: Array[String] = ["front", "rear"] + if not added_rear.is_empty(): + slots.append("added") + return slots + + +func member_profession(slot: String) -> String: + match slot: + "front": + return front_choice + "rear": + return rear_choice + "added": + return added_rear + return "" + + +func member_slot_label(slot: String) -> String: + match slot: + "front": + return tr_text("前排", "Front") + "rear": + return tr_text("原后排", "Original rear") + "added": + return tr_text("增援后排", "Added rear") + return "" + + +func member_level(slot: String) -> int: + if slot not in member_slots(): + return 0 + return 2 if training_committed and slot == trained_member else 1 + + +func trained_profession() -> String: + return member_profession(trained_member) if training_committed else "" + + +func training_color(slot: String) -> Color: + match member_profession(slot): + "Hearthguard", "Wayrunner": + return COLOR_TEAL + "Farstring": + return COLOR_GOLD + "Kindhand": + return Color("#77b6a5") + return COLOR_GOLD + + +func training_effect_short(slot: String) -> String: + match member_profession(slot): + "Hearthguard", "Wayrunner": + return tr_text("前排重压再减 20", "Front pressure -20") + "Farstring": + return tr_text("唯一射击提前 1 秒", "One shot moves 1s earlier") + "Kindhand": + return tr_text("后排治疗 +12 → +25", "Rear healing +12 → +25") + return "" + + +func training_effect_detail(slot: String) -> String: + match member_profession(slot): + "Hearthguard", "Wayrunner": + return tr_text("下一战正面重压在装备结算后再减少 20。 +有钉阵胫甲:前排 70 → 90;无胫甲:55 → 75。", "The next front pressure loses 20 damage after item mitigation.\nWith Frontpin: 70 → 90 HP; without: 55 → 75 HP.") + "Farstring": + return tr_text("唯一一次普通射击移到敌人施压之前。 +不增加攻击次数;战斗时间 7.40 → 6.40 秒。", "The one normal shot moves before enemy pressure.\nNo extra attack; battle time falls from 7.40 to 6.40 seconds.") + "Kindhand": + return tr_text("下一战治疗目标是受伤的原后排。 +恢复量从 +12 提高到 +25:后排 72 → 85。", "The next heal targets the damaged original rear.\nHealing rises from +12 to +25: rear HP 72 → 85.") + return "" + + +func training_guidance() -> String: + if training_choice.is_empty(): + return tr_text("没有预选。比较生存、速度与恢复,只能选一人。", "Nothing is preselected. Compare survival, tempo and recovery; choose one.") + return tr_text("已选择%s · %s;确认前仍可换人。" % [candidate_name(member_profession(training_choice)), training_effect_short(training_choice)], "%s selected · %s. You can still revise." % [candidate_name(member_profession(training_choice)), training_effect_short(training_choice)]) + + +func training_result_explanation() -> String: + match trained_profession(): + "Hearthguard", "Wayrunner": + return tr_text("前排 Lv.2 在已有装备结算后再减少 20 伤害。 +这次前排保留 %d 生命;其他成员仍是 Lv.1。" % front_hp, "Front Lv.2 removed 20 damage after item mitigation.\nThe front kept %d HP; all other members remain Lv.1." % front_hp) + "Farstring": + return tr_text("弓手 Lv.2 把唯一射击提前到敌人之前。 +没有增加攻击次数;战斗缩短为 %.2f 秒。" % fifth_result_time, "Archer Lv.2 moved its one shot before the enemy.\nNo extra attack was added; battle time fell to %.2f seconds." % fifth_result_time) + "Kindhand": + return tr_text("治疗 Lv.2 把受伤原后排从 60 恢复到 %d。 +恢复量是 +25;其他成员仍是 Lv.1。" % rear_hp, "Healer Lv.2 restored the damaged original rear from 60 to %d.\nThe heal was +25; all other members remain Lv.1." % rear_hp) + return "" + + +func member_levels_summary() -> String: + var parts: Array[String] = [] + for slot in member_slots(): + parts.append("%s %s Lv.%d" % [member_slot_label(slot), candidate_name(member_profession(slot)), member_level(slot)]) + return " | ".join(parts) if locale == "zh_CN" else " | ".join(parts) + + func route_name(id: String) -> String: if not ROUTES.has(id): return "" diff --git a/runtime/tests/p9_0r7_growth_touch_test.gd b/runtime/tests/p9_0r7_growth_touch_test.gd new file mode 100644 index 00000000..51c4a4b2 --- /dev/null +++ b/runtime/tests/p9_0r7_growth_touch_test.gd @@ -0,0 +1,125 @@ +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_second_cycle_result() + await settle(6) + + await tap("OpenTrainingButton") + expect(game.page == game.AppPage.TRAINING and game.training_choice.is_empty(), "result touch opens unselected training") + await tap("Training_Front") + await tap("Training_Rear") + expect(game.training_choice == "rear", "touch revises member selection") + await tap("ReviewTrainingButton") + expect(game.page == game.AppPage.TRAINING_REVIEW, "touch opens training review") + await tap("CancelTrainingReviewButton") + expect(game.page == game.AppPage.TRAINING and game.training_choice == "rear", "touch cancel preserves selected member") + await tap("Training_Added") + await tap("ReviewTrainingButton") + await tap("CommitTrainingButton") + expect(game.page == game.AppPage.FIFTH_BATTLE and game.trained_member == "added", "touch commits actual added member") + await wait_for_page(game.AppPage.THIRD_CYCLE_RESULT, 720) + expect(game.member_level("added") == 2 and game.rear_hp == 85, "trained Kindhand changes real battle recovery") + expect(game.coin == 20 and game.fifth_reward_committed, "result has exact one-time reward") + expect(find_control(game, "ThirdCycleFacts") != null and find_control(game, "MemberLevels") != null, "causal result and individual levels render") + + game.queue_free() + game = null + await settle(90) + if failures.is_empty(): + print("ABG_P9_0R7_GROWTH_TOUCH_OK touch=%d fallback=0 direct=0 open=1 revise=1 review=2 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_second_cycle_result() -> void: + game.clear_experience() + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.adaptation_choice = "EQ-061" + game.adaptation_committed = true + game.guild_choice = "recruit_rear" + game.guild_committed = true + game.added_rear = "Kindhand" + game.coin = 12 + game.route_choice = "R6-01" + game.route_committed = true + game.route_reward_committed = true + game.front_hp = 70 + game.rear_hp = 100 + game.enemy_hp = 0 + game.fourth_result_time = 7.00 + game.page = game.AppPage.SECOND_CYCLE_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 fifth 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.0R7 growth touch: %s" % message) diff --git a/runtime/tests/p9_0r7_growth_touch_test.gd.uid b/runtime/tests/p9_0r7_growth_touch_test.gd.uid new file mode 100644 index 00000000..998ecf32 --- /dev/null +++ b/runtime/tests/p9_0r7_growth_touch_test.gd.uid @@ -0,0 +1 @@ +uid://dpe8k2v0m5qxt diff --git a/runtime/tests/p9_0r7_individual_growth_test.gd b/runtime/tests/p9_0r7_individual_growth_test.gd new file mode 100644 index 00000000..00c47c11 --- /dev/null +++ b/runtime/tests/p9_0r7_individual_growth_test.gd @@ -0,0 +1,151 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + var game := MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle(10) + game.locale = "zh_CN" + game.muted = true + + prepare_second_cycle_result(game, "EQ-002", "Farstring", "") + expect(game.member_slots() == ["front", "rear"], "two-person Company exposes front/rear only") + game.open_training() + expect(game.page == game.AppPage.TRAINING and game.training_choice.is_empty(), "training opens unselected") + expect(find_control(game, "Training_Added") == null, "absent added member never renders") + game.choose_training("added") + expect(game.training_choice.is_empty(), "absent added member is rejected") + game.choose_training("front") + game.choose_training("rear") + expect(game.training_choice == "rear", "selection is reversible without committing") + game.open_training_review() + game.cancel_training_review() + expect(game.page == game.AppPage.TRAINING and game.training_choice == "rear", "review cancel preserves choice") + + prepare_second_cycle_result(game, "", "Farstring", "Kindhand") + expect(game.member_slots() == ["front", "rear", "added"], "three-person Company exposes actual added member") + for slot in ["front", "rear", "added"]: + expect(game.member_level(slot) == 1, "%s starts at Lv.1" % slot) + game.open_training() + game.choose_training("added") + game.open_training_review() + var coin_before: int = game.coin + game.commit_training() + expect(game.training_committed and game.trained_member == "added", "commit binds one actual member") + expect(game.member_level("added") == 2 and game.member_level("front") == 1 and game.member_level("rear") == 1, "exactly one member reaches Lv.2") + game.commit_training() + expect(game.coin == coin_before and game.trained_member == "added", "duplicate training commit is inert") + resolve_fifth_battle(game) + expect(game.page == game.AppPage.THIRD_CYCLE_RESULT and game.enemy_hp == 0, "fifth battle reaches causal result") + expect(game.rear_hp == 85 and game.rear_events == 2, "trained added Kindhand heals +25 and both rears act once") + expect(game.coin == coin_before + 8 and game.fifth_reward_committed, "fifth reward commits once") + game.commit_fifth_reward() + expect(game.coin == coin_before + 8, "duplicate fifth reward is inert") + + for item in ["EQ-002", ""]: + for training_slot in ["front", "rear", "added"]: + prepare_second_cycle_result(game, item, "Farstring", "Kindhand") + commit_training_choice(game, training_slot) + var duration := resolve_fifth_battle(game) + var expected_front := 90 if item == "EQ-002" else 75 + if training_slot != "front": + expected_front = 70 if item == "EQ-002" else 55 + var expected_rear := 85 if training_slot == "added" else 72 + var expected_time := 6.40 if training_slot == "rear" else 7.40 + expect(game.front_hp == expected_front, "%s/%s front counterfactual" % [item, training_slot]) + expect(game.rear_hp == expected_rear, "%s/%s rear counterfactual" % [item, training_slot]) + expect(is_equal_approx(duration, expected_time), "%s/%s timing counterfactual" % [item, training_slot]) + expect(game.rear_events == 2, "%s/%s rear actions remain exactly two" % [item, training_slot]) + + prepare_second_cycle_result(game, "", "Kindhand", "Farstring") + commit_training_choice(game, "rear") + resolve_fifth_battle(game) + expect(game.rear_hp == 85 and game.rear_events == 2, "trained original Kindhand heals +25") + + prepare_second_cycle_result(game, "EQ-002", "Farstring", "") + commit_training_choice(game, "rear") + var two_person_time := resolve_fifth_battle(game) + expect(game.rear_hp == 60 and game.rear_events == 1, "two-person Farstring acts once and cannot heal") + expect(is_equal_approx(two_person_time, 6.40), "two-person trained Farstring preserves fast timing") + + game.request_restart() + game.confirm_restart() + expect(game.page == game.AppPage.TITLE, "Restart returns to Title") + expect(game.training_choice.is_empty() and not game.training_committed and game.trained_member.is_empty() and not game.fifth_reward_committed, "Restart clears growth transaction") + game.queue_free() + await settle(90) + if failures.is_empty(): + print("ABG_P9_0R7_INDIVIDUAL_GROWTH_OK slots=2,3 revise=true review_cancel=true commit_once=true level2=1 outcomes=8 front_hp=90,75,70,55 rear_hp=85,72,60 times=7.40,6.40 reward=8 cleanup=true") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func prepare_second_cycle_result(game: Control, item: String, original_rear: String, extra_rear: String) -> void: + game.clear_experience() + game.front_choice = "Hearthguard" + game.rear_choice = original_rear + game.adaptation_choice = item if not item.is_empty() else "EQ-061" + game.adaptation_committed = true + game.guild_choice = "retain_item" if extra_rear.is_empty() else "recruit_rear" + game.guild_committed = true + game.retained_item = item + game.added_rear = extra_rear + game.coin = 12 + game.route_choice = "R6-01" + game.route_committed = true + game.route_reward_committed = true + game.front_hp = 90 + game.rear_hp = 100 + game.enemy_hp = 0 + game.fourth_result_time = 7.40 + game.page = game.AppPage.SECOND_CYCLE_RESULT + game.render() + + +func commit_training_choice(game: Control, slot: String) -> void: + game.open_training() + game.choose_training(slot) + game.open_training_review() + game.commit_training() + + +func resolve_fifth_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.fifth_battle_elapsed += duration + game.advance_battle_phase() + return declared_time + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R7 growth: %s" % message) diff --git a/runtime/tests/p9_0r7_individual_growth_test.gd.uid b/runtime/tests/p9_0r7_individual_growth_test.gd.uid new file mode 100644 index 00000000..d7814feb --- /dev/null +++ b/runtime/tests/p9_0r7_individual_growth_test.gd.uid @@ -0,0 +1 @@ +uid://bvu38pkm7r2a1 diff --git a/runtime/tests/p9_0r7_preview_capture_test.gd b/runtime/tests/p9_0r7_preview_capture_test.gd new file mode 100644 index 00000000..943bacb3 --- /dev/null +++ b/runtime/tests/p9_0r7_preview_capture_test.gd @@ -0,0 +1,165 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") +const STATES := ["training_two", "training_three", "review_front", "review_farstring", "review_kindhand", "battle_front", "battle_farstring", "battle_kindhand", "result_front", "result_farstring", "result_kindhand"] +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_0R7_CAPTURE_ROOT") + if output_root.is_empty(): + output_root = "/tmp/abg-p9-0r7-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_0R7_PREVIEW_CAPTURE_OK frames=11 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 original_rear := "Farstring" + var added := "Kindhand" + var item := "" + if state_name == "training_two": + item = "EQ-002" + added = "" + prepare_second_cycle_result(game, item, original_rear, added) + match state_name: + "training_two": + game.page = game.AppPage.TRAINING + "training_three": + game.training_choice = "added" + game.page = game.AppPage.TRAINING + "review_front": + game.training_choice = "front" + game.page = game.AppPage.TRAINING_REVIEW + "review_farstring": + game.training_choice = "rear" + game.page = game.AppPage.TRAINING_REVIEW + "review_kindhand": + game.training_choice = "added" + game.page = game.AppPage.TRAINING_REVIEW + "battle_front": + prepare_fifth_battle(game, "front", 2, 90, 100, 140) + game.retained_item = "EQ-002" + game.emit_battle_feedback(2, "front", -10, "历练减伤", "trained mitigation", "medium") + game.battle_feedback_clock = 0.16 + "battle_farstring": + prepare_fifth_battle(game, "rear", 1, 100, 100, 110) + game.emit_battle_feedback(1, "enemy", -30, "历练先射", "trained early shot", "medium") + game.battle_feedback_clock = 0.16 + "battle_kindhand": + prepare_fifth_battle(game, "added", 5, 55, 85, 80) + game.emit_battle_feedback(5, "rear", 25, "历练强疗", "trained recovery", "medium") + game.battle_feedback_clock = 0.16 + "result_front": + prepare_result(game, "front", "EQ-002", 90, 72, 7.40) + "result_farstring": + prepare_result(game, "rear", "", 55, 72, 6.40) + "result_kindhand": + prepare_result(game, "added", "", 55, 85, 7.40) + + +func prepare_second_cycle_result(game: Control, item: String, original_rear: String, extra_rear: String) -> void: + game.clear_experience() + game.front_choice = "Hearthguard" + game.rear_choice = original_rear + game.adaptation_choice = item if not item.is_empty() else "EQ-061" + game.adaptation_committed = true + game.guild_committed = true + game.guild_choice = "retain_item" if extra_rear.is_empty() else "recruit_rear" + game.retained_item = item + game.added_rear = extra_rear + game.coin = 12 + game.route_choice = "R6-01" + game.route_committed = true + game.route_reward_committed = true + game.front_hp = 90 + game.rear_hp = 100 + game.enemy_hp = 0 + game.fourth_result_time = 7.40 + game.page = game.AppPage.SECOND_CYCLE_RESULT + + +func prepare_fifth_battle(game: Control, slot: String, phase: int, front_health: int, rear_health: int, enemy_health: int) -> void: + game.training_choice = slot + game.training_committed = true + game.trained_member = slot + game.page = game.AppPage.FIFTH_BATTLE + game.battle_playing = false + game.battle_phase = phase + game.battle_clock = 0.18 + game.front_hp = front_health + game.rear_hp = rear_health + game.enemy_hp = enemy_health + + +func prepare_result(game: Control, slot: String, item: String, front_health: int, rear_health: int, result_time: float) -> void: + game.retained_item = item + game.training_choice = slot + game.training_committed = true + game.trained_member = slot + game.fifth_reward_committed = true + game.coin = 20 + game.front_hp = front_health + game.rear_hp = rear_health + game.enemy_hp = 0 + game.fifth_result_time = result_time + game.page = game.AppPage.THIRD_CYCLE_RESULT + + +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_0r7_preview_capture_test.gd.uid b/runtime/tests/p9_0r7_preview_capture_test.gd.uid new file mode 100644 index 00000000..5e20cede --- /dev/null +++ b/runtime/tests/p9_0r7_preview_capture_test.gd.uid @@ -0,0 +1 @@ +uid://b8m2c51jy3dws