extends Control const UI_FONT := preload("res://assets/fonts/NotoSansSC.ttf") const YARD_BACKGROUND := preload("res://assets/backgrounds/windglass_verge.webp") const FIELD_BACKGROUND := preload("res://assets/backgrounds/first_expedition_field.png") const ACTORS := { "Hearthguard": preload("res://assets/actors/hearthguard.png"), "Wayrunner": preload("res://assets/actors/wayrunner.png"), "Farstring": preload("res://assets/actors/farstring.png"), "Kindhand": preload("res://assets/actors/kindhand.png"), } const RIFTBLADE := { "idle": preload("res://assets/enemies/riftblade/idle.png"), "attack": preload("res://assets/enemies/riftblade/attack.png"), "hit": preload("res://assets/enemies/riftblade/hit.png"), "death": preload("res://assets/enemies/riftblade/death.png"), } const ANIMATION_SPECS := { "Hearthguard": { "idle": {"root": "hearthguard/idle", "frames": 6, "fps": 6.0, "loop": true}, "advance": {"root": "hearthguard/advance", "frames": 8, "fps": 10.0, "loop": true}, "guard": {"root": "hearthguard/guard", "frames": 4, "fps": 10.0, "loop": false}, "basic_attack": {"root": "hearthguard/basic_attack", "frames": 6, "fps": 12.0, "loop": false}, "victory": {"root": "hearthguard/victory", "frames": 6, "fps": 6.0, "loop": false}, }, "Wayrunner": { "idle": {"root": "wayrunner/idle", "frames": 2, "fps": 3.0, "loop": true}, "advance": {"root": "wayrunner/advance", "frames": 2, "fps": 6.0, "loop": true}, "evade_counter": {"root": "wayrunner/evade_counter", "frames": 4, "fps": 7.0, "loop": false}, }, "Farstring": { "idle": {"root": "farstring/idle", "frames": 2, "fps": 3.0, "loop": true}, "advance": {"root": "farstring/advance", "frames": 2, "fps": 6.0, "loop": true}, "draw_release": {"root": "farstring/draw_release", "frames": 4, "fps": 7.0, "loop": false}, }, "Kindhand": { "idle": {"root": "kindhand/idle", "frames": 2, "fps": 3.0, "loop": true}, "advance": {"root": "kindhand/advance", "frames": 2, "fps": 6.0, "loop": true}, "lantern_heal": {"root": "kindhand/lantern_heal", "frames": 4, "fps": 7.0, "loop": false}, }, "Riftblade": { "idle": {"root": "riftblade/idle", "frames": 6, "fps": 6.0, "loop": true}, "advance": {"root": "riftblade/advance", "frames": 8, "fps": 10.0, "loop": true}, "telegraph": {"root": "riftblade/telegraph", "frames": 6, "fps": 10.0, "loop": false}, "attack": {"root": "riftblade/attack", "frames": 6, "fps": 12.0, "loop": false}, "hit": {"root": "riftblade/hit", "frames": 4, "fps": 10.0, "loop": false}, "death": {"root": "riftblade/death", "frames": 8, "fps": 10.0, "loop": false}, }, } const VFX := { "guard": preload("res://assets/vfx/protect_absorb.png"), "heal": preload("res://assets/vfx/protect_gain.png"), "hit": preload("res://assets/vfx/damage_heavy.png"), } const BATTLE_PHASE_DURATIONS := [1.60, 1.15, 1.05, 1.05, 1.05, 1.35] const SECOND_BATTLE_PHASE_DURATIONS := { "EQ-002": [1.35, 1.00, 1.10, 1.05, 1.30, 1.25], "EQ-061": [1.20, 0.85, 0.95, 0.90, 1.00, 1.10], } const THIRD_BATTLE_PHASE_DURATIONS := { "EQ-002": [1.20, 0.80, 1.00, 1.00, 1.10, 1.10, 1.40], "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 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", "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 const FIFTH_BATTLE_REWARD := 8 const BATTLE_FEEDBACK_DURATION := 0.68 const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24 const BOOT_REQUIRED_PATHS: Array[String] = [ "res://main.tscn", "res://main.gd", "res://assets/backgrounds/windglass_verge.webp", "res://assets/backgrounds/first_expedition_field.png", "res://assets/actors/hearthguard.png", "res://assets/actors/wayrunner.png", "res://assets/actors/farstring.png", "res://assets/actors/kindhand.png", "res://assets/enemies/riftblade/idle.png", "res://assets/enemies/riftblade/attack.png", "res://assets/animations/hearthguard/advance/frame-0.png", "res://assets/animations/wayrunner/evade_counter/frame-0.png", "res://assets/animations/farstring/draw_release/frame-0.png", "res://assets/animations/kindhand/lantern_heal/frame-0.png", "res://assets/animations/riftblade/death/frame-0.png", "res://assets/fonts/NotoSansSC.ttf", ] const CANDIDATES := { "Hearthguard": { "slot": "front", "name_zh": "艾连", "name_en": "Elian", "role_zh": "盾卫 · 稳稳挡住敌人", "role_en": "Guardian · holds the enemy", }, "Wayrunner": { "slot": "front", "name_zh": "托马斯", "name_en": "Tomas", "role_zh": "游侠 · 闪避后反击", "role_en": "Runner · evades and counters", }, "Farstring": { "slot": "rear", "name_zh": "妮娅", "name_en": "Nia", "role_zh": "弓手 · 后排持续射击", "role_en": "Archer · fires from the rear", }, "Kindhand": { "slot": "rear", "name_zh": "赛布尔", "name_en": "Sable", "role_zh": "治疗 · 为前排恢复生命", "role_en": "Healer · restores the front", }, } enum AppPage { BOOT, TITLE, MAIN_MENU, GAME_INTRO, SETTINGS, FIRST_MISSION, RECRUIT_SELECTION, FRONT_SELECTION, REAR_SELECTION, FORMATION_REVIEW, 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 AUTO_FORMATION_ORDER: Array[String] = ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"] const COLOR_INK := Color("#10182c") const COLOR_INDIGO := Color("#172745") const COLOR_INDIGO_HOVER := Color("#24446c") const COLOR_PARCHMENT := Color("#f1e2b5") const COLOR_PARCHMENT_DARK := Color("#d6bc78") const COLOR_GOLD := Color("#dcae43") const COLOR_GOLD_LIGHT := Color("#ffe49a") const COLOR_TEAL := Color("#67d9ce") const COLOR_CRIMSON := Color("#9d3348") const COLOR_WHITE := Color("#fffaf0") @export var auto_boot := true var page := AppPage.BOOT var locale := "zh_CN" var text_scale := 1.0 var reduced_motion := false var muted := false var audio_enabled := true var boot_index := 0 var boot_verified := false var boot_failed_path := "" var boot_running := false var boot_paths_override: Array[String] = [] var front_choice := "" var rear_choice := "" var selected_recruits: Array[String] = [] var formation_commit_count := 0 var notice := "" var restart_confirmation := false var battle_phase := 0 var battle_clock := 0.0 var battle_playing := false var front_hp := 100 var enemy_hp := 100 var guard_events := 0 var rear_events := 0 var victory_events := 0 var phase_impact_committed: Dictionary = {} var coin := 0 var first_reward_committed := false var adaptation_choice := "" var adaptation_committed := false var second_battle_elapsed := 0.0 var second_result_time := 0.0 var guild_choice := "" var guild_committed := false 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 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 = {} var battle_feedback_clock := 0.0 var battle_feedback_history: Array[Dictionary] = [] var display_root: Control var front_sprite: TextureRect var rear_sprite: TextureRect var added_rear_sprite: TextureRect var enemy_sprite: TextureRect var battle_feedback_label: Label var battle_feedback_vfx: TextureRect var battle_feedback_origin := Vector2.ZERO var victory_front_sprite: TextureRect var victory_rear_sprite: TextureRect var victory_enemy_sprite: TextureRect var projectile: Control var front_base_position := Vector2.ZERO var rear_base_position := Vector2.ZERO var added_rear_base_position := Vector2.ZERO var enemy_base_position := Vector2.ZERO var audio_player: AudioStreamPlayer var sfx_playback: AudioStreamGeneratorPlayback var render_pending := false func _ready() -> void: var ui_theme := Theme.new() ui_theme.default_font = UI_FONT theme = ui_theme display_root = Control.new() display_root.name = "DisplayRoot" display_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(display_root) load_animation_library() if audio_enabled: setup_audio() resized.connect(queue_render) render() if auto_boot: call_deferred("run_boot_verification") func _exit_tree() -> void: if audio_player != null: audio_player.stop() sfx_playback = null audio_player = null func setup_audio() -> void: var generator := AudioStreamGenerator.new() generator.mix_rate = 22050.0 generator.buffer_length = 0.25 audio_player = AudioStreamPlayer.new() audio_player.name = "PrototypeSFX" audio_player.bus = &"SFX" audio_player.playback_type = AudioServer.PLAYBACK_TYPE_STREAM audio_player.stream = generator add_child(audio_player) audio_player.play() sfx_playback = audio_player.get_stream_playback() func load_animation_library() -> void: animation_library.clear() for character in ANIMATION_SPECS: var states: Dictionary = ANIMATION_SPECS[character] var loaded_states := {} for state in states: var spec: Dictionary = states[state] var frames: Array[Texture2D] = [] for index in range(int(spec["frames"])): var path := "res://assets/animations/%s/frame-%d.png" % [spec["root"], index] var texture := load(path) as Texture2D assert(texture != null, "Missing animation texture: %s" % path) frames.append(texture) loaded_states[state] = { "frames": frames, "fps": float(spec["fps"]), "loop": bool(spec["loop"]), } animation_library[character] = loaded_states func animation_frame_index(character: String, state: String, elapsed: float) -> int: var resolved_state := state if not animation_library.has(character): return 0 var states: Dictionary = animation_library[character] if not states.has(resolved_state): resolved_state = "idle" var animation: Dictionary = states[resolved_state] var frames: Array = animation["frames"] if frames.is_empty(): return 0 var fps: float = animation["fps"] if reduced_motion and bool(animation["loop"]): return 0 if reduced_motion: fps = minf(fps, 5.0) var raw_index := int(floor(maxf(0.0, elapsed) * fps)) return raw_index % frames.size() if bool(animation["loop"]) else mini(raw_index, frames.size() - 1) func animation_texture(character: String, state: String, elapsed: float) -> Texture2D: if animation_library.has(character): var states: Dictionary = animation_library[character] var resolved_state := state if states.has(state) else "idle" if states.has(resolved_state): var animation: Dictionary = states[resolved_state] var frames: Array = animation["frames"] return frames[animation_frame_index(character, resolved_state, elapsed)] return RIFTBLADE.get(state, RIFTBLADE["idle"]) if character == "Riftblade" else ACTORS.get(character, ACTORS["Hearthguard"]) 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: return "basic_attack" if front_choice == "Hearthguard" else "evade_counter" if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1: return "basic_attack" if front_choice == "Hearthguard" else "evade_counter" match battle_phase: 0: return "advance" 2: if front_choice == "Hearthguard": return "guard" if front_choice == "Wayrunner": return "evade_counter" return "idle" 4: if front_choice == "Hearthguard": return "basic_attack" if front_choice == "Wayrunner": return "evade_counter" return "draw_release" if front_choice == "Farstring" else "lantern_heal" return "idle" 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: return "draw_release" if rear_choice == "Farstring" else "lantern_heal" match battle_phase: 0: return "advance" 3, 4: if rear_choice == "Farstring": return "draw_release" if rear_choice == "Kindhand": return "lantern_heal" return "evade_counter" if rear_choice == "Wayrunner" else "idle" return "idle" 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: return "advance" return "idle" 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" 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: match battle_phase: 0: return "advance" 1: return "telegraph" 2: return "attack" 3, 4, 5: return "hit" 6: return "death" if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1: return "hit" match battle_phase: 0: return "advance" 1: return "telegraph" 2: return "attack" 3: return "hit" if rear_choice in ["Farstring", "Wayrunner"] else "idle" 4: return "hit" 5: return "death" return "idle" func queue_render() -> void: if render_pending: return render_pending = true call_deferred("render") func render() -> void: render_pending = false front_sprite = null rear_sprite = null added_rear_sprite = null enemy_sprite = null battle_feedback_label = null battle_feedback_vfx = null victory_front_sprite = null victory_rear_sprite = null victory_enemy_sprite = null 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, 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() else: match page: AppPage.BOOT: render_boot() AppPage.TITLE: render_title() AppPage.MAIN_MENU: render_main_menu() AppPage.GAME_INTRO: render_game_intro() AppPage.SETTINGS: render_settings() AppPage.FIRST_MISSION: render_first_mission() AppPage.RECRUIT_SELECTION: render_recruit_selection() AppPage.FRONT_SELECTION: render_role_selection(true) AppPage.REAR_SELECTION: render_role_selection(false) AppPage.FORMATION_REVIEW: render_formation_review() AppPage.YARD: render_yard() AppPage.BATTLE: render_battle() AppPage.VICTORY: render_victory() AppPage.ADAPTATION: render_adaptation() AppPage.SECOND_BATTLE: render_battle() AppPage.SECOND_RESULT: render_second_result() AppPage.GUILD_RETURN: render_guild_return() AppPage.THIRD_BATTLE: 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() 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() func add_background(texture: Texture2D) -> void: var background := TextureRect.new() background.name = "WorldBackground" background.texture = texture background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(background) func render_portrait_notice() -> void: add_full_wash(Color(0.015, 0.04, 0.07, 0.72)) var panel := PanelContainer.new() panel.name = "RotateNotice" panel.position = Vector2(24, size.y * 0.32) panel.size = Vector2(size.x - 48, 226) panel.add_theme_stylebox_override("panel", parchment_style()) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 10) panel.add_child(box) box.add_child(centered_label("↻", 34, COLOR_GOLD)) box.add_child(centered_label(tr_text("请将手机旋转为横屏", "Rotate your phone to landscape"), 21, COLOR_INK)) var guidance := centered_label(tr_text("角色、选择和战斗会在横屏中完整显示", "Characters, choices and battle will fit in landscape"), 12, Color("#55472f")) guidance.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART box.add_child(guidance) display_root.add_child(panel) if page not in [AppPage.TITLE, AppPage.BOOT, AppPage.MAIN_MENU, AppPage.GAME_INTRO, AppPage.SETTINGS]: var home := game_button("PortraitHome", tr_text("回到主页", "Home"), go_home, false) home.position = Vector2(70, size.y * 0.32 + 244) home.size = Vector2(size.x - 140, 52) display_root.add_child(home) func render_boot() -> void: add_full_wash(Color(0.015, 0.035, 0.07, 0.58)) var shell := VBoxContainer.new() shell.name = "BootPanel" shell.position = Vector2(size.x * 0.12, size.y * 0.25) shell.size = Vector2(size.x * 0.76, size.y * 0.5) shell.alignment = BoxContainer.ALIGNMENT_CENTER shell.add_theme_constant_override("separation", 16) display_root.add_child(shell) shell.add_child(centered_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 38, COLOR_WHITE)) var status := tr_text("远征装备已经就绪", "Expedition kit ready") if boot_verified else tr_text("正在整理远征装备…", "Preparing the expedition…") shell.add_child(centered_label(status, 18, COLOR_TEAL)) var progress := ProgressBar.new() progress.name = "BootProgress" progress.custom_minimum_size = Vector2(0, 18) progress.max_value = max(1, active_boot_paths().size()) progress.value = boot_index progress.show_percentage = false progress.add_theme_stylebox_override("background", lacquer_style(Color("#121d34"), Color("#6d7f95"), 1)) progress.add_theme_stylebox_override("fill", lacquer_style(COLOR_GOLD, COLOR_GOLD_LIGHT, 1)) shell.add_child(progress) if not boot_failed_path.is_empty(): shell.add_child(centered_label(tr_text("有一件装备没有准备好", "One required asset could not be prepared"), 14, Color("#ffb4b4"))) var retry := game_button("BootRetry", tr_text("重新检查", "Check again"), retry_boot, true) retry.custom_minimum_size = Vector2(260, 56) shell.add_child(retry) func render_title() -> void: add_full_wash(Color(0.01, 0.025, 0.055, 0.26)) var copy := VBoxContainer.new() copy.name = "TitleScreen" copy.position = Vector2(size.x * 0.065, size.y * 0.47) copy.size = Vector2(size.x * 0.57, size.y * 0.31) copy.add_theme_constant_override("separation", 7) display_root.add_child(copy) copy.add_child(game_label(tr_text("风路断裂,公会正在召集第一支小队", "The wind road is broken. The guild needs its first squad."), 15, COLOR_TEAL)) copy.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 46, COLOR_WHITE)) copy.add_child(game_label(tr_text("选择两名成员,系统自动编队并执行战斗。", "Choose two Recruits; the system forms the line and runs the battle."), 17, COLOR_WHITE)) var start := game_button("EnterGameButton", "◆ " + tr_text("进入游戏", "Enter Game") + " ◆", enter_game, true) start.position = Vector2(size.x * 0.065, size.y * 0.82) start.size = Vector2(min(380.0, size.x * 0.44), 58) display_root.add_child(start) func render_main_menu() -> void: add_full_wash(Color(0.01, 0.025, 0.055, 0.35)) var identity := VBoxContainer.new() identity.name = "MainMenuIdentity" identity.position = Vector2(size.x * 0.055, size.y * 0.20) identity.size = Vector2(size.x * 0.48, size.y * 0.56) identity.add_theme_constant_override("separation", 8) display_root.add_child(identity) identity.add_child(game_label(tr_text("公会作战席", "GUILD COMMAND"), 15, COLOR_TEAL)) identity.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 38, COLOR_WHITE)) identity.add_child(game_label(tr_text("组建小队,读取威胁,让每次远征改变下一次选择。", "Build a Company, read the threat, and let each expedition change the next decision."), 15, COLOR_WHITE)) var status := game_label(tr_text("当前体验:组队 → 五场远征 → 装备、路线与个人成长", "Current experience: recruit → five expeditions → equipment, routes and individual growth"), 11, COLOR_GOLD_LIGHT) status.name = "MainMenuStatus" identity.add_child(status) var rail := PanelContainer.new() rail.name = "MainMenuPanel" rail.position = Vector2(size.x * 0.62, 64) rail.size = Vector2(size.x * 0.34, size.y - 92) rail.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(rail) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 9) rail.add_child(box) box.add_child(centered_label("◆ " + tr_text("远征准备", "EXPEDITION READY") + " ◆", 20, COLOR_INK)) box.add_child(centered_label(tr_text("尚未开始远征", "No expedition in progress"), 11, Color("#6d5523"))) box.add_child(game_button("StartNewGameButton", tr_text("开始新游戏", "Start New Game"), start_new_game, true)) box.add_child(game_button("GameIntroButton", tr_text("游戏介绍", "Game Introduction"), open_game_intro, false)) box.add_child(game_button("SettingsButton", tr_text("设置", "Settings"), open_settings, false)) func render_game_intro() -> void: add_full_wash(Color(0.01, 0.025, 0.055, 0.48)) var heading := centered_label(tr_text("远征团长要做什么?", "WHAT DOES THE CAPTAIN DO?"), 24, COLOR_WHITE) heading.name = "GameIntroductionScreen" heading.position = Vector2(32, 24) heading.size = Vector2(size.x - 64, 48) display_root.add_child(heading) var beats := [ [tr_text("1 组建阵线", "1 BUILD THE LINE"), tr_text("选择一名前排与一名后排。\n前排承压,后排输出或治疗。", "Choose one front and one rear.\nThe front holds; the rear attacks or heals.")], [tr_text("2 观察战斗", "2 WATCH THE BATTLE"), tr_text("战斗自动执行你的组队决定。\n看清谁受伤、谁先手、谁提供恢复。", "Combat executes your committed formation.\nRead damage, initiative and recovery.")], [tr_text("3 战后调整", "3 ADAPT AFTER BATTLE"), tr_text("根据结果选择装备或补齐队伍。\n不是操作角色,而是改变下一场条件。", "Choose equipment or complete the line.\nChange the next encounter, not each attack.")], [tr_text("4 路线与成长", "4 ROUTE AND GROWTH"), tr_text("比较路线压力,再培养一名成员。\n下一战会验证你的选择。", "Compare route pressure, then train one member.\nThe next battle proves that choice.")], ] var gap := 10.0 var card_width := (size.x - 50.0 - gap * 3.0) / 4.0 for index in range(beats.size()): var card := PanelContainer.new() card.name = "IntroductionBeat%d" % (index + 1) card.position = Vector2(25.0 + float(index) * (card_width + gap), 88) card.size = Vector2(card_width, size.y - 162) card.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(card) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 8) card.add_child(box) box.add_child(centered_label(beats[index][0], 14, COLOR_INK)) box.add_child(centered_label(beats[index][1], 10, Color("#4c402d"))) var back := game_button("IntroductionBackButton", tr_text("返回主菜单", "Back to Main Menu"), back_to_main_menu, false) back.position = Vector2(size.x * 0.35, size.y - 62) back.size = Vector2(size.x * 0.30, 48) display_root.add_child(back) func render_settings() -> void: add_full_wash(Color(0.01, 0.025, 0.055, 0.48)) var panel := PanelContainer.new() panel.name = "SettingsScreen" panel.position = Vector2(size.x * 0.10, 14) panel.size = Vector2(size.x * 0.80, size.y - 28) 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("◆ " + tr_text("设置", "SETTINGS") + " ◆", 22, COLOR_INK)) box.add_child(setting_button("SettingsLocaleButton", tr_text("语言", "Language"), tr_text("简体中文", "English"), tr_text("切换全部界面文字", "Switch all interface text"), toggle_locale)) box.add_child(setting_button("SettingsTextScaleButton", tr_text("文字大小", "Text Size"), "130%" if text_scale > 1.0 else "100%", tr_text("放大说明与按钮文字", "Enlarge labels and button text"), toggle_text_scale)) box.add_child(setting_button("SettingsMotionButton", tr_text("动态效果", "Motion"), tr_text("减少动态", "Reduced") if reduced_motion else tr_text("标准", "Standard"), tr_text("减少循环动作与弹跳", "Reduce looping motion and bounce"), toggle_motion)) box.add_child(setting_button("SettingsMuteButton", tr_text("声音", "Sound"), tr_text("静音", "Muted") if muted else tr_text("开启", "On"), tr_text("控制战斗与确认音效", "Control battle and confirmation cues"), toggle_mute)) box.add_child(game_button("SettingsBackButton", tr_text("返回主菜单", "Back to Main Menu"), back_to_main_menu, false)) func setting_button(node_name: String, category: String, value: String, explanation: String, callback: Callable) -> Button: var button := game_button(node_name, "%s  %s\n%s" % [category, value, explanation], callback, false) button.custom_minimum_size.y = 51 button.add_theme_font_size_override("font_size", scaled(11)) return button func render_hud(context: String) -> void: var bar := PanelContainer.new() bar.name = "GameHUD" bar.position = Vector2(12, 10) bar.size = Vector2(size.x - 24, 54) bar.add_theme_stylebox_override("panel", lacquer_style(Color(0.045, 0.075, 0.14, 0.94), COLOR_GOLD, 2)) display_root.add_child(bar) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) bar.add_child(row) var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home) row.add_child(home) var identity := VBoxContainer.new() identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(identity) identity.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 15, COLOR_WHITE)) identity.add_child(game_label(context, 10, COLOR_TEAL)) var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart) row.add_child(restart) row.add_child(icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)) func render_first_mission() -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.18)) render_hud(tr_text("第一次任务 · 风环遗迹", "First Mission · Windring Ruins")) var briefing := PanelContainer.new() briefing.name = "FirstMissionBriefing" briefing.position = Vector2(22, 78) briefing.size = Vector2(size.x * 0.52, size.y - 96) briefing.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(briefing) var briefing_box := VBoxContainer.new() briefing_box.add_theme_constant_override("separation", 7) briefing.add_child(briefing_box) briefing_box.add_child(game_label(tr_text("第一份委托", "FIRST COMMISSION"), 12, Color("#7a5b1c"))) briefing_box.add_child(game_label(tr_text("守住断裂的风路", "Hold the Broken Wind Road"), 24, COLOR_INK)) briefing_box.add_child(game_label(tr_text("裂影刃手占据了风环遗迹。你要组建一支两人小队,重新打开通往聚落的道路。", "A Riftblade holds the Windring Ruins. Build a two-person Company and reopen the road to the settlement."), 12, Color("#4c402d"))) briefing_box.add_child(game_label(tr_text("从四名成员中选择两人。你只负责选人,系统会根据职业自动安排他们的行动顺序。", "Choose two of four Recruits. You choose the people; the system arranges their field order by role."), 12, Color("#4c402d"))) var threat := HBoxContainer.new() threat.name = "FirstMissionThreat" threat.add_theme_constant_override("separation", 8) briefing_box.add_child(threat) var enemy_portrait := TextureRect.new() enemy_portrait.name = "FirstMissionEnemy" enemy_portrait.texture = RIFTBLADE["idle"] enemy_portrait.custom_minimum_size = Vector2(96, 92) enemy_portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE enemy_portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED enemy_portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS enemy_portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE threat.add_child(enemy_portrait) var threat_copy := VBoxContainer.new() threat_copy.alignment = BoxContainer.ALIGNMENT_CENTER threat_copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL threat.add_child(threat_copy) threat_copy.add_child(game_label(tr_text("本次威胁 · 裂影刃手", "THREAT · RIFTBLADE"), 12, COLOR_CRIMSON)) threat_copy.add_child(game_label(tr_text("它会正面突进并打击先行者。\n观察系统怎样排列你选的两个人,以及他们如何配合。", "It advances and strikes the lead.\nWatch how the system orders your two choices and how they work together."), 10, Color("#4c402d"))) var plan := PanelContainer.new() plan.name = "FirstMissionPlan" plan.position = Vector2(size.x * 0.57, 78) plan.size = Vector2(size.x * 0.40, size.y - 96) plan.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2)) display_root.add_child(plan) var plan_box := VBoxContainer.new() plan_box.alignment = BoxContainer.ALIGNMENT_CENTER plan_box.add_theme_constant_override("separation", 8) plan.add_child(plan_box) plan_box.add_child(centered_label(tr_text("你的第一项决定", "YOUR FIRST DECISION"), 16, COLOR_GOLD_LIGHT)) plan_box.add_child(centered_label(tr_text("1 任意选择两人\n2 系统自动排列\n3 检查两人的职责\n4 确认后自动战斗", "1 Choose any two people\n2 System arranges them\n3 Review both responsibilities\n4 Commit to automatic combat"), 12, COLOR_WHITE)) var begin := game_button("OpenRecruitSelectionButton", tr_text("开始选择成员", "Choose Recruits"), open_recruit_selection, true) begin.custom_minimum_size.y = 54 plan_box.add_child(begin) func render_recruit_selection() -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.16)) render_hud(tr_text("组建小队 · 选择任意两人", "Build the Company · Choose Any Two")) var guide := PanelContainer.new() guide.name = "RecruitSelectionGuide" guide.position = Vector2(18, 76) guide.size = Vector2(size.x - 36, 48) guide.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2)) display_root.add_child(guide) var guide_row := HBoxContainer.new() guide_row.add_theme_constant_override("separation", 8) guide.add_child(guide_row) var count_text := tr_text("已选 %d / 2" % selected_recruits.size(), "%d / 2 SELECTED" % selected_recruits.size()) var count_label := game_label(count_text, 15, COLOR_GOLD_LIGHT) count_label.name = "RecruitSelectionCount" count_label.custom_minimum_size.x = 112 count_label.autowrap_mode = TextServer.AUTOWRAP_OFF count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER guide_row.add_child(count_label) var instruction := tr_text("点角色加入;再次点击可移出。站位由系统安排。", "Tap to add; tap again to remove. The system assigns field order.") if selected_recruits.size() == 2: instruction = tr_text("小队已满。要换人,请先移出一名已选成员。", "Company full. Remove one selected Recruit before adding another.") var guide_copy := game_label(instruction, 11, COLOR_WHITE) guide_copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL guide_row.add_child(guide_copy) var card_width := 151.0 for index in range(AUTO_FORMATION_ORDER.size()): render_recruit_card(AUTO_FORMATION_ORDER[index], Vector2(18 + index * 159, 132), Vector2(card_width, size.y - 150)) var action := PanelContainer.new() action.name = "RecruitSelectionAction" action.position = Vector2(662, 132) action.size = Vector2(size.x - 680, size.y - 150) action.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.95), COLOR_GOLD, 2)) display_root.add_child(action) var action_box := VBoxContainer.new() action_box.alignment = BoxContainer.ALIGNMENT_CENTER action_box.add_theme_constant_override("separation", 8) action.add_child(action_box) action_box.add_child(centered_label(tr_text("系统编队", "AUTO FORMATION"), 12, COLOR_TEAL)) var selected_names: Array[String] = [] for profession in selected_recruits: selected_names.append(candidate_name(profession)) action_box.add_child(centered_label(tr_text("尚未选人", "No recruits yet") if selected_names.is_empty() else "\n".join(selected_names), 13, COLOR_WHITE)) var review := game_button("ReviewFormationButton", tr_text("查看自动阵型", "Review Formation"), review_automatic_formation, true) review.custom_minimum_size.y = 54 review.disabled = selected_recruits.size() != 2 action_box.add_child(review) func render_recruit_card(profession: String, card_position: Vector2, card_size: Vector2) -> void: var selected := selected_recruits.has(profession) var full := selected_recruits.size() >= 2 and not selected var role_color := Color("#7fb5ff") if profession in ["Hearthguard", "Wayrunner"] else Color("#70d6b0") var panel := PanelContainer.new() panel.name = "RecruitCard_%s" % profession panel.position = card_position panel.size = card_size panel.add_theme_stylebox_override("panel", candidate_style(selected, false, role_color)) display_root.add_child(panel) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 1) panel.add_child(box) var portrait := TextureRect.new() portrait.name = "RecruitPortrait_%s" % profession portrait.texture = ACTORS[profession] portrait.custom_minimum_size = Vector2(0, 98) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(portrait) box.add_child(centered_label(("✓ " if selected else "") + candidate_name(profession), 14, COLOR_GOLD_LIGHT if selected else COLOR_WHITE)) box.add_child(centered_label(candidate_role_short(profession), 10, COLOR_PARCHMENT)) box.add_child(centered_label(recruit_behavior_short(profession), 8, COLOR_PARCHMENT)) var label := tr_text("移出", "Remove") if selected else (tr_text("小队已满", "Full") if full else tr_text("选择", "Choose")) var choose := game_button("Recruit_%s" % profession, label, toggle_recruit.bind(profession), selected) choose.custom_minimum_size.y = 46 choose.disabled = full box.add_child(choose) func render_role_selection(selecting_front: bool) -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.16)) var slot_label := tr_text("前排", "FRONT") if selecting_front else tr_text("后排", "REAR") render_hud(tr_text("组建小队 · 选择%s" % slot_label, "Build the Company · Choose %s" % slot_label)) var choices: Array[String] = [] choices.assign(["Hearthguard", "Wayrunner"] if selecting_front else ["Farstring", "Kindhand"]) var selected := front_choice if selecting_front else rear_choice var guide := PanelContainer.new() guide.name = "FrontSelectionGuide" if selecting_front else "RearSelectionGuide" guide.position = Vector2(18, 78) guide.size = Vector2(208, size.y - 96) guide.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(guide) var guide_box := VBoxContainer.new() guide_box.add_theme_constant_override("separation", 7) guide.add_child(guide_box) guide_box.add_child(game_label(tr_text("第 1 步 / 2", "STEP 1 / 2") if selecting_front else tr_text("第 2 步 / 2", "STEP 2 / 2"), 11, Color("#7a5b1c"))) guide_box.add_child(game_label(tr_text("谁站在最前面?", "Who holds the line?") if selecting_front else tr_text("谁在后方支援?", "Who supports from the rear?"), 19, COLOR_INK)) guide_box.add_child(game_label(tr_text("敌人会先攻击前排。选择更稳定的格挡,或更灵活的闪避反击。", "The enemy strikes the front first. Choose steady blocking or evasive counterplay.") if selecting_front else tr_text("后排不会承受第一击。选择提前削弱敌人,或在受击后恢复前排。", "The rear avoids the first strike. Choose early damage or recovery after impact."), 11, Color("#4c402d"))) guide_box.add_child(game_label(tr_text("没有默认答案。点选后仍可替换。", "Nothing is preselected. You can still replace your choice."), 10, Color("#7a5b1c"))) for index in range(choices.size()): render_role_choice_card(choices[index], Vector2(242 + index * 196, 78), Vector2(180, size.y - 96), selecting_front) var action := PanelContainer.new() action.name = "SelectionAction" action.position = Vector2(638, 78) action.size = Vector2(size.x - 656, size.y - 96) action.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2)) display_root.add_child(action) var action_box := VBoxContainer.new() action_box.alignment = BoxContainer.ALIGNMENT_CENTER action_box.add_theme_constant_override("separation", 8) action.add_child(action_box) action_box.add_child(centered_label(tr_text("当前选择", "CURRENT CHOICE"), 12, COLOR_TEAL)) action_box.add_child(centered_label(tr_text("尚未选择", "Not selected") if selected.is_empty() else "✓ " + candidate_name(selected), 16, COLOR_WHITE)) if not selected.is_empty(): action_box.add_child(centered_label(formation_contribution(selected), 10, COLOR_PARCHMENT)) var next_name := "ContinueRearSelectionButton" if selecting_front else "ReviewFormationButton" var next_label := tr_text("继续选择后排", "Continue to Rear") if selecting_front else tr_text("检查完整阵型", "Review Formation") var next_callback := continue_to_rear_selection if selecting_front else review_formation var next_button := game_button(next_name, next_label, next_callback, true) next_button.custom_minimum_size.y = 54 next_button.disabled = selected.is_empty() action_box.add_child(next_button) if not selecting_front: action_box.add_child(game_button("BackToFrontSelectionButton", tr_text("返回修改前排", "Back to Front"), open_front_selection, false)) func render_role_choice_card(profession: String, card_position: Vector2, card_size: Vector2, selecting_front: bool) -> void: var selected := (front_choice if selecting_front else rear_choice) == profession var slot_color := Color("#7fb5ff") if selecting_front else Color("#70d6b0") var panel := PanelContainer.new() panel.name = "RoleCard_%s" % profession panel.position = card_position panel.size = card_size panel.add_theme_stylebox_override("panel", candidate_style(selected, false, slot_color)) display_root.add_child(panel) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 2) panel.add_child(box) var portrait := TextureRect.new() portrait.name = "RolePortrait_%s" % profession portrait.texture = ACTORS[profession] portrait.custom_minimum_size = Vector2(0, 116) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE box.add_child(portrait) box.add_child(centered_label(("✓ " if selected else "") + candidate_name(profession), 15, COLOR_GOLD_LIGHT if selected else COLOR_WHITE)) box.add_child(centered_label(candidate_role(profession), 9, COLOR_PARCHMENT)) var button_name := ("Front_" if selecting_front else "Rear_") + profession var callback: Callable = choose_front_candidate.bind(profession) if selecting_front else choose_rear_candidate.bind(profession) var choose := game_button(button_name, tr_text("已选择", "Selected") if selected else tr_text("选择", "Choose"), callback, selected) choose.custom_minimum_size.y = 46 box.add_child(choose) func render_formation_review() -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.14)) render_hud(tr_text("第一次远征 · 系统自动编队", "First Expedition · Automatic Formation")) var heading := centered_label(tr_text("你选择成员,系统根据职业安排先后位置", "YOU CHOOSE PEOPLE; THE SYSTEM ASSIGNS FIELD ORDER"), 16, COLOR_WHITE) heading.position = Vector2(150, 72) heading.size = Vector2(size.x - 300, 32) display_root.add_child(heading) render_formation_review_card("front", front_choice, Vector2(38, 108), Vector2(270, 196)) render_formation_review_card("rear", rear_choice, Vector2(322, 108), Vector2(270, 196)) var action := PanelContainer.new() action.name = "FormationCommitPanel" action.position = Vector2(606, 108) action.size = Vector2(size.x - 624, 196) action.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.95), COLOR_GOLD, 2)) display_root.add_child(action) var action_box := VBoxContainer.new() action_box.alignment = BoxContainer.ALIGNMENT_CENTER action_box.add_theme_constant_override("separation", 7) action.add_child(action_box) action_box.add_child(centered_label(tr_text("确认后", "ON COMMIT"), 12, COLOR_TEAL)) action_box.add_child(centered_label(tr_text("战斗自动开始\n观察先行者与第二名成员配合", "Combat starts automatically\nWatch the lead and second member combine"), 10, COLOR_WHITE)) var commit := game_button("CommitFormationButton", tr_text("确认阵型并出发", "Commit and Depart"), commit_formation, true) commit.custom_minimum_size.y = 54 commit.disabled = not formation_is_valid() or formation_commit_count > 0 action_box.add_child(commit) var revise := game_button("ReviseRecruitSelectionButton", tr_text("重新选择两名成员", "Revise Recruits"), revise_recruit_selection, false) revise.position = Vector2(38, size.y - 70) revise.size = Vector2(554, 52) display_root.add_child(revise) func render_formation_review_card(slot: String, profession: String, card_position: Vector2, card_size: Vector2) -> void: var color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0") var panel := PanelContainer.new() panel.name = "Formation_%s" % slot panel.position = card_position panel.size = card_size panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.92), color, 3)) display_root.add_child(panel) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 5) panel.add_child(row) var portrait := TextureRect.new() portrait.texture = ACTORS.get(profession) portrait.custom_minimum_size = Vector2(112, 150) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(portrait) var copy := VBoxContainer.new() copy.alignment = BoxContainer.ALIGNMENT_CENTER copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(copy) copy.add_child(game_label(tr_text("系统先行位", "SYSTEM LEAD") if slot == "front" else tr_text("系统第二位", "SYSTEM SECOND"), 10, color)) copy.add_child(game_label(candidate_name(profession), 17, COLOR_WHITE)) copy.add_child(game_label(candidate_role_short(profession), 11, COLOR_GOLD_LIGHT)) copy.add_child(game_label(formation_position_contribution(profession, slot), 9, COLOR_PARCHMENT)) func render_yard() -> void: add_full_wash(Color(0.015, 0.025, 0.055, 0.12)) render_hud(tr_text("公会庭院 · 组建两人小队", "Guild Yard · Build a two-person squad")) var direction := PanelContainer.new() direction.name = "YardDirection" direction.position = Vector2(22, 76) direction.size = Vector2(min(390.0, size.x * 0.44), 66) direction.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), COLOR_GOLD, 2)) var direction_box := VBoxContainer.new() direction_box.add_theme_constant_override("separation", 1) direction.add_child(direction_box) direction_box.add_child(game_label(tr_text("组建第一支两人小队", "Build your first two-person squad"), 18, COLOR_WHITE)) direction_box.add_child(game_label(tr_text("先选蓝色前排,再选绿色后排;可随时替换。", "Choose a blue front, then a green rear. You can replace either."), 11, COLOR_PARCHMENT)) display_root.add_child(direction) var stage_bottom := size.y - 98.0 var actor_height: float = clampf(size.y * 0.29, 106.0, 210.0) render_yard_slot("front", front_choice, Vector2(size.x * 0.39, stage_bottom - actor_height), actor_height) render_yard_slot("rear", rear_choice, Vector2(size.x * 0.57, stage_bottom - actor_height * 0.92), actor_height * 0.92) var candidate_dock := HBoxContainer.new() candidate_dock.name = "CandidateLine" candidate_dock.position = Vector2(18, size.y - 78) candidate_dock.size = Vector2(size.x * 0.69, 62) candidate_dock.add_theme_constant_override("separation", 6) display_root.add_child(candidate_dock) for profession in ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]: candidate_dock.add_child(candidate_button(profession)) var status := game_label(notice if not notice.is_empty() else tr_text("蓝色=前排 绿色=后排", "Blue = front Green = rear"), 11, COLOR_WHITE) status.name = "YardStatus" status.position = Vector2(22, size.y - 110) status.size = Vector2(size.x * 0.62, 28) status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER display_root.add_child(status) var depart := game_button("DepartButton", tr_text("出发:第一次远征", "Depart: First Expedition"), depart, true) depart.position = Vector2(size.x * 0.71, size.y - 78) depart.size = Vector2(size.x * 0.275, 62) depart.disabled = front_choice.is_empty() or rear_choice.is_empty() display_root.add_child(depart) func render_yard_slot(slot: String, profession: String, actor_position: Vector2, actor_height: float) -> void: var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0") var label_text := tr_text("前排", "FRONT") if slot == "front" else tr_text("后排", "REAR") if profession.is_empty(): var empty := centered_label("+\n" + label_text, 18, COLOR_WHITE) empty.name = "Empty_%s" % slot empty.position = actor_position + Vector2(actor_height * 0.16, actor_height * 0.18) empty.size = Vector2(actor_height * 0.68, actor_height * 0.62) empty.add_theme_stylebox_override("normal", dashed_slot_style(slot_color)) display_root.add_child(empty) return actor_at(ACTORS[profession], actor_position, Vector2(actor_height, actor_height), "Yard_%s" % slot) var plaque := centered_label(label_text + " · " + candidate_name(profession), 11, COLOR_WHITE) plaque.position = Vector2(actor_position.x - actor_height * 0.05, actor_position.y + actor_height * 0.76) plaque.size = Vector2(actor_height * 1.1, 34) plaque.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), slot_color, 2)) display_root.add_child(plaque) func candidate_button(profession: String) -> Button: var data: Dictionary = CANDIDATES[profession] var selected := front_choice == profession or rear_choice == profession var slot: String = data["slot"] var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0") var button := Button.new() button.name = "Candidate_%s" % profession var role := tr_text("前", "F") if slot == "front" else tr_text("后", "R") button.text = ("✓ " if selected else "") + role + " · " + candidate_name(profession) + "\n" + candidate_role_short(profession) button.focus_mode = Control.FOCUS_ALL button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.size_flags_stretch_ratio = 1.0 button.custom_minimum_size = Vector2(104, 60) 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_color_override("font_hover_color", COLOR_WHITE) button.add_theme_color_override("font_pressed_color", COLOR_WHITE) button.add_theme_stylebox_override("normal", candidate_style(selected, false, slot_color)) button.add_theme_stylebox_override("hover", candidate_style(selected, true, slot_color)) button.add_theme_stylebox_override("pressed", candidate_style(true, true, slot_color)) button.pressed.connect(func(): choose_candidate(profession), CONNECT_DEFERRED) return button 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 fourth_cycle := page == AppPage.FOURTH_BATTLE 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) var actor_width: float = actor_height var contact_reached := battle_phase > 0 or reduced_motion front_base_position = Vector2(size.x * (0.39 if contact_reached else 0.18), world_bottom - actor_height) rear_base_position = Vector2(size.x * (0.26 if contact_reached else 0.08), world_bottom - actor_height * 0.92) added_rear_base_position = Vector2(size.x * (0.12 if contact_reached else -0.06), world_bottom - actor_height * 0.86) enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84) front_sprite = actor_at(animation_texture(front_choice, front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor") rear_sprite = actor_at(animation_texture(rear_choice, rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor") if (third_cycle or fourth_cycle or fifth_cycle) and not added_rear.is_empty(): added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor") 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, 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() ribbon.name = "BattleEvent" ribbon.position = Vector2(size.x * 0.27, 116) ribbon.size = Vector2(size.x * 0.46, 48) 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 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 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) company.size = Vector2(size.x * 0.23, 48) 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 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": 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) elif second_cycle and adaptation_choice == "EQ-061" and battle_phase == 2: add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) elif battle_phase == 3 and rear_choice == "Farstring": 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() func render_victory() -> void: add_full_wash(Color(0.02, 0.035, 0.075, 0.2)) render_hud(tr_text("第一次远征完成", "First Expedition Complete")) var actor_height: float = clampf(size.y * 0.29, 98.0, 185.0) victory_front_sprite = actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.13, size.y - actor_height - 45), Vector2(actor_height, actor_height), "VictoryFront") victory_rear_sprite = actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.29, size.y - actor_height * 0.92 - 45), Vector2(actor_height * 0.92, actor_height * 0.92), "VictoryRear") victory_enemy_sprite = actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.48, size.y - actor_height * 0.52 - 42), Vector2(actor_height * 0.52, actor_height * 0.52), "VictoryEnemy") var banner := PanelContainer.new() banner.name = "VictoryBanner" var banner_height: float = min(size.y - 112.0, 330.0) banner.position = Vector2(size.x * 0.58, (size.y - banner_height) * 0.5 + 14.0) banner.size = Vector2(size.x * 0.38, banner_height) banner.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(banner) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 6) banner.add_child(box) box.add_child(centered_label("◆ " + tr_text("为什么这场赢了", "Why this battle was won") + " ◆", 22, COLOR_INK)) var explanation := centered_label(victory_explanation(), 12, Color("#4c402d")) explanation.name = "VictoryDiagnosis" box.add_child(explanation) var reward := centered_label(tr_text("远征奖励 +20 金币\n当前金币:%d" % coin, "Expedition reward +20 Coin\nCurrent Coin: %d" % coin), 13, Color("#6d5523")) reward.name = "FirstReward" box.add_child(reward) var again := game_button("ContinueAdaptationButton", tr_text("查看下一场威胁", "Read the next threat"), open_adaptation, true) again.custom_minimum_size.y = 48 box.add_child(again) var home := game_button("VictoryHomeButton", tr_text("回到主页", "Home"), go_home, false) home.custom_minimum_size.y = 46 box.add_child(home) func render_adaptation() -> void: add_full_wash(Color(0.015, 0.03, 0.07, 0.26)) render_hud(tr_text("战术借用 · 第二次远征前", "Tactical loan · Before the second expedition")) var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0) actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(size.x * 0.06, size.y - actor_height - 36), Vector2(actor_height, actor_height), "AdaptFront") actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.19, size.y - actor_height * 0.88 - 36), Vector2(actor_height * 0.88, actor_height * 0.88), "AdaptRear") actor_at(animation_texture("Riftblade", "attack", 0.35), Vector2(size.x * 0.28, size.y - actor_height * 0.75 - 38), Vector2(actor_height * 0.75, actor_height * 0.75), "AdaptEnemy") var threat := PanelContainer.new() threat.name = "NextThreat" threat.position = Vector2(20, 76) threat.size = Vector2(size.x * 0.37, 92) threat.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_CRIMSON, 2)) var threat_copy := VBoxContainer.new() threat.add_child(threat_copy) threat_copy.add_child(game_label(tr_text("下一场已知威胁", "NEXT KNOWN THREAT"), 10, COLOR_GOLD_LIGHT)) threat_copy.add_child(game_label(tr_text("老兵的第一击会推开并重创先行者%s" % candidate_name(front_choice), "The veteran's first strike pushes and heavily hits lead %s" % candidate_name(front_choice)), 14, COLOR_WHITE)) display_root.add_child(threat) var choice_panel := PanelContainer.new() choice_panel.name = "AdaptationConsole" choice_panel.position = Vector2(size.x * 0.39, 72) choice_panel.size = Vector2(size.x * 0.59, size.y - 88) choice_panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.94), COLOR_GOLD, 2)) display_root.add_child(choice_panel) var choice_shell := VBoxContainer.new() choice_shell.name = "AdaptationChoices" choice_shell.add_theme_constant_override("separation", 6) choice_panel.add_child(choice_shell) choice_shell.add_child(game_label(tr_text("借一件装备,只用于下一场", "Borrow one item for the next battle"), 17, COLOR_WHITE)) choice_shell.add_child(game_label(adaptation_guidance(), 10, COLOR_TEAL)) var choices := HBoxContainer.new() choices.add_theme_constant_override("separation", 8) choice_shell.add_child(choices) choices.add_child(adaptation_button("EQ-002", "▣")) choices.add_child(adaptation_button("EQ-061", "◷")) var begin := game_button("BeginSecondBattle", tr_text("带着这件装备出发", "Depart with this item"), begin_second_battle, true) begin.disabled = adaptation_choice.is_empty() begin.custom_minimum_size.y = 48 choice_shell.add_child(begin) func adaptation_button(id: String, symbol: String) -> Button: var selected := adaptation_choice == id var button := Button.new() button.name = "Adaptation_%s" % id.replace("-", "_") button.text = "%s %s\n%s\n%s" % [symbol, adaptation_name(id), adaptation_effect(id), adaptation_tradeoff(id)] button.focus_mode = Control.FOCUS_ALL button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.custom_minimum_size = Vector2(210, 116) 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", candidate_style(selected, false, COLOR_TEAL)) button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL)) button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL)) button.pressed.connect(func(): choose_adaptation(id), CONNECT_DEFERRED) return button func render_second_result() -> void: add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) render_hud(tr_text("第二次远征完成", "Second Expedition Complete")) var actor_height: float = clampf(size.y * 0.30, 100.0, 188.0) actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.11, size.y - actor_height - 42), Vector2(actor_height, actor_height), "SecondResultFront") actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.27, size.y - actor_height * 0.9 - 42), Vector2(actor_height * 0.9, actor_height * 0.9), "SecondResultRear") actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.47, size.y - actor_height * 0.54 - 40), Vector2(actor_height * 0.54, actor_height * 0.54), "SecondResultEnemy") var result := PanelContainer.new() result.name = "SecondResultPanel" result.position = Vector2(size.x * 0.60, 82) result.size = Vector2(size.x * 0.37, size.y - 108) 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", 4) result.add_child(box) var outcome := tr_text("更稳:%s保留更多生命" % candidate_name(front_choice), "SAFER: %s keeps more vitality" % candidate_name(front_choice)) if adaptation_choice == "EQ-002" else tr_text("更快:%s先手缩短战斗" % candidate_name(front_choice), "FASTER: %s acts first" % candidate_name(front_choice)) box.add_child(centered_label("◆ " + outcome + " ◆", 18, COLOR_INK)) box.add_child(centered_label(second_result_explanation(), 11, Color("#4c402d"))) var facts := centered_label(tr_text("%s生命 %d / 100\n战斗用时 %.2f 秒\n金币保留 %d" % [candidate_name(front_choice), front_hp, second_result_time, coin], "%s HP %d / 100\nBattle time %.2f s\nCoin kept %d" % [candidate_name(front_choice), front_hp, second_result_time, coin]), 13, Color("#6d5523")) facts.name = "SecondResultFacts" box.add_child(facts) if later_preview_supports_pair(): var return_button := game_button("OpenGuildReturnButton", tr_text("带着经验返回公会", "Return to the Guild"), open_guild_return, true) return_button.custom_minimum_size.y = 44 box.add_child(return_button) else: var boundary := centered_label(tr_text("这组搭配已验证到第二战", "This pair preview ends after battle two"), 10, Color("#7a5b1c")) boundary.name = "PairPreviewBoundary" box.add_child(boundary) var home := game_button("SecondResultHomeButton", tr_text("回到主页", "Home"), go_home, false) home.custom_minimum_size.y = 44 box.add_child(home) func render_guild_return() -> void: add_full_wash(Color(0.012, 0.028, 0.06, 0.16)) render_hud(tr_text("公会庭院 · 第三次远征准备", "Guild Yard · Prepare the third expedition")) var actor_height: float = clampf(size.y * 0.29, 98.0, 178.0) actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(size.x * 0.055, size.y - actor_height - 34), Vector2(actor_height, actor_height), "GuildFront") actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.19, size.y - actor_height * 0.9 - 34), Vector2(actor_height * 0.9, actor_height * 0.9), "GuildRear") if guild_choice == "recruit_rear": var preview_rear := missing_rear_profession() actor_at(animation_texture(preview_rear, "idle", victory_clock), Vector2(size.x * 0.315, size.y - actor_height * 0.82 - 36), Vector2(actor_height * 0.82, actor_height * 0.82), "GuildRecruitPreview") var resources := PanelContainer.new() resources.name = "GuildResources" resources.position = Vector2(18, 74) resources.size = Vector2(size.x * 0.35, 76) resources.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2)) display_root.add_child(resources) var resource_copy := VBoxContainer.new() resources.add_child(resource_copy) resource_copy.add_child(game_label(tr_text("带回公会的远征资源", "EXPEDITION RESOURCES RETURNED"), 10, COLOR_TEAL)) resource_copy.add_child(game_label(tr_text("金币 %d | 小队 2 人\n借用装备:%s" % [coin, adaptation_name(adaptation_choice)], "Coin %d | Company 2\nLoaned item: %s" % [coin, adaptation_name(adaptation_choice)]), 12, COLOR_WHITE)) var bench := PanelContainer.new() bench.name = "PreparationBench" bench.position = Vector2(size.x * 0.40, 72) bench.size = Vector2(size.x * 0.58, size.y - 88) bench.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.95), COLOR_GOLD, 2)) display_root.add_child(bench) var shell := VBoxContainer.new() shell.add_theme_constant_override("separation", 5) bench.add_child(shell) shell.add_child(game_label(tr_text("队长更强:把金币花在哪?", "THE CAPTAIN IS STRONGER: where should Coin go?"), 16, COLOR_WHITE)) shell.add_child(game_label(guild_choice_guidance(), 10, COLOR_TEAL)) var choices := HBoxContainer.new() choices.add_theme_constant_override("separation", 7) shell.add_child(choices) choices.add_child(guild_choice_button("retain_item")) choices.add_child(guild_choice_button("recruit_rear")) var depart_button := game_button("BeginThirdBattle", tr_text("确认花费并出发", "Confirm spend and depart"), begin_third_battle, true) depart_button.disabled = guild_choice.is_empty() depart_button.custom_minimum_size.y = 48 shell.add_child(depart_button) func guild_choice_button(id: String) -> Button: var selected := guild_choice == id var button := Button.new() button.name = "GuildChoice_%s" % id if id == "retain_item": button.text = tr_text("▣ 保留%s · 12 金币\n小队保持 2 人|剩余 8 金币\n继续发挥已验证的装备效果" % adaptation_name(adaptation_choice), "▣ RETAIN %s · 12 Coin\nCompany stays 2 | 8 Coin left\nKeep the proven item effect" % adaptation_name(adaptation_choice)) else: button.text = tr_text("+ 招募%s · 14 金币\n小队扩至 3 人|剩余 6 金币\n归还借用装备,补齐弓手与治疗" % candidate_name(missing_rear_profession()), "+ RECRUIT %s · 14 Coin\nCompany grows to 3 | 6 Coin left\nReturn item; gain Archer and Healer" % candidate_name(missing_rear_profession())) button.focus_mode = Control.FOCUS_ALL button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.custom_minimum_size = Vector2(215, 112) 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", candidate_style(selected, false, COLOR_TEAL)) button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL)) button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL)) button.pressed.connect(func(): choose_guild_option(id), CONNECT_DEFERRED) return button func render_session_result() -> void: add_full_wash(Color(0.02, 0.035, 0.075, 0.18)) render_hud(tr_text("首轮远征完成", "First Expedition Cycle Complete")) var actor_height: float = clampf(size.y * 0.29, 98.0, 180.0) actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.05, size.y - actor_height - 38), Vector2(actor_height, actor_height), "SessionFront") actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.9 - 38), Vector2(actor_height * 0.9, actor_height * 0.9), "SessionRear") if not added_rear.is_empty(): actor_at(animation_texture(added_rear, "idle", victory_clock), Vector2(size.x * 0.30, size.y - actor_height * 0.84 - 38), Vector2(actor_height * 0.84, actor_height * 0.84), "SessionAddedRear") actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.52 - 36), Vector2(actor_height * 0.52, actor_height * 0.52), "SessionEnemy") var result := PanelContainer.new() result.name = "SessionResultPanel" result.position = Vector2(size.x * 0.57, 78) result.size = Vector2(size.x * 0.40, size.y - 100) 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) var headline := tr_text("装备让两人小队守住优势", "THE ITEM KEPT A TWO-PERSON EDGE") if guild_choice == "retain_item" else tr_text("第三名成员补齐了远程与治疗", "A THIRD MEMBER COMPLETED THE REAR LINE") box.add_child(centered_label("◆ " + headline + " ◆", 17, COLOR_INK)) var explanation := centered_label(session_result_explanation(), 11, Color("#4c402d")) explanation.name = "SessionResultExplanation" box.add_child(explanation) var company_size := 3 if not added_rear.is_empty() else 2 var facts := centered_label(tr_text("金币 %d | 小队 %d 人\n前排生命 %d / 100 | 用时 %.2f 秒" % [coin, company_size, front_hp, third_result_time], "Coin %d | Company %d\nFront HP %d / 100 | Time %.2f s" % [coin, company_size, front_hp, third_result_time]), 12, Color("#6d5523")) facts.name = "SessionResultFacts" box.add_child(facts) 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("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" wash.color = Color(0.01, 0.015, 0.03, 0.76) wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) display_root.add_child(wash) var dialog := PanelContainer.new() dialog.name = "RestartDialog" dialog.position = Vector2(size.x * 0.31, size.y * 0.24) dialog.size = Vector2(size.x * 0.38, size.y * 0.48) dialog.add_theme_stylebox_override("panel", parchment_style()) display_root.add_child(dialog) var box := VBoxContainer.new() box.alignment = BoxContainer.ALIGNMENT_CENTER box.add_theme_constant_override("separation", 12) dialog.add_child(box) box.add_child(centered_label(tr_text("重新开始这次体验?", "Restart this experience?"), 20, COLOR_INK)) box.add_child(centered_label(tr_text("当前队伍和战斗进度会清空。", "The current squad and battle progress will be cleared."), 13, Color("#55472f"))) var confirm := game_button("RestartConfirmButton", tr_text("确认重新开始", "Restart"), confirm_restart, true) confirm.custom_minimum_size.y = 52 box.add_child(confirm) var cancel := game_button("RestartCancelButton", tr_text("继续当前体验", "Keep playing"), cancel_restart, false) cancel.custom_minimum_size.y = 48 box.add_child(cancel) func run_boot_verification() -> void: if boot_running: return boot_running = true boot_verified = false boot_failed_path = "" boot_index = 0 render() for path in active_boot_paths(): if not ResourceLoader.exists(path) or ResourceLoader.load(path) == null: boot_failed_path = path boot_running = false render() return boot_index += 1 render() await get_tree().process_frame boot_verified = true boot_running = false render() await get_tree().create_timer(0.35).timeout page = AppPage.TITLE render() func active_boot_paths() -> Array[String]: return boot_paths_override if not boot_paths_override.is_empty() else BOOT_REQUIRED_PATHS func retry_boot() -> void: call_deferred("run_boot_verification") func enter_game() -> void: if page != AppPage.TITLE or not boot_verified: return page = AppPage.MAIN_MENU play_cue("confirm") render() func open_game_intro() -> void: if page != AppPage.MAIN_MENU: return page = AppPage.GAME_INTRO play_cue("select") render() func open_settings() -> void: if page != AppPage.MAIN_MENU: return page = AppPage.SETTINGS play_cue("select") render() func back_to_main_menu() -> void: if page not in [AppPage.GAME_INTRO, AppPage.SETTINGS]: return page = AppPage.MAIN_MENU play_cue("confirm") render() func start_new_game() -> void: if page != AppPage.MAIN_MENU: return clear_experience() page = AppPage.FIRST_MISSION play_cue("confirm") render() func start_adventure() -> void: # Historical direct-entry helper retained for frozen battle regressions. clear_experience() page = AppPage.YARD notice = tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.") play_cue("confirm") render() func open_recruit_selection() -> void: if page not in [AppPage.FIRST_MISSION, AppPage.FORMATION_REVIEW]: return page = AppPage.RECRUIT_SELECTION notice = "" play_cue("select") render() func open_front_selection() -> void: # Compatibility alias for frozen R9 tests; the player path uses one page. if page in [AppPage.FIRST_MISSION, AppPage.FORMATION_REVIEW]: open_recruit_selection() func toggle_recruit(profession: String) -> void: if page != AppPage.RECRUIT_SELECTION or not CANDIDATES.has(profession): return if selected_recruits.has(profession): selected_recruits.erase(profession) notice = tr_text("已移出%s。" % candidate_name(profession), "%s removed." % candidate_name(profession)) elif selected_recruits.size() < 2: selected_recruits.append(profession) notice = tr_text("已选择%s。" % candidate_name(profession), "%s selected." % candidate_name(profession)) else: notice = tr_text("小队已满;请先移出一名成员。", "Company full; remove one Recruit first.") return play_cue("select") render() func ordered_selected_recruits() -> Array[String]: var ordered: Array[String] = [] for profession in AUTO_FORMATION_ORDER: if selected_recruits.has(profession): ordered.append(profession) return ordered func assign_automatic_formation() -> bool: var ordered := ordered_selected_recruits() if ordered.size() != 2: return false front_choice = ordered[0] rear_choice = ordered[1] return true func review_automatic_formation() -> void: if page != AppPage.RECRUIT_SELECTION or not assign_automatic_formation(): return page = AppPage.FORMATION_REVIEW notice = "" play_cue("confirm") render() func revise_recruit_selection() -> void: if page != AppPage.FORMATION_REVIEW or formation_commit_count > 0: return page = AppPage.RECRUIT_SELECTION play_cue("select") render() func choose_front_candidate(profession: String) -> void: if page != AppPage.FRONT_SELECTION or not CANDIDATES.has(profession) or CANDIDATES[profession]["slot"] != "front": return front_choice = profession notice = tr_text("已选择%s;确认后继续选择后排。" % candidate_name(profession), "%s selected. Continue to choose the rear." % candidate_name(profession)) play_cue("select") render() func continue_to_rear_selection() -> void: if page != AppPage.FRONT_SELECTION or not valid_front_choice(): return page = AppPage.REAR_SELECTION notice = "" play_cue("confirm") render() func choose_rear_candidate(profession: String) -> void: if page != AppPage.REAR_SELECTION or not CANDIDATES.has(profession) or CANDIDATES[profession]["slot"] != "rear": return rear_choice = profession notice = tr_text("已选择%s;现在检查完整阵型。" % candidate_name(profession), "%s selected. Review the complete formation." % candidate_name(profession)) play_cue("select") render() func review_formation() -> void: if page != AppPage.REAR_SELECTION or not formation_is_valid(): return page = AppPage.FORMATION_REVIEW notice = "" play_cue("confirm") render() func revise_front_selection() -> void: if page != AppPage.FORMATION_REVIEW or formation_commit_count > 0: return page = AppPage.FRONT_SELECTION play_cue("select") render() func revise_rear_selection() -> void: if page != AppPage.FORMATION_REVIEW or formation_commit_count > 0: return page = AppPage.REAR_SELECTION play_cue("select") render() func valid_front_choice() -> bool: return CANDIDATES.has(front_choice) and CANDIDATES[front_choice]["slot"] == "front" func valid_rear_choice() -> bool: return CANDIDATES.has(rear_choice) and CANDIDATES[rear_choice]["slot"] == "rear" func formation_is_valid() -> bool: return selected_recruits.size() == 2 and front_choice in selected_recruits and rear_choice in selected_recruits and front_choice != rear_choice func commit_formation() -> void: if page != AppPage.FORMATION_REVIEW or not formation_is_valid() or formation_commit_count > 0: return formation_commit_count += 1 begin_first_battle() func formation_contribution(profession: String) -> String: match profession: "Hearthguard": return tr_text("稳稳格挡第一记重击,\n用防守保留更多生命。", "Blocks the first heavy strike\nand preserves more health.") "Wayrunner": return tr_text("闪避一部分伤害,\n随后用反击回应敌人。", "Evades part of the damage,\nthen answers with a counter.") "Farstring": return tr_text("在后排提前射击,\n直接削减敌人生命。", "Fires early from the rear\nto reduce enemy health.") "Kindhand": return tr_text("敌人出手后治疗前排,\n恢复已经损失的生命。", "Heals the front after impact\nand restores lost health.") return "" func recruit_behavior_short(profession: String) -> String: match profession: "Hearthguard": return tr_text("格挡 · 承压", "Block · endure") "Wayrunner": return tr_text("闪避 · 反击", "Evade · counter") "Farstring": return tr_text("远射 · 输出", "Ranged damage") "Kindhand": return tr_text("治疗 · 恢复", "Heal · recover") return "" func formation_position_contribution(profession: String, slot: String) -> String: if slot == "front": match profession: "Hearthguard": return tr_text("系统让他先行:\n格挡敌人的第一击。", "System puts them first:\nblocks the opening strike.") "Wayrunner": return tr_text("系统让他先行:\n闪避后准备反击。", "System puts them first:\nevades, then counters.") "Farstring": return tr_text("系统让她先行:\n会暴露在敌人第一击下。", "System puts her first:\nexposed to the opening strike.") return tr_text("第二位会在先行者受击后行动。", "The second member acts after the lead is struck.") if profession == "Wayrunner" else formation_contribution(profession) func choose_candidate(profession: String) -> void: if not CANDIDATES.has(profession): return var slot: String = CANDIDATES[profession]["slot"] if slot == "front": front_choice = profession notice = tr_text("%s进入前排。现在选择一名后排。" % candidate_name(profession), "%s joined the front. Now choose a rear role." % candidate_name(profession)) else: rear_choice = profession notice = tr_text("%s进入后排。队伍已准备好。" % candidate_name(profession), "%s joined the rear. The squad is ready." % candidate_name(profession)) play_cue("select") render() func depart() -> void: if front_choice.is_empty() or rear_choice.is_empty(): return begin_first_battle() func begin_first_battle() -> void: battle_phase = 0 battle_clock = 0.0 battle_playing = true front_hp = 100 enemy_hp = 100 guard_events = 0 rear_events = 0 victory_events = 0 phase_impact_committed.clear() reset_battle_feedback() victory_clock = 0.0 page = AppPage.BATTLE play_cue("depart") render() func advance_battle_phase() -> void: 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 battle_clock = 0.0 if page == AppPage.THIRD_BATTLE: if battle_phase == 7: battle_playing = false victory_events += 1 third_result_time = third_battle_elapsed 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 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 victory_events += 1 if page == AppPage.SECOND_BATTLE: second_result_time = second_battle_elapsed page = AppPage.SECOND_RESULT else: commit_first_victory_reward() page = AppPage.VICTORY render() 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: 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: return 3.0 / 12.0 if retained_item == "EQ-061" 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.SECOND_BATTLE and adaptation_choice == "EQ-061": match phase: 1: return 3.0 / 12.0 2: return 4.0 / 12.0 3: return 2.0 / 7.0 4: return 3.0 / 10.0 5: return 5.0 / 10.0 match phase: 2: return 4.0 / 12.0 3: return 2.0 / 7.0 4: return 3.0 / 10.0 5: return 5.0 / 10.0 return -1.0 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): 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): var third_durations: Array = THIRD_BATTLE_PHASE_DURATIONS[route] return float(third_durations[phase]) if phase >= 0 and phase < third_durations.size() else 0.0 if page == AppPage.SECOND_BATTLE and SECOND_BATTLE_PHASE_DURATIONS.has(adaptation_choice): var durations: Array = SECOND_BATTLE_PHASE_DURATIONS[adaptation_choice] return float(durations[phase]) if phase >= 0 and phase < durations.size() else 0.0 if phase < 0 or phase >= BATTLE_PHASE_DURATIONS.size(): return 0.0 return float(BATTLE_PHASE_DURATIONS[phase]) func commit_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" 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 if page == AppPage.THIRD_BATTLE: commit_third_phase_impact(phase, refresh_runtime) return if page == AppPage.SECOND_BATTLE: commit_second_phase_impact(phase, refresh_runtime) return match phase: 2: var previous_front_hp := front_hp front_hp = 82 if front_choice == "Hearthguard" else (88 if front_choice == "Wayrunner" else 58) target = "front" delta = front_hp - previous_front_hp qualifier_zh = "格挡" if front_choice == "Hearthguard" else ("闪避减伤" if front_choice == "Wayrunner" else "脆弱先行") qualifier_en = "blocked" if front_choice == "Hearthguard" else ("evaded" if front_choice == "Wayrunner" else "exposed lead") guard_events += 1 play_cue("hit") 3: if rear_choice in ["Farstring", "Wayrunner"]: var previous_enemy_hp := enemy_hp enemy_hp = 64 if rear_choice == "Farstring" else 70 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "后排命中" if rear_choice == "Farstring" else "侧翼反击" qualifier_en = "rear hit" if rear_choice == "Farstring" else "flank counter" 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: var previous_enemy_hp := enemy_hp enemy_hp = 34 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "连携" qualifier_en = "combo" play_cue("hit") 5: var previous_enemy_hp := enemy_hp enemy_hp = 0 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "终结" qualifier_en = "finisher" 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.BATTLE: render() func commit_second_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 adaptation_choice != "EQ-061": return var previous_enemy_hp := enemy_hp enemy_hp = 72 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "先手" qualifier_en = "initiative" play_cue("hit") 2: var previous_front_hp := front_hp front_hp = 90 if adaptation_choice == "EQ-002" else 70 target = "front" delta = front_hp - previous_front_hp qualifier_zh = "抗推" if adaptation_choice == "EQ-002" else "重击" qualifier_en = "push resisted" if adaptation_choice == "EQ-002" else "heavy strike" guard_events += 1 play_cue("hit") 3: if rear_choice in ["Farstring", "Wayrunner"]: var previous_enemy_hp := enemy_hp if rear_choice == "Farstring": enemy_hp = 64 if adaptation_choice == "EQ-002" else 36 else: enemy_hp = 70 if adaptation_choice == "EQ-002" else 42 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "后排命中" if rear_choice == "Farstring" else "侧翼反击" qualifier_en = "rear hit" if rear_choice == "Farstring" else "flank counter" 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: var previous_enemy_hp := enemy_hp enemy_hp = 34 if adaptation_choice == "EQ-002" else 24 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "连携" qualifier_en = "combo" play_cue("hit") 5: var previous_enemy_hp := enemy_hp enemy_hp = 0 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "终结" qualifier_en = "finisher" 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.SECOND_BATTLE: render() func commit_third_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 retained_item != "EQ-061": return var previous_enemy_hp := enemy_hp enemy_hp = 102 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "晨钟先手" qualifier_en = "Early Bell" play_cue("hit") 2: var previous_front_hp := front_hp front_hp = 90 if retained_item == "EQ-002" else 70 target = "front" delta = front_hp - previous_front_hp qualifier_zh = "装备抗推" if retained_item == "EQ-002" else "队长重击" qualifier_en = "item resisted" if retained_item == "EQ-002" else "captain strike" guard_events += 1 play_cue("hit") 3: if rear_choice == "Farstring": var previous_enemy_hp := enemy_hp enemy_hp = 66 if added_rear.is_empty() else 94 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "弓手命中" qualifier_en = "Archer hit" 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 = 94 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "新弓手命中" qualifier_en = "new Archer hit" 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 = 44 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "装备连携" qualifier_en = "item follow-up" play_cue("hit") 5: var previous_enemy_hp := enemy_hp enemy_hp = 32 if not added_rear.is_empty() else 26 target = "enemy" delta = enemy_hp - previous_enemy_hp qualifier_zh = "三人连携" if not added_rear.is_empty() else "压制" qualifier_en = "three-person combo" if not added_rear.is_empty() else "pressure" 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 = "captain 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.THIRD_BATTLE: 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 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, "target": target, "delta": delta, "qualifier_zh": qualifier_zh, "qualifier_en": qualifier_en, "tier": tier, } battle_feedback_clock = 0.0 battle_feedback_history.append(active_battle_feedback.duplicate(true)) func reset_battle_feedback() -> void: active_battle_feedback.clear() battle_feedback_clock = 0.0 battle_feedback_history.clear() battle_feedback_label = null battle_feedback_vfx = null func expire_battle_feedback() -> void: active_battle_feedback.clear() battle_feedback_clock = 0.0 if battle_feedback_label != null and is_instance_valid(battle_feedback_label): battle_feedback_label.queue_free() if battle_feedback_vfx != null and is_instance_valid(battle_feedback_vfx): battle_feedback_vfx.queue_free() battle_feedback_label = null battle_feedback_vfx = null 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, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.THIRD_CYCLE_RESULT]: if front_choice.is_empty() or rear_choice.is_empty(): return victory_clock += delta if victory_front_sprite != null and is_instance_valid(victory_front_sprite): 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, AppPage.FIFTH_BATTLE] or not battle_playing or restart_confirmation: return var feedback_was_active := not active_battle_feedback.is_empty() battle_clock += delta if page == AppPage.SECOND_BATTLE: second_battle_elapsed += delta elif page == AppPage.THIRD_BATTLE: 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) var duration := phase_duration(battle_phase) if battle_clock >= duration: advance_battle_phase() return if front_sprite == null or not is_instance_valid(front_sprite): return var progress: float = clampf(battle_clock / duration, 0.0, 1.0) if battle_phase == 0 and not reduced_motion: front_sprite.position.x = lerp(size.x * 0.13, size.x * 0.38, progress) rear_sprite.position.x = lerp(size.x * 0.03, size.x * 0.20, progress) if added_rear_sprite != null and is_instance_valid(added_rear_sprite): added_rear_sprite.position.x = lerp(-added_rear_sprite.size.x, size.x * 0.10, progress) enemy_sprite.position.x = lerp(size.x * 0.82, size.x * 0.57, progress) front_sprite.texture = animation_texture(front_choice, front_animation_state(), battle_clock) rear_sprite.texture = animation_texture(rear_choice, rear_animation_state(), battle_clock) 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 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 var projectile_progress := clampf((battle_clock - projectile_start) / (projectile_end - projectile_start), 0.0, 1.0) projectile.visible = battle_clock >= projectile_start and battle_clock <= projectile_end projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, projectile_progress) if feedback_was_active: battle_feedback_clock += delta update_battle_feedback_visual() func commit_first_victory_reward() -> void: if first_reward_committed: return first_reward_committed = true coin += FIRST_VICTORY_COIN func open_adaptation() -> void: if page != AppPage.VICTORY or not first_reward_committed: return page = AppPage.ADAPTATION play_cue("confirm") render() func choose_adaptation(id: String) -> void: if page != AppPage.ADAPTATION or adaptation_committed or id not in ["EQ-002", "EQ-061"]: return adaptation_choice = id play_cue("select") render() func begin_second_battle() -> void: if page != AppPage.ADAPTATION or adaptation_choice.is_empty() or adaptation_committed: return adaptation_committed = true battle_phase = 0 battle_clock = 0.0 battle_playing = true front_hp = 100 enemy_hp = 100 guard_events = 0 rear_events = 0 phase_impact_committed.clear() reset_battle_feedback() second_battle_elapsed = 0.0 second_result_time = 0.0 page = AppPage.SECOND_BATTLE play_cue("depart") render() func open_guild_return() -> void: if page != AppPage.SECOND_RESULT or not adaptation_committed: return guild_choice = "" guild_committed = false retained_item = "" added_rear = "" page = AppPage.GUILD_RETURN play_cue("confirm") render() func choose_guild_option(id: String) -> void: if page != AppPage.GUILD_RETURN or guild_committed or id not in ["retain_item", "recruit_rear"]: return var cost := RETAIN_ITEM_COST if id == "retain_item" else RECRUIT_REAR_COST if coin < cost: return guild_choice = id play_cue("select") render() func begin_third_battle() -> void: if page != AppPage.GUILD_RETURN or guild_committed or guild_choice.is_empty(): return var cost := RETAIN_ITEM_COST if guild_choice == "retain_item" else RECRUIT_REAR_COST if coin < cost: return guild_committed = true coin -= cost if guild_choice == "retain_item": retained_item = adaptation_choice added_rear = "" else: retained_item = "" added_rear = missing_rear_profession() battle_phase = 0 battle_clock = 0.0 battle_playing = true front_hp = 100 enemy_hp = 130 guard_events = 0 rear_events = 0 phase_impact_committed.clear() reset_battle_feedback() third_battle_elapsed = 0.0 third_result_time = 0.0 page = AppPage.THIRD_BATTLE play_cue("depart") 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 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() func cancel_restart() -> void: restart_confirmation = false render() func confirm_restart() -> void: restart_confirmation = false clear_experience() page = AppPage.MAIN_MENU play_cue("confirm") render() func go_home() -> void: restart_confirmation = false clear_experience() page = AppPage.MAIN_MENU play_cue("confirm") render() func clear_experience() -> void: front_choice = "" rear_choice = "" selected_recruits.clear() formation_commit_count = 0 notice = "" battle_phase = 0 battle_clock = 0.0 battle_playing = false front_hp = 100 enemy_hp = 100 guard_events = 0 rear_events = 0 victory_events = 0 coin = 0 first_reward_committed = false adaptation_choice = "" adaptation_committed = false second_battle_elapsed = 0.0 second_result_time = 0.0 guild_choice = "" guild_committed = false retained_item = "" 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 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 func toggle_locale() -> void: locale = "en" if locale == "zh_CN" else "zh_CN" render() func toggle_text_scale() -> void: text_scale = 1.0 if text_scale > 1.0 else 1.3 render() func toggle_motion() -> void: reduced_motion = not reduced_motion render() func toggle_mute() -> void: muted = not muted AudioServer.set_bus_mute(AudioServer.get_bus_index(&"SFX"), muted) if not muted: play_cue("select") render() 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: return third_battle_event_text() if page == AppPage.SECOND_BATTLE: return second_battle_event_text() match battle_phase: 0: return tr_text("小队正在接近敌人…", "The squad advances toward the enemy…") 1: return tr_text("危险!裂影刃手准备攻击前排", "Danger! The Riftblade targets the front") 2: if front_choice == "Hearthguard": return tr_text("盾卫挡住重击,伤害降低", "The Guardian braces and reduces the blow") if front_choice == "Wayrunner": return tr_text("游侠侧身闪避并准备反击", "The Runner evades and prepares a counter") return tr_text("弓手被系统排在先行位,承受了高额伤害", "The Archer was assigned as lead and takes heavy damage") 3: if rear_choice == "Farstring": return tr_text("弓手从第二位射中敌人", "The Archer strikes from second position") if rear_choice == "Kindhand": return tr_text("治疗举起灵灯,为先行者恢复生命", "The Healer restores the lead") return tr_text("游侠从第二位切入,发动侧翼反击", "The Runner enters from second position with a flank counter") 4: return tr_text("前后排配合,敌人即将倒下", "Front and rear combine; the enemy is nearly defeated") 5: return tr_text("裂影刃手被击败!", "Riftblade defeated!") return "" func victory_explanation() -> String: var lead_line := tr_text("系统让%s先行,她承受了更高风险。" % candidate_name(front_choice), "The system put %s first, exposing her to greater risk." % candidate_name(front_choice)) if front_choice == "Farstring" else tr_text("系统让%s先行并回应了敌人的第一击。" % candidate_name(front_choice), "The system put %s first to answer the opening strike." % candidate_name(front_choice)) var second_line := tr_text("%s从第二位发动侧翼反击。" % candidate_name(rear_choice), "%s countered from second position." % candidate_name(rear_choice)) if rear_choice == "Wayrunner" else (tr_text("%s从第二位造成远程伤害。" % candidate_name(rear_choice), "%s dealt ranged damage from second position." % candidate_name(rear_choice)) if rear_choice == "Farstring" else tr_text("%s从第二位恢复先行者生命。" % candidate_name(rear_choice), "%s restored the lead from second position." % candidate_name(rear_choice))) return lead_line + "\n" + second_line func second_battle_event_text() -> String: match battle_phase: 0: return tr_text("小队带着借用装备迎战老兵…", "The squad advances with its loaned item…") 1: return tr_text("晨钟先手:%s抢在敌人之前行动" % candidate_name(front_choice), "Early Bell: %s acts before the enemy" % candidate_name(front_choice)) if adaptation_choice == "EQ-061" else tr_text("危险!老兵准备推开并重创%s" % candidate_name(front_choice), "Danger! The veteran prepares to strike %s" % candidate_name(front_choice)) 2: return tr_text("钉阵胫甲稳住%s,重击降为 -10" % candidate_name(front_choice), "Frontpin anchors %s; the strike falls to -10" % candidate_name(front_choice)) if adaptation_choice == "EQ-002" else tr_text("%s承受 -30 重击;速度换来风险" % candidate_name(front_choice), "%s takes the -30 strike; speed accepted the risk" % candidate_name(front_choice)) 3: if rear_choice == "Wayrunner": return tr_text("%s从第二位切入,发动侧翼反击" % candidate_name(rear_choice), "%s enters from second position with a flank counter" % candidate_name(rear_choice)) if rear_choice == "Farstring": return tr_text("%s从第二位补上远程伤害" % candidate_name(rear_choice), "%s follows with a ranged hit" % candidate_name(rear_choice)) return tr_text("%s从第二位恢复%s生命" % [candidate_name(rear_choice), candidate_name(front_choice)], "%s restores %s from second position" % [candidate_name(rear_choice), candidate_name(front_choice)]) 4: return tr_text("更慢但更稳的连携开始收尾", "The slower, safer coordination begins the finish") if adaptation_choice == "EQ-002" else tr_text("先手优势让敌人更快进入终结线", "Initiative brings the enemy to the finish sooner") 5: return tr_text("老兵裂影刃手被击败!", "Veteran Riftblade defeated!") return "" func third_battle_event_text() -> String: match battle_phase: 0: return tr_text("小队向裂隙队长推进…", "The squad advances on the Rift Captain…") 1: return tr_text("晨钟先手:前排抢在队长之前行动", "Early Bell: the front acts before the captain") if retained_item == "EQ-061" else tr_text("队长蓄力,准备撕开前排", "The captain gathers force against the front") 2: return tr_text("钉阵胫甲守住阵线,重击只造成 -10", "Frontpin holds the line; the strike deals only -10") if retained_item == "EQ-002" else tr_text("队长重击前排;现在需要后排支援", "The captain hits the front; rear support is needed") 3: return tr_text("原后排弓手命中队长", "The original Archer hits the captain") if rear_choice == "Farstring" else tr_text("原后排治疗为前排恢复 +12", "The original Healer restores +12 to the front") 4: if not added_rear.is_empty(): return tr_text("新弓手补上远程伤害", "The new Archer adds ranged damage") if added_rear == "Farstring" else tr_text("新治疗补上 +12 恢复", "The new Healer adds +12 recovery") return tr_text("保留装备继续放大两人连携", "The retained item amplifies the two-person follow-up") 5: return tr_text("三人阵线合力压制队长", "The three-person line overwhelms the captain") if not added_rear.is_empty() else tr_text("两人阵线守住装备优势", "The two-person line keeps its item advantage") 6: return tr_text("裂隙队长被击败!", "Rift Captain defeated!") 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 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 "" 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") func adaptation_effect(id: String) -> String: return tr_text("保护先行者 · 第一次伤害降为 -10", "Protect the lead · first damage becomes -10") if id == "EQ-002" else tr_text("让先行者抢在敌人之前攻击", "Move the lead's first attack before the enemy") func adaptation_tradeoff(id: String) -> String: return tr_text("代价:小队下一次攻击更晚", "Tradeoff: the next Company strike is later") if id == "EQ-002" else tr_text("代价:先行者承受完整 -30 重击", "Tradeoff: the lead takes the full -30 strike") func adaptation_guidance() -> String: if adaptation_choice.is_empty(): return tr_text("两种都能获胜:一个更稳,一个更快。没有预选。", "Both can win: one is safer, one is faster. Nothing is preselected.") return tr_text("已选择 %s;确认前仍可切换。" % adaptation_name(adaptation_choice), "%s selected; you can still switch before departure." % adaptation_name(adaptation_choice)) func second_result_explanation() -> String: var member_line := "" match rear_choice: "Wayrunner": member_line = tr_text("%s从第二位发动侧翼反击。" % candidate_name(rear_choice), "%s countered from second position." % candidate_name(rear_choice)) "Farstring": member_line = tr_text("%s从第二位补上远程伤害。" % candidate_name(rear_choice), "%s added ranged damage from second position." % candidate_name(rear_choice)) "Kindhand": member_line = tr_text("%s为%s恢复了 12 生命。" % [candidate_name(rear_choice), candidate_name(front_choice)], "%s restored 12 HP to %s." % [candidate_name(rear_choice), candidate_name(front_choice)]) if adaptation_choice == "EQ-002": return tr_text("钉阵胫甲让%s受到的第一击从 -30 降到 -10。" % candidate_name(front_choice), "Frontpin reduced %s's first hit from -30 to -10." % candidate_name(front_choice)) + "\n" + member_line return tr_text("晨钟让%s先造成 -28,但她承受了完整 -30 重击。" % candidate_name(front_choice), "Early Bell let %s deal -28 first, but she took the full -30 strike." % candidate_name(front_choice)) + "\n" + member_line func later_preview_supports_pair() -> bool: return front_choice in ["Hearthguard", "Wayrunner"] and rear_choice in ["Farstring", "Kindhand"] func missing_rear_profession() -> String: return "Kindhand" if rear_choice == "Farstring" else "Farstring" func guild_choice_guidance() -> String: if guild_choice.is_empty(): return tr_text("没有预选。保留已验证的装备,或用更多金币补齐后排。", "Nothing is preselected. Keep the proven item, or spend more to complete the rear line.") if guild_choice == "retain_item": return tr_text("已选择保留装备;剩余 8 金币、小队 2 人。出发前仍可切换。", "Retain selected: 8 Coin, Company 2. You can still switch before departure.") return tr_text("已选择招募%s;剩余 6 金币、小队 3 人。出发前仍可切换。" % candidate_name(missing_rear_profession()), "Recruit %s selected: 6 Coin, Company 3. You can still switch." % candidate_name(missing_rear_profession())) func session_result_explanation() -> String: if guild_choice == "retain_item": if retained_item == "EQ-002": return tr_text("你把金币用于保留钉阵胫甲。\n队伍仍是两人,但队长重击从 -30 降为 -10。", "You spent Coin to retain Frontpin.\nThe line stayed at two, but the captain strike fell from -30 to -10.") return tr_text("你把金币用于保留晨钟。\n队伍仍是两人,但先手让战斗更快结束。", "You spent Coin to retain Early Bell.\nThe line stayed at two, but initiative ended the battle faster.") return tr_text("你归还借用装备并招募了%s。\n弓手与治疗都行动:阵线更完整,但花费更多。" % candidate_name(added_rear), "You returned the loan and recruited %s.\nArcher and Healer both acted: a fuller line at greater cost." % candidate_name(added_rear)) func candidate_name(profession: String) -> String: if profession.is_empty() or not CANDIDATES.has(profession): return "" return CANDIDATES[profession]["name_zh"] if locale == "zh_CN" else CANDIDATES[profession]["name_en"] func candidate_role(profession: String) -> String: return CANDIDATES[profession]["role_zh"] if locale == "zh_CN" else CANDIDATES[profession]["role_en"] func candidate_role_short(profession: String) -> String: match profession: "Hearthguard": return tr_text("盾卫", "Guardian") "Wayrunner": return tr_text("游侠", "Runner") "Farstring": return tr_text("弓手", "Archer") "Kindhand": return tr_text("治疗", "Healer") return "" func tr_text(zh: String, en: String) -> String: return zh if locale == "zh_CN" else en func portrait_layout() -> bool: return size.y > size.x func scaled(value: int) -> int: return int(round(float(value) * text_scale)) func add_full_wash(color: Color) -> ColorRect: var wash := ColorRect.new() wash.color = color wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) wash.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(wash) return wash func actor_at(texture: Texture2D, actor_position: Vector2, actor_size: Vector2, node_name: String) -> TextureRect: var actor := TextureRect.new() actor.name = node_name actor.texture = texture actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE actor.stretch_mode = TextureRect.STRETCH_SCALE actor.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(actor) actor.position = actor_position actor.size = actor_size return actor func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> TextureRect: var image := TextureRect.new() image.name = "BattleVFX" image.texture = texture image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE image.stretch_mode = TextureRect.STRETCH_SCALE image.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(image) image.position = vfx_position image.size = Vector2(diameter, diameter) return image func render_battle_feedback(actor_width: float, actor_height: float) -> void: if active_battle_feedback.is_empty(): return var target: String = active_battle_feedback["target"] var delta: int = int(active_battle_feedback["delta"]) var target_position := front_base_position if target == "front" else (rear_base_position if target == "rear" else enemy_base_position) var target_scale := 1.0 if target == "front" else (0.90 if target == "rear" else 0.82) var 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), maxf(174.0, target_position.y - actor_height * 0.10) ) var signed_value := ("+" if delta > 0 else "") + str(delta) var qualifier: String = active_battle_feedback["qualifier_zh"] if locale == "zh_CN" else active_battle_feedback["qualifier_en"] var feedback_color := COLOR_TEAL if delta > 0 else COLOR_GOLD_LIGHT if active_battle_feedback["tier"] == "large": feedback_color = Color("#ffcf57") battle_feedback_label = centered_label("%s %s" % [signed_value, qualifier], 22 if active_battle_feedback["tier"] == "large" else 19, feedback_color) battle_feedback_label.name = "BattleFeedbackLabel" battle_feedback_label.position = battle_feedback_origin battle_feedback_label.size = Vector2(feedback_width, 58.0) battle_feedback_label.pivot_offset = battle_feedback_label.size * 0.5 battle_feedback_label.add_theme_color_override("font_outline_color", Color(0.025, 0.035, 0.065, 0.98)) battle_feedback_label.add_theme_constant_override("outline_size", 7) battle_feedback_label.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.55)) battle_feedback_label.add_theme_constant_override("shadow_offset_x", 2) battle_feedback_label.add_theme_constant_override("shadow_offset_y", 3) display_root.add_child(battle_feedback_label) var vfx_texture: Texture2D = VFX["heal"] if delta > 0 else (VFX["guard"] if int(active_battle_feedback["phase"]) == 2 else VFX["hit"]) var vfx_diameter := actor_height * (0.31 if active_battle_feedback["tier"] == "large" else 0.25) battle_feedback_vfx = add_vfx( vfx_texture, target_position + Vector2(actor_width * target_scale * 0.40, actor_height * target_scale * 0.25), vfx_diameter ) battle_feedback_vfx.name = "BattleFeedbackVFX" func update_battle_feedback_visual() -> void: if active_battle_feedback.is_empty(): return if battle_feedback_clock >= BATTLE_FEEDBACK_DURATION: expire_battle_feedback() return var normalized := clampf(battle_feedback_clock / BATTLE_FEEDBACK_DURATION, 0.0, 1.0) var fade := 1.0 if normalized < 0.58 else 1.0 - ((normalized - 0.58) / 0.42) if battle_feedback_label != null and is_instance_valid(battle_feedback_label): var rise := 0.0 if reduced_motion else 34.0 * (1.0 - pow(1.0 - normalized, 2.0)) battle_feedback_label.position = battle_feedback_origin - Vector2(0.0, rise) var pop_scale := 1.0 if not reduced_motion: pop_scale = lerpf(0.78, 1.12, battle_feedback_clock / 0.10) if battle_feedback_clock < 0.10 else lerpf(1.12, 1.0, clampf((battle_feedback_clock - 0.10) / 0.16, 0.0, 1.0)) battle_feedback_label.scale = Vector2.ONE * pop_scale battle_feedback_label.modulate.a = clampf(fade, 0.0, 1.0) if battle_feedback_vfx != null and is_instance_valid(battle_feedback_vfx): battle_feedback_vfx.visible = battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW battle_feedback_vfx.modulate.a = clampf(1.0 - battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW, 0.0, 1.0) if not reduced_motion and battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW: 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 func add_signal_label(value: String, label_position: Vector2, color: Color) -> Label: var label := centered_label(value, 34, color) label.position = label_position label.size = Vector2(90, 60) label.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.04, 0.08, 0.72), color, 2)) display_root.add_child(label) return label func add_health_bar(node_name: String, bar_position: Vector2, bar_size: Vector2, value: int, maximum: int, actor_name: String) -> void: var shell := VBoxContainer.new() shell.name = node_name shell.position = bar_position shell.size = bar_size + Vector2(0, 24) display_root.add_child(shell) var caption := game_label("%s %d/%d" % [actor_name, value, maximum], 11, COLOR_WHITE) shell.add_child(caption) var bar := ProgressBar.new() bar.max_value = maximum bar.value = value bar.show_percentage = false bar.custom_minimum_size.y = bar_size.y bar.add_theme_stylebox_override("background", lacquer_style(Color("#14192a"), Color("#667085"), 1)) bar.add_theme_stylebox_override("fill", lacquer_style(COLOR_TEAL if value > 35 else COLOR_CRIMSON, COLOR_WHITE, 1)) shell.add_child(bar) func game_label(value: String, font_size: int, color: Color) -> Label: var label := Label.new() label.text = value label.add_theme_font_size_override("font_size", scaled(font_size)) label.add_theme_color_override("font_color", color) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.mouse_filter = Control.MOUSE_FILTER_IGNORE return label func centered_label(value: String, font_size: int, color: Color) -> Label: var label := game_label(value, font_size, color) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER return label func compact_button(node_name: String, label: String, callback: Callable) -> Button: var button := game_button(node_name, label, callback, false) button.custom_minimum_size = Vector2(52, 46) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.add_theme_font_size_override("font_size", scaled(12)) return button func icon_button(node_name: String, glyph: String, tooltip: String, callback: Callable) -> Button: var button := game_button(node_name, glyph, callback, false) button.tooltip_text = tooltip button.custom_minimum_size = Vector2(52, 44) button.size_flags_horizontal = Control.SIZE_SHRINK_END button.add_theme_font_size_override("font_size", scaled(18)) return button func game_button(node_name: String, label: String, callback: Callable, primary: bool) -> Button: var button := Button.new() button.name = node_name button.text = label button.focus_mode = Control.FOCUS_ALL button.custom_minimum_size.y = 48 button.add_theme_font_size_override("font_size", scaled(14)) button.add_theme_color_override("font_color", COLOR_INK if primary else COLOR_WHITE) button.add_theme_color_override("font_hover_color", COLOR_INK if primary else COLOR_WHITE) button.add_theme_color_override("font_pressed_color", COLOR_INK if primary else COLOR_WHITE) button.add_theme_color_override("font_disabled_color", Color("#9ba2ad")) button.add_theme_stylebox_override("normal", ornate_button_style(primary, "normal")) button.add_theme_stylebox_override("hover", ornate_button_style(primary, "hover")) button.add_theme_stylebox_override("pressed", ornate_button_style(primary, "pressed")) button.add_theme_stylebox_override("focus", ornate_button_style(primary, "focus")) button.add_theme_stylebox_override("disabled", ornate_button_style(false, "disabled")) button.pressed.connect(callback, CONNECT_DEFERRED) return button func ornate_button_style(primary: bool, state: String) -> StyleBoxFlat: var fill := COLOR_GOLD if primary else Color("#1b3154") var border := COLOR_GOLD_LIGHT if primary else Color("#7e9bb7") if state == "hover" or state == "focus": fill = COLOR_GOLD_LIGHT if primary else COLOR_INDIGO_HOVER border = COLOR_WHITE elif state == "pressed": fill = COLOR_PARCHMENT_DARK if primary else Color("#10213c") border = COLOR_GOLD elif state == "disabled": fill = Color("#3d4657") border = Color("#697180") var style := lacquer_style(fill, border, 2) style.shadow_color = Color(0.01, 0.015, 0.03, 0.7) style.shadow_size = 5 if state != "pressed" else 2 style.shadow_offset = Vector2(0, 3 if state != "pressed" else 1) return style func candidate_style(selected: bool, hover: bool, slot_color: Color) -> StyleBoxFlat: var fill := Color(0.035, 0.065, 0.13, 0.88 if not hover else 0.96) var border := COLOR_GOLD if selected else slot_color var style := lacquer_style(fill, COLOR_WHITE if hover else border, 3 if selected else 2) style.shadow_color = Color(0.01, 0.015, 0.03, 0.75) style.shadow_size = 4 style.shadow_offset = Vector2(0, 2) 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 style.corner_radius_top_right = 28 style.corner_radius_bottom_left = 28 style.corner_radius_bottom_right = 28 style.shadow_color = Color(0.01, 0.015, 0.03, 0.58) style.shadow_size = 6 style.shadow_offset = Vector2(0, 3) return style func lacquer_style(fill: Color, border: Color, width: int) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = fill style.border_color = border style.set_border_width_all(width) style.set_corner_radius_all(5) style.content_margin_left = 10 style.content_margin_right = 10 style.content_margin_top = 7 style.content_margin_bottom = 7 return style func parchment_style() -> StyleBoxFlat: var style := lacquer_style(COLOR_PARCHMENT, COLOR_GOLD, 3) style.corner_radius_top_left = 7 style.corner_radius_top_right = 7 style.corner_radius_bottom_left = 7 style.corner_radius_bottom_right = 7 style.shadow_color = Color(0.02, 0.02, 0.03, 0.75) style.shadow_size = 9 style.shadow_offset = Vector2(0, 5) style.content_margin_left = 18 style.content_margin_right = 18 style.content_margin_top = 16 style.content_margin_bottom = 16 return style func play_cue(kind: String) -> void: if muted or sfx_playback == null: return var frequency: float = { "select": 360.0, "confirm": 440.0, "depart": 300.0, "hit": 180.0, "role": 540.0, "victory": 680.0, }.get(kind, 400.0) var duration := 0.13 if kind in ["hit", "victory"] else 0.08 var frames := int(22050.0 * duration) for index in range(frames): var envelope := sin(PI * float(index) / float(frames)) * 0.035 var value := sin(TAU * frequency * float(index) / 22050.0) * envelope sfx_playback.push_frame(Vector2(value, value))