Files
aetherbound-guild/runtime/compat/p9_0_root_facade.gd
T

3757 lines
180 KiB
GDScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Control
signal authoritative_battle_requested
const FirstSessionStateScript = preload("res://domain/p9_1/session_state.gd")
const FirstSessionServiceScript = preload("res://domain/p9_1/session_service.gd")
const P91ContractsScript = preload("res://domain/p9_1/contracts.gd")
const PresentationSettingsScript = preload("res://presentation/presentation_settings.gd")
const BattleClockScript = preload("res://presentation/battle_clock.gd")
const AnimationAdapterScript = preload("res://presentation/animation_adapter.gd")
const AudioAdapterScript = preload("res://presentation/audio_adapter.gd")
const FeedbackPresenterScript = preload("res://presentation/feedback_presenter.gd")
const SessionPresenterScript = preload("res://ui/session_presenter.gd")
const BattleFixtureScript = preload("res://domain/p9_1/battle_fixture.gd")
const WidgetsScript = preload("res://ui/widgets.gd")
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 := BattleFixtureScript.FIRST_DURATIONS
const SECOND_BATTLE_PHASE_DURATIONS := BattleFixtureScript.SECOND_DURATIONS
const THIRD_BATTLE_PHASE_DURATIONS := BattleFixtureScript.THIRD_DURATIONS
const FOURTH_BATTLE_PHASE_DURATIONS := BattleFixtureScript.FOURTH_DURATIONS
const FIFTH_BATTLE_PHASE_DURATIONS := BattleFixtureScript.FIFTH_DURATIONS
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 DEPARTURE_TRANSITION_DURATION := 0.52
const REDUCED_DEPARTURE_DURATION := 0.12
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 BATTLE_PRESENTATION_PAGES := [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]
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 session_state := FirstSessionStateScript.new()
var session_service := FirstSessionServiceScript.new(session_state)
var presentation_settings := PresentationSettingsScript.new()
var battle_clock_owner := BattleClockScript.new()
var battle_fixture := BattleFixtureScript.new()
var animation_adapter := AnimationAdapterScript.new()
var audio_adapter := AudioAdapterScript.new()
var feedback_presenter := FeedbackPresenterScript.new()
var session_presenter: P91SessionPresenter
var page: int:
get: return session_state.legacy_page
set(value): session_state.set_legacy_page(value)
var locale: String:
get: return presentation_settings.locale
set(value): presentation_settings.locale = value
var text_scale: float:
get: return presentation_settings.text_scale
set(value): presentation_settings.text_scale = value
var reduced_motion: bool:
get: return presentation_settings.reduced_motion
set(value): presentation_settings.reduced_motion = value
var muted: bool:
get: return presentation_settings.muted
set(value): presentation_settings.muted = value
var audio_enabled: bool:
get: return presentation_settings.audio_enabled
set(value): presentation_settings.audio_enabled = value
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: String:
get: return session_state.front_choice
set(value): session_state.front_choice = value
var rear_choice: String:
get: return session_state.rear_choice
set(value): session_state.rear_choice = value
var selected_recruits: Array[String]:
get: return session_state.selected_recruits
set(value): session_state.selected_recruits = value
var formation_commit_count: int:
get: return session_state.formation_commit_count
set(value): session_state.formation_commit_count = value
var notice: String:
get: return session_state.notice
set(value): session_state.notice = value
var restart_confirmation: bool:
get: return session_state.restart_confirmation
set(value): session_state.restart_confirmation = value
var battle_phase: int:
get: return session_state.battle_phase
set(value): session_state.battle_phase = value
var battle_clock: float:
get: return battle_clock_owner.elapsed
set(value): battle_clock_owner.elapsed = value
var battle_playing: bool:
get: return session_state.battle_playing
set(value): session_state.battle_playing = value
var front_hp: int:
get: return session_state.front_hp
set(value): session_state.front_hp = value
var enemy_hp: int:
get: return session_state.enemy_hp
set(value): session_state.enemy_hp = value
var guard_events: int:
get: return session_state.guard_events
set(value): session_state.guard_events = value
var rear_events: int:
get: return session_state.rear_events
set(value): session_state.rear_events = value
var victory_events: int:
get: return session_state.victory_events
set(value): session_state.victory_events = value
var phase_impact_committed: Dictionary:
get: return session_state.phase_impact_committed
set(value): session_state.phase_impact_committed = value
var coin: int:
get: return session_state.coin
set(value): session_state.coin = value
var first_reward_committed: bool:
get: return session_state.first_reward_committed
set(value): session_state.first_reward_committed = value
var adaptation_choice: String:
get: return session_state.adaptation_choice
set(value): session_state.adaptation_choice = value
var adaptation_committed: bool:
get: return session_state.adaptation_committed
set(value): session_state.adaptation_committed = value
var second_battle_elapsed: float:
get: return battle_clock_owner.second_elapsed
set(value): battle_clock_owner.second_elapsed = value
var second_result_time: float:
get: return battle_clock_owner.second_result_time
set(value): battle_clock_owner.second_result_time = value
var guild_choice: String:
get: return legacy_guild_choice(session_state.guild_response)
set(value): set_legacy_guild_choice(value)
var guild_committed: bool:
get: return session_state.guild_committed
set(value): session_state.guild_committed = value
var retained_item: String:
get: return session_state.retained_item
set(value): session_state.retained_item = value
var added_rear: String:
get: return session_state.added_rear
set(value): session_state.added_rear = value
var third_battle_elapsed: float:
get: return battle_clock_owner.third_elapsed
set(value): battle_clock_owner.third_elapsed = value
var third_result_time: float:
get: return battle_clock_owner.third_result_time
set(value): battle_clock_owner.third_result_time = value
var rear_hp: int:
get: return session_state.rear_hp
set(value): session_state.rear_hp = value
var route_choice: String:
get: return session_state.route_choice
set(value): session_state.route_choice = value
var route_committed: bool:
get: return session_state.route_committed
set(value): session_state.route_committed = value
var route_reward_committed: bool:
get: return session_state.route_reward_committed
set(value): session_state.route_reward_committed = value
var fourth_battle_elapsed: float:
get: return battle_clock_owner.fourth_elapsed
set(value): battle_clock_owner.fourth_elapsed = value
var fourth_result_time: float:
get: return battle_clock_owner.fourth_result_time
set(value): battle_clock_owner.fourth_result_time = value
var training_choice: String:
get: return session_state.training_choice
set(value): session_state.training_choice = value
var training_committed: bool:
get: return session_state.training_committed
set(value): session_state.training_committed = value
var trained_member: String:
get: return session_state.trained_member
set(value): session_state.trained_member = value
var fifth_reward_committed: bool:
get: return session_state.fifth_reward_committed
set(value): session_state.fifth_reward_committed = value
var fifth_battle_elapsed: float:
get: return battle_clock_owner.fifth_elapsed
set(value): battle_clock_owner.fifth_elapsed = value
var fifth_result_time: float:
get: return battle_clock_owner.fifth_result_time
set(value): battle_clock_owner.fifth_result_time = value
var animation_library: Dictionary:
get: return animation_adapter.library
set(value): animation_adapter.library = value
var victory_clock: float:
get: return battle_clock_owner.result_elapsed
set(value): battle_clock_owner.result_elapsed = value
var active_battle_feedback: Dictionary:
get: return feedback_presenter.active
set(value): feedback_presenter.active = value
var battle_feedback_clock: float:
get: return feedback_presenter.clock
set(value): feedback_presenter.clock = value
var battle_feedback_history: Array[Dictionary]:
get: return feedback_presenter.history
set(value): feedback_presenter.history = value
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 yard_preview_sprites: Dictionary = {}
var yard_threat_sprite: TextureRect
var yard_preview_clock := 0.0
var departure_transition_active := false
var departure_transition_clock := 0.0
var departure_sprites: Dictionary = {}
var departure_start_positions: Dictionary = {}
var departure_target_positions: Dictionary = {}
var departure_threat_sprite: TextureRect
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:
get: return audio_adapter.player
set(value): audio_adapter.player = value
var sfx_playback: AudioStreamGeneratorPlayback:
get: return audio_adapter.playback
set(value): audio_adapter.playback = value
var render_pending := false
func _ready() -> void:
session_presenter = SessionPresenterScript.new(self)
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:
audio_adapter.cleanup()
func setup_audio() -> void:
audio_adapter.attach(self)
func load_animation_library() -> void:
animation_adapter.load_specs(ANIMATION_SPECS)
func animation_frame_index(character: String, state: String, elapsed: float) -> int:
if reduced_motion and animation_library.has(character) and animation_library[character].has(state) and bool(animation_library[character][state]["loop"]):
return 0
return animation_adapter.frame_index(character, state, elapsed)
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 visible_member_ids() -> Array[String]:
var members := session_state.current_member_ids()
if members.is_empty():
for member_id in [front_choice, rear_choice, added_rear]:
if not member_id.is_empty() and member_id not in members:
members.append(member_id)
return members
func visible_member_id(index: int) -> String:
var members := visible_member_ids()
return members[index] if index >= 0 and index < members.size() else ""
func visible_member_slot(member_id: String) -> String:
var index := visible_member_ids().find(member_id)
return ["front", "rear", "added"][index] if index >= 0 and index < 3 else ""
func support_member_ids() -> Array[String]:
var members := visible_member_ids()
return members.slice(1) if members.size() > 1 else []
func profession_action_state(member_id: String) -> String:
match member_id:
"Hearthguard": return "basic_attack"
"Wayrunner": return "evade_counter"
"Farstring": return "draw_release"
"Kindhand": return "lantern_heal"
return "idle"
func profession_defense_state(member_id: String) -> String:
if member_id == "Hearthguard":
return "guard"
if member_id == "Wayrunner":
return "evade_counter"
return "idle"
func later_battle_animation_state(member_id: String) -> String:
var lead_id := visible_member_id(0)
var supports := support_member_ids()
if battle_phase == 0:
return "advance"
if page == AppPage.THIRD_BATTLE:
if battle_phase == 1 and retained_item == "EQ-061" and member_id == lead_id:
return profession_action_state(member_id)
if battle_phase == 2 and member_id == lead_id:
return profession_defense_state(member_id)
if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]:
return profession_action_state(member_id)
if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]:
return profession_action_state(member_id)
if battle_phase == 5 and member_id == lead_id:
return profession_action_state(member_id)
if page == AppPage.FOURTH_BATTLE:
if battle_phase == 1 and selected_build_profile() == "tempo" and route_choice == "R6-02" and member_id == lead_id:
return profession_action_state(member_id)
if battle_phase == 2 and member_id == (session_state.marked_member_id() if route_choice == "R6-03" else lead_id):
return profession_defense_state(member_id)
if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]:
return profession_action_state(member_id)
if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]:
return profession_action_state(member_id)
if battle_phase == 5 and member_id == lead_id:
return profession_action_state(member_id)
if page == AppPage.FIFTH_BATTLE:
var pending_supports := supports.duplicate()
if trained_profession() == "Farstring":
pending_supports.erase(trained_profession())
if battle_phase == 1 and trained_profession() == "Farstring" and member_id == trained_profession():
return profession_action_state(member_id)
if battle_phase == 2 and member_id == lead_id:
return profession_defense_state(member_id)
if battle_phase == 3 and member_id == session_state.marked_member_id():
return profession_defense_state(member_id)
if battle_phase == 4 and not pending_supports.is_empty() and member_id == pending_supports[0]:
return profession_action_state(member_id)
if battle_phase == 5 and pending_supports.size() > 1 and member_id == pending_supports[1]:
return profession_action_state(member_id)
return "idle"
func enemy_animation_state() -> String:
if page == AppPage.FIFTH_BATTLE:
match battle_phase:
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:
if session_presenter == null:
render_legacy_page()
else:
session_presenter.render(render_snapshot(), {})
func render_snapshot() -> Dictionary:
var result := session_service.snapshot()
result["settings"] = presentation_settings.snapshot()
result["encounter"]["elapsed"] = battle_clock
return result.duplicate(true)
func render_binding_error() -> void:
for child in display_root.get_children():
child.free()
var error_label := game_label("P91-BINDING-ERROR", 24, COLOR_CRIMSON)
error_label.name = "P91BindingError"
error_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
display_root.add_child(error_label)
func render_legacy_page() -> 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
yard_preview_sprites.clear()
yard_threat_sprite = 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:
session_presenter.add_background(texture)
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:
render_gameful_yard_stage(false)
func render_gameful_yard_stage(mission_ready: bool) -> void:
yard_preview_sprites.clear()
yard_threat_sprite = null
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
var world_space := Control.new()
world_space.name = "GuildYardWorldSpace"
world_space.position = Vector2(0, 54)
world_space.size = Vector2(size.x, size.y - 54)
world_space.mouse_filter = Control.MOUSE_FILTER_IGNORE
world_space.set_meta("visible_world_ratio", 0.82)
display_root.add_child(world_space)
var top_shade := ColorRect.new()
top_shade.name = "GuildYardTopShade"
top_shade.position = Vector2.ZERO
top_shade.size = Vector2(size.x, 58)
top_shade.color = Color(0.015, 0.035, 0.075, 0.78)
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(top_shade)
var identity := game_label(tr_text("裂脉远征团 · 风路公会庭院", "AETHERBOUND GUILD · WIND-ROAD YARD"), 15, COLOR_WHITE)
identity.name = "MainMenuIdentity" if not mission_ready else "FirstMissionIdentity"
identity.position = Vector2(18, 8)
identity.size = Vector2(size.x * 0.55, 42)
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(identity)
if mission_ready:
var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home)
home.position = Vector2(size.x - 184, 7)
home.size = Vector2(52, 44)
display_root.add_child(home)
var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart)
restart.position = Vector2(size.x - 126, 7)
restart.size = Vector2(52, 44)
display_root.add_child(restart)
var mute := icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)
mute.position = Vector2(size.x - 68, 7)
mute.size = Vector2(52, 44)
display_root.add_child(mute)
else:
var intro := icon_button("GameIntroButton", "?", tr_text("游戏介绍", "Game Introduction"), open_game_intro)
intro.position = Vector2(size.x - 126, 7)
intro.size = Vector2(52, 44)
display_root.add_child(intro)
var settings := icon_button("SettingsButton", "⚙", tr_text("设置", "Settings"), open_settings)
settings.position = Vector2(size.x - 68, 7)
settings.size = Vector2(52, 44)
display_root.add_child(settings)
var commission := game_label(
tr_text("第一份委托 · 守住断裂的风路\n裂影刃手正在逼近钟门", "FIRST COMMISSION · HOLD THE BROKEN WIND ROAD\nA Riftblade is closing on the bell gate")
if not mission_ready
else tr_text("钟门警报 · 裂影刃手即将抵达\n从庭院中的四名成员里召集任意两人", "BELL-GATE ALERT · RIFTBLADE APPROACHING\nRally any two of the four Recruits in the yard"),
13,
COLOR_WHITE
)
commission.name = "GuildYardCommissionRibbon"
commission.position = Vector2(18, 68)
commission.size = Vector2(minf(size.x * 0.54, 470.0), 62)
commission.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
commission.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.86), COLOR_GOLD, 2))
display_root.add_child(commission)
var stage_bottom := size.y - 54.0
var actor_height: float = clampf(size.y * 0.37, 126.0, 224.0)
var actor_spacing: float = minf(size.x * 0.145, actor_height * 0.93)
var actor_start := maxf(24.0, size.x * 0.055)
for index in range(AUTO_FORMATION_ORDER.size()):
var profession: String = AUTO_FORMATION_ORDER[index]
var actor_position := Vector2(actor_start + actor_spacing * index, stage_bottom - actor_height)
var actor := actor_at(animation_texture(profession, "idle", yard_preview_clock), actor_position, Vector2(actor_height, actor_height), "YardRecruit_%s" % profession)
actor.set_meta("profession", profession)
yard_preview_sprites[profession] = actor
var nameplate := centered_label("%s · %s" % [candidate_name(profession), candidate_role_short(profession)], 10, COLOR_WHITE)
nameplate.name = "YardRecruitLabel_%s" % profession
nameplate.position = Vector2(actor_position.x + actor_height * 0.02, stage_bottom - 35)
nameplate.size = Vector2(actor_height * 0.96, 34)
nameplate.add_theme_stylebox_override("normal", lacquer_style(Color(0.025, 0.045, 0.09, 0.84), COLOR_TEAL, 1))
display_root.add_child(nameplate)
var breach_path := ColorRect.new()
breach_path.name = "GuildYardBreachPath"
breach_path.position = Vector2(size.x * 0.66, size.y * 0.67)
breach_path.size = Vector2(size.x * 0.27, 4)
breach_path.color = Color(0.99, 0.61, 0.23, 0.82)
breach_path.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(breach_path)
var threat_size := clampf(size.y * 0.29, 102.0, 176.0)
var threat_position := Vector2(size.x - threat_size - 36, size.y * 0.31)
yard_threat_sprite = actor_at(animation_texture("Riftblade", "idle", yard_preview_clock), threat_position, Vector2(threat_size, threat_size), "GuildYardThreat")
var threat_label := centered_label(tr_text("钟门威胁\n裂影刃手", "BELL-GATE THREAT\nRIFTBLADE"), 10, Color("#ffd2b1"))
threat_label.name = "GuildYardThreatLabel"
threat_label.position = Vector2(threat_position.x - 4, threat_position.y + threat_size * 0.78)
threat_label.size = Vector2(threat_size + 8, 43)
threat_label.add_theme_stylebox_override("normal", lacquer_style(Color(0.10, 0.025, 0.045, 0.88), COLOR_CRIMSON, 2))
display_root.add_child(threat_label)
var action_name := "OpenRecruitSelectionButton" if mission_ready else "StartNewGameButton"
var action_label := tr_text("集结两名成员", "Assemble Company") if mission_ready else tr_text("接受风路委托", "Accept Commission")
var action_callback: Callable = open_recruit_selection if mission_ready else start_new_game
var action := game_button(action_name, action_label, action_callback, true)
action.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
action.size = Vector2(minf(222.0, size.x * 0.28), 54)
display_root.add_child(action)
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:
render_gameful_yard_stage(true)
func render_recruit_selection() -> void:
render_gameful_recruit_rally(false)
func render_gameful_recruit_rally(review_mode: bool) -> void:
yard_preview_sprites.clear()
yard_threat_sprite = null
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
var top_shade := ColorRect.new()
top_shade.name = "RecruitRallyTopShade"
top_shade.position = Vector2.ZERO
top_shade.size = Vector2(size.x, 58)
top_shade.color = Color(0.015, 0.035, 0.075, 0.80)
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(top_shade)
var identity := game_label(
tr_text("公会庭院 · 队伍顺序", "GUILD YARD · COMPANY ORDER")
if review_mode
else tr_text("公会庭院 · 集结队伍", "GUILD YARD · RALLY COMPANY"),
15,
COLOR_WHITE
)
identity.name = "FormationReviewIdentity" if review_mode else "RecruitSelectionIdentity"
identity.position = Vector2(18, 8)
identity.size = Vector2(size.x * 0.55, 42)
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(identity)
var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home)
home.position = Vector2(size.x - 184, 7)
home.size = Vector2(52, 44)
display_root.add_child(home)
var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart)
restart.position = Vector2(size.x - 126, 7)
restart.size = Vector2(52, 44)
display_root.add_child(restart)
var mute := icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)
mute.position = Vector2(size.x - 68, 7)
mute.size = Vector2(52, 44)
display_root.add_child(mute)
var order := ordered_selected_recruits()
if review_mode:
order.assign([front_choice, rear_choice])
var context_text := tr_text("集结 %d / 2 · 直接点击庭院中的成员\n你只选人;系统会自动安排顺序。" % selected_recruits.size(), "RALLY %d / 2 · TAP A RECRUIT IN THE YARD\nChoose people; the system assigns their order." % selected_recruits.size())
if selected_recruits.size() == 1:
context_text = tr_text("集结 1 / 2 · %s 已响应\n再点击一名成员;再次点击已选成员可移出。" % candidate_name(order[0]), "RALLY 1 / 2 · %s ANSWERED\nTap one more Recruit; tap the selected Recruit to remove." % candidate_name(order[0]))
elif selected_recruits.size() == 2:
context_text = tr_text("队伍已组成 · 系统顺序:%s → %s\n要换人,请先点击一名已选成员。" % [candidate_name(order[0]), candidate_name(order[1])], "COMPANY READY · SYSTEM ORDER: %s -> %s\nTo replace someone, first tap a selected Recruit." % [candidate_name(order[0]), candidate_name(order[1])])
if review_mode:
context_text = tr_text("系统已排好行动顺序 · 尚未出发\n1 先行 %s → 2 支援 %s" % [candidate_name(front_choice), candidate_name(rear_choice)], "SYSTEM ORDER READY · NOT DEPARTED\n1 Lead %s -> 2 Support %s" % [candidate_name(front_choice), candidate_name(rear_choice)])
if not notice.is_empty() and not review_mode and selected_recruits.size() < 2:
context_text = notice + "\n" + tr_text("直接点击角色可继续调整。", "Tap a Recruit directly to keep adjusting.")
var context := game_label(context_text, 12, COLOR_WHITE)
context.name = "RecruitSelectionGuide" if not review_mode else "FormationOrderGuide"
context.position = Vector2(18, 68)
context.size = Vector2(minf(size.x * 0.57, 490.0), 62)
context.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
context.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.88), COLOR_GOLD, 2))
display_root.add_child(context)
var count_label := game_label(tr_text("已选 %d / 2" % selected_recruits.size(), "%d / 2 SELECTED" % selected_recruits.size()), 13, COLOR_GOLD_LIGHT)
count_label.name = "RecruitSelectionCount"
count_label.position = Vector2(size.x * 0.59, 74)
count_label.size = Vector2(size.x * 0.18, 42)
count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(count_label)
var threat_size := clampf(size.y * 0.24, 94.0, 152.0)
var threat_position := Vector2(size.x - threat_size - 22, 76)
yard_threat_sprite = actor_at(animation_texture("Riftblade", "idle", yard_preview_clock), threat_position, Vector2(threat_size, threat_size), "RecruitRallyThreat")
yard_threat_sprite.modulate = Color(1.0, 0.80, 0.86, 0.86)
var threat_label := centered_label(tr_text("裂口逼近", "BREACH CLOSING"), 9, Color("#ffd2b1"))
threat_label.name = "RecruitRallyThreatLabel"
threat_label.position = Vector2(threat_position.x - 2, threat_position.y + threat_size * 0.76)
threat_label.size = Vector2(threat_size + 4, 30)
threat_label.add_theme_stylebox_override("normal", lacquer_style(Color(0.10, 0.025, 0.045, 0.86), COLOR_CRIMSON, 1))
display_root.add_child(threat_label)
var actor_height := clampf(size.y * 0.33, 112.0, 200.0)
var candidate_spacing := minf(size.x * 0.132, actor_height * 0.88)
var candidate_start := maxf(24.0, size.x * 0.035)
var stage_bottom := size.y - 52.0
var rally_start := size.x - actor_height * 2.08 - 84.0
for index in range(AUTO_FORMATION_ORDER.size()):
var profession: String = AUTO_FORMATION_ORDER[index]
var selected := order.has(profession)
var order_index := order.find(profession)
var candidate_position := Vector2(candidate_start + candidate_spacing * index, stage_bottom - actor_height)
var actor_position := candidate_position
if selected:
actor_position = Vector2(
rally_start + actor_height * 0.88 * (float(order_index) if order.size() > 1 else 0.5),
stage_bottom - actor_height - (8.0 if order_index == 0 else 0.0)
)
var rally_mark := ColorRect.new()
rally_mark.name = "RallyMark_%s" % profession
rally_mark.position = Vector2(actor_position.x + actor_height * 0.14, stage_bottom - 9)
rally_mark.size = Vector2(actor_height * 0.72, 5)
rally_mark.color = COLOR_GOLD_LIGHT
rally_mark.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(rally_mark)
var actor := actor_at(animation_texture(profession, "idle", yard_preview_clock), actor_position, Vector2(actor_height, actor_height), "RallyActor_%s" % profession)
actor.set_meta("profession", profession)
actor.set_meta("selected", selected)
actor.set_meta("order_index", order_index)
var capacity_blocked := selected_recruits.size() >= 2 and not selected
if capacity_blocked or review_mode and not selected:
actor.modulate = Color(0.48, 0.52, 0.58, 0.70)
yard_preview_sprites[profession] = actor
var target := Button.new()
target.name = "Recruit_%s" % profession
target.position = actor_position
target.size = Vector2(actor_height, actor_height)
target.tooltip_text = "%s · %s" % [candidate_name(profession), candidate_role(profession)]
target.focus_mode = Control.FOCUS_ALL
target.disabled = review_mode or capacity_blocked
target.add_theme_stylebox_override("normal", recruit_actor_target_style(selected, false, false))
target.add_theme_stylebox_override("hover", recruit_actor_target_style(selected, true, false))
target.add_theme_stylebox_override("pressed", recruit_actor_target_style(true, true, false))
target.add_theme_stylebox_override("focus", recruit_actor_target_style(selected, true, false))
target.add_theme_stylebox_override("disabled", recruit_actor_target_style(false, false, true))
target.pressed.connect(toggle_recruit.bind(profession), CONNECT_DEFERRED)
display_root.add_child(target)
if selected:
var sigil := centered_label("◆", 20, COLOR_GOLD_LIGHT)
sigil.name = "SelectedSigil_%s" % profession
sigil.position = Vector2(actor_position.x + actor_height * 0.36, actor_position.y - 3)
sigil.size = Vector2(actor_height * 0.28, 34)
sigil.add_theme_color_override("font_outline_color", COLOR_INK)
sigil.add_theme_constant_override("outline_size", 6)
display_root.add_child(sigil)
var actor_label := candidate_name(profession) + " · " + candidate_role_short(profession)
if selected and order.size() == 2:
actor_label = tr_text("%d %s · %s" % [order_index + 1, "先行" if order_index == 0 else "支援", candidate_name(profession)], "%d %s · %s" % [order_index + 1, "LEAD" if order_index == 0 else "SUPPORT", candidate_name(profession)])
elif selected:
actor_label = tr_text("已集结 · %s" % candidate_name(profession), "RALLIED · %s" % candidate_name(profession))
elif review_mode:
actor_label = tr_text("留守 · %s" % candidate_name(profession), "STANDBY · %s" % candidate_name(profession))
elif capacity_blocked:
actor_label = tr_text("队伍已满 · %s" % candidate_name(profession), "FULL · %s" % candidate_name(profession))
var nameplate := centered_label(actor_label, 9, COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
nameplate.name = "RallyLabel_%s" % profession
nameplate.position = Vector2(actor_position.x, stage_bottom - 32)
nameplate.size = Vector2(actor_height, 31)
nameplate.add_theme_stylebox_override("normal", lacquer_style(Color(0.025, 0.045, 0.09, 0.86), COLOR_GOLD if selected else COLOR_TEAL, 2 if selected else 1))
display_root.add_child(nameplate)
if review_mode:
var revise := game_button("ReviseRecruitSelectionButton", tr_text("调整成员", "Revise Recruits"), revise_recruit_selection, false)
revise.position = Vector2(18, size.y - 62)
revise.size = Vector2(minf(188.0, size.x * 0.24), 50)
display_root.add_child(revise)
var commit := game_button("CommitFormationButton", tr_text("按此顺序出发", "Depart in This Order"), commit_formation, true)
commit.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
commit.size = Vector2(minf(222.0, size.x * 0.28), 54)
commit.disabled = not formation_is_valid() or formation_commit_count > 0
display_root.add_child(commit)
else:
var review := game_button("ReviewFormationButton", tr_text("确认系统顺序", "Review System Order"), review_automatic_formation, true)
review.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
review.size = Vector2(minf(222.0, size.x * 0.28), 54)
review.disabled = selected_recruits.size() != 2
review.visible = selected_recruits.size() == 2
display_root.add_child(review)
func recruit_actor_target_style(selected: bool, hover: bool, disabled: bool) -> StyleBoxFlat:
var fill := Color(0.02, 0.05, 0.10, 0.04)
var border := Color(0.0, 0.0, 0.0, 0.0)
var width := 0
if disabled:
fill = Color(0.02, 0.03, 0.05, 0.12)
elif hover:
fill = Color(0.02, 0.11, 0.14, 0.18)
border = COLOR_GOLD_LIGHT if selected else COLOR_TEAL
width = 2
elif selected:
fill = Color(0.12, 0.09, 0.02, 0.04)
var style := lacquer_style(fill, border, width)
style.set_corner_radius_all(16)
style.shadow_size = 0
return style
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:
render_gameful_recruit_rally(true)
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:
if departure_transition_active and page == AppPage.BATTLE:
render_departure_transition()
return
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 later_cycle := third_cycle or fourth_cycle or fifth_cycle
var members := visible_member_ids()
var visible_front := visible_member_id(0)
var visible_rear := visible_member_id(1)
var visible_third := visible_member_id(2)
var battle_title := tr_text("第五次远征 · 双压关隘", "Fifth Expedition · Twin-Pressure Pass") if fifth_cycle else (tr_text("第四次远征 · %s" % route_name(route_choice), "Fourth Expedition · %s" % route_name(route_choice)) if fourth_cycle else (tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))))
render_hud(battle_title)
var world_bottom := size.y - 58.0
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(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor")
rear_sprite = actor_at(animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor")
front_sprite.set_meta("member_id", visible_front)
rear_sprite.set_meta("member_id", visible_rear)
if later_cycle and members.size() > 2:
added_rear_sprite = actor_at(animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor")
added_rear_sprite.set_meta("member_id", visible_third)
enemy_sprite = actor_at(animation_texture("Riftblade", enemy_animation_state(), battle_clock), enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor")
add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), session_state.member_health(visible_front), 100, candidate_name(visible_front))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 140 if fifth_cycle else (130 if third_cycle else (120 if fourth_cycle else 100)), tr_text("双压守卫", "Twin Warden") if fifth_cycle else (tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade"))))
if fifth_cycle or (fourth_cycle and route_choice == "R6-03"):
var marked_id := session_state.marked_member_id()
add_health_bar("RearHealth", Vector2(22, 101), Vector2(size.x * 0.20, 12), session_state.member_health(marked_id), 100, candidate_name(marked_id))
var event := battle_event_text()
var ribbon := PanelContainer.new()
ribbon.name = "BattleEvent"
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 not later_cycle and battle_phase == 3 and rear_choice == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
elif later_cycle:
for member_id in members:
if later_battle_animation_state(member_id) == "draw_release":
projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT)
break
render_battle_feedback(actor_width, actor_height)
update_battle_feedback_visual()
func render_departure_transition() -> void:
add_background(YARD_BACKGROUND)
departure_sprites.clear()
departure_start_positions.clear()
departure_target_positions.clear()
departure_threat_sprite = null
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
var top_shade := ColorRect.new()
top_shade.name = "DepartureTopShade"
top_shade.position = Vector2.ZERO
top_shade.size = Vector2(size.x, 58)
top_shade.color = Color(0.015, 0.035, 0.075, 0.82)
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(top_shade)
var identity := game_label(tr_text("公会庭院 · 队伍出发", "GUILD YARD · COMPANY DEPARTING"), 15, COLOR_WHITE)
identity.name = "DepartureIdentity"
identity.position = Vector2(18, 8)
identity.size = Vector2(size.x * 0.62, 42)
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(identity)
var callout := game_label(tr_text("钟门已开启\n队伍正在接近裂影刃手", "BELL GATE OPEN\nThe Company is closing on the Riftblade"), 13, COLOR_WHITE)
callout.name = "DepartureCallout"
callout.position = Vector2(18, 68)
callout.size = Vector2(minf(size.x * 0.48, 420.0), 60)
callout.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
callout.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.86), COLOR_GOLD, 2))
display_root.add_child(callout)
var gate_pulse := centered_label("◆", 28, COLOR_GOLD_LIGHT)
gate_pulse.name = "DepartureGatePulse"
gate_pulse.position = Vector2(size.x - 132, size.y * 0.34)
gate_pulse.size = Vector2(48, 48)
gate_pulse.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02, 0.92))
gate_pulse.add_theme_constant_override("outline_size", 5)
display_root.add_child(gate_pulse)
var threat_size := clampf(size.y * 0.27, 100.0, 166.0)
var threat_position := Vector2(size.x - threat_size - 20, size.y * 0.35)
departure_threat_sprite = actor_at(animation_texture("Riftblade", "idle", departure_transition_clock), threat_position, Vector2(threat_size, threat_size), "DepartureThreat")
departure_threat_sprite.modulate = Color(1.0, 0.78, 0.84, 0.92)
var order: Array[String] = [front_choice, rear_choice]
var actor_height := clampf(size.y * 0.36, 126.0, 214.0)
var stage_bottom := size.y - 48.0
var start_x := size.x - actor_height * 2.18 - 98.0
var target_x := size.x - actor_height * 1.68
var duration := REDUCED_DEPARTURE_DURATION if reduced_motion else DEPARTURE_TRANSITION_DURATION
var progress := 1.0 if reduced_motion else smoothstep(0.0, 1.0, clampf(departure_transition_clock / duration, 0.0, 1.0))
for index in range(order.size()):
var member_id: String = order[index]
var start_position := Vector2(start_x + actor_height * 0.90 * index, stage_bottom - actor_height - (8.0 if index == 0 else 0.0))
var target_position := Vector2(target_x + actor_height * 0.68 * index, stage_bottom - actor_height - (8.0 if index == 0 else 0.0))
departure_start_positions[member_id] = start_position
departure_target_positions[member_id] = target_position
var actor_position := start_position.lerp(target_position, progress)
var actor := actor_at(animation_texture(member_id, "idle" if reduced_motion else "advance", departure_transition_clock), actor_position, Vector2(actor_height, actor_height), "DepartureActor_%s" % member_id)
actor.set_meta("member_id", member_id)
actor.set_meta("order_index", index)
departure_sprites[member_id] = actor
var path_line := ColorRect.new()
path_line.name = "DeparturePathLine"
path_line.position = Vector2(size.x * 0.43, stage_bottom - 8)
path_line.size = Vector2(size.x * 0.47, 5)
path_line.color = Color(0.99, 0.70, 0.28, 0.86)
path_line.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(path_line)
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.53, (size.y - banner_height) * 0.5 + 14.0)
banner.size = Vector2(size.x * 0.44, 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", 4)
banner.add_child(box)
box.add_child(centered_label("◆ " + tr_text("战报 1 / 5", "RESULT 1 / 5") + " ◆", 17, COLOR_INK))
add_causal_result_lines(box, 1, "Result1CausalLines", {0: "VictoryDiagnosis", 3: "FirstReward"})
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\n%s\n%s" % [symbol, adaptation_option_title(id), adaptation_reason(id), adaptation_gain(id), adaptation_cost(id), adaptation_foregone(id)]
button.focus_mode = Control.FOCUS_ALL
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.clip_text = true
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(210, 142)
button.add_theme_font_size_override("font_size", scaled(8))
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.53, 76)
result.size = Vector2(size.x * 0.44, size.y - 96)
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)
box.add_child(centered_label("◆ " + tr_text("战报 2 / 5", "RESULT 2 / 5") + " ◆", 16, COLOR_INK))
add_causal_result_lines(box, 2, "Result2CausalLines", {1: "SecondResultExplanation", 3: "SecondResultFacts"})
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)
var preview_order := guild_preview_order()
var preview_names := ["GuildFront", "GuildRear", "GuildRecruitPreview"]
for index in range(preview_order.size()):
var member_id: String = preview_order[index]
var actor_scale: float = [1.0, 0.9, 0.82][index]
var actor := actor_at(
animation_texture(member_id, "idle", victory_clock),
Vector2(size.x * (0.055 + 0.135 * index), size.y - actor_height * actor_scale - 34),
Vector2(actor_height * actor_scale, actor_height * actor_scale),
preview_names[index]
)
actor.set_meta("member_id", member_id)
var resources := PanelContainer.new()
resources.name = "GuildResources"
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
var recruit_id := session_service.mapped_guild_recruit()
if id == "retain_item":
button.text = tr_text("▣ 保留已验证装备\n当前理由:第二战已证明%s有效\n得到:继续%s\n代价:12 金币;小队仍为 2 人\n放弃:%s与第三名成员\n确认后:金币 %d → %d;小队 2 → 2" % [adaptation_name(adaptation_choice), adaptation_gain(adaptation_choice).trim_prefix("得到:"), candidate_name(recruit_id), coin, coin - RETAIN_ITEM_COST], "▣ KEEP THE PROVEN ITEM\nWhy now: battle two proved %s\nGain: keep %s\nCost: 12 Coin; Company stays at 2\nGive up: %s and a third member\nOn commit: Coin %d -> %d; Company 2 -> 2" % [adaptation_name(adaptation_choice), adaptation_gain(adaptation_choice).trim_prefix("Gain: "), candidate_name(recruit_id), coin, coin - RETAIN_ITEM_COST])
else:
var position := guild_recruit_position(recruit_id)
button.text = tr_text("+ 招募第三名成员:%s\n当前理由:补上当前阵线缺少的职责\n得到:%s加入%s并重新编队\n代价:14 金币;归还借用装备\n放弃:已验证的%s效果\n确认后:金币 %d → %d;小队 2 → 3" % [candidate_name(recruit_id), candidate_name(recruit_id), position, adaptation_name(adaptation_choice), coin, coin - RECRUIT_REAR_COST], "+ RECRUIT A THIRD MEMBER: %s\nWhy now: fills the current role gap\nGain: %s joins as %s; line re-forms\nCost: 14 Coin; return the loaned item\nGive up: proven %s effect\nOn commit: Coin %d -> %d; Company 2 -> 3" % [candidate_name(recruit_id), candidate_name(recruit_id), position, adaptation_name(adaptation_choice), coin, coin - RECRUIT_REAR_COST])
button.focus_mode = Control.FOCUS_ALL
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.clip_text = true
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(215, 148)
button.add_theme_font_size_override("font_size", scaled(8))
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)
render_visible_party("Session", Vector2(size.x * 0.05, size.y - actor_height - 38), actor_height)
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.52 - 36), Vector2(actor_height * 0.52, actor_height * 0.52), "SessionEnemy")
var result := PanelContainer.new()
result.name = "SessionResultPanel"
result.position = Vector2(size.x * 0.52, 74)
result.size = Vector2(size.x * 0.46, size.y - 92)
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("◆ " + tr_text("战报 3 / 5", "RESULT 3 / 5") + " ◆", 16, COLOR_INK))
add_causal_result_lines(box, 3, "Result3CausalLines", {1: "SessionResultExplanation", 3: "SessionResultFacts"})
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)
render_visible_party("Route", Vector2(20, size.y - actor_height - 24), actor_height, 80.0)
var build_panel := PanelContainer.new()
build_panel.name = "RouteBuildSummary"
build_panel.position = Vector2(16, 72)
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\n%s\n%s\n%s" % [route_name(id), tr_text("当前理由:%s" % route_coverage_label(id), "Why now: %s" % route_coverage_label(id)), tr_text("得到:+%d 金币" % route_reward(id), "Gain: +%d Coin" % route_reward(id)), tr_text("代价:%s / %s" % [route_exposure(id), route_pressure(id)], "Cost: %s / %s" % [route_exposure(id), route_pressure(id)]), tr_text("放弃:其他路线", "Give up: other routes")]
button.focus_mode = Control.FOCUS_ALL
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.clip_text = true
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
button.custom_minimum_size = Vector2(150, 108)
button.size = Vector2(150, 108)
button.add_theme_font_size_override("font_size", scaled(8))
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)
render_visible_party("CycleResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height)
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "CycleResultEnemy")
var result := PanelContainer.new()
result.name = "SecondCycleResultPanel"
result.position = Vector2(size.x * 0.51, 74)
result.size = Vector2(size.x * 0.47, size.y - 92)
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("◆ " + tr_text("战报 4 / 5", "RESULT 4 / 5") + " ◆", 16, COLOR_INK))
add_causal_result_lines(box, 4, "Result4CausalLines", {1: "SecondCycleExplanation", 3: "SecondCycleFacts"})
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
var actor := actor_at(animation_texture(profession, "idle", victory_clock), Vector2(actor_x, 82), Vector2(actor_height, actor_height), "TrainingActor_%s" % slot)
actor.set_meta("member_id", profession)
var choice := training_button(slot)
choice.position = Vector2(20.0 + slot_width * float(index), 205)
choice.size = Vector2(slot_width - 8.0, 118)
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.set_meta("member_id", profession)
button.text = "%s · %s · %s\nLv.1 → Lv.2\n%s\n%s\n%s\n%s" % [member_slot_label(slot), candidate_name(profession), candidate_role_short(profession), tr_text("得到:%s" % training_effect_short(slot), "Gain: %s" % training_effect_short(slot)), tr_text("代价:消耗唯一历练印记", "Cost: consume the only Training Mark"), tr_text("下一战验证:%s" % training_test_short(slot), "Next battle tests: %s" % training_test_short(slot)), tr_text("放弃:其他成员本轮保持 Lv.1", "Give up: other members stay Lv.1 this cycle")]
button.focus_mode = Control.FOCUS_ALL
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
button.clip_text = true
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
button.custom_minimum_size = Vector2(146, 128)
button.add_theme_font_size_override("font_size", scaled(8))
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,本轮不获得其历练效果。\n确认前金币、路线与成员等级都不会改变。", "Give up: other members stay Lv.1; their training effects are not gained this cycle.\nCoin, route, and member levels do not change before commit."), 9, 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)
render_visible_party("GrowthResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height)
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "GrowthResultEnemy")
var result := PanelContainer.new()
result.name = "ThirdCycleResultPanel"
result.position = Vector2(size.x * 0.50, 72)
result.size = Vector2(size.x * 0.48, size.y - 88)
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("◆ " + tr_text("首次体验完成", "FIRST SESSION COMPLETE") + " ◆", 16, COLOR_INK))
add_causal_result_lines(box, 5, "Result5CausalLines", {1: "ThirdCycleExplanation", 3: "ThirdCycleFacts"})
var levels := centered_label(member_levels_summary(), 8, Color("#6d5523"))
levels.name = "MemberLevels"
box.add_child(levels)
box.add_child(game_button("ContinueAuthoritativeBattleButton", tr_text("继续:裂隙守望战", "Continue: Breach Watch"), request_authoritative_battle, true))
var result_actions := HBoxContainer.new()
result_actions.name = "ThirdCycleResultActions"
result_actions.add_theme_constant_override("separation", 6)
box.add_child(result_actions)
var replay := game_button("ReplayGrowthButton", tr_text("重新开始", "Restart"), request_restart, false)
replay.size_flags_horizontal = Control.SIZE_EXPAND_FILL
result_actions.add_child(replay)
var home := game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)
home.size_flags_horizontal = Control.SIZE_EXPAND_FILL
result_actions.add_child(home)
func request_authoritative_battle() -> void:
if page != AppPage.THIRD_CYCLE_RESULT:
return
authoritative_battle_requested.emit()
func add_causal_result_lines(container: VBoxContainer, result_index: int, prefix: String, legacy_names: Dictionary = {}) -> void:
var snapshot := render_snapshot()
var clauses := causal_result_clauses(result_index, snapshot)
var labels_zh := ["发生了什么:", "为什么:", "决定造成的差异:", "现在改变了:", "下一步:"]
var labels_en := ["What happened: ", "Why: ", "Difference made by the decision: ", "Changed now: ", "Next: "]
if result_index == 1:
labels_zh[2] = "系统编队造成的差异:"
labels_en[2] = "Difference made by automatic formation: "
var labels: Array = labels_zh if locale == "zh_CN" else labels_en
for index in range(5):
var line := game_label(labels[index] + clauses[index], 8, COLOR_INK if index < 3 else Color("#6d5523"))
line.name = "%s_Line%d" % [prefix, index + 1]
line.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
container.add_child(line)
if legacy_names.has(index):
var legacy := game_label(clauses[index], 1, Color(0, 0, 0, 0))
legacy.name = String(legacy_names[index])
legacy.visible = false
legacy.custom_minimum_size = Vector2.ZERO
container.add_child(legacy)
func causal_result_clauses(result_index: int, snapshot: Dictionary) -> Array[String]:
var members := snapshot_member_ids(snapshot)
var lead_id := members[0] if not members.is_empty() else front_choice
var second_id := members[1] if members.size() > 1 else rear_choice
var marked_id := String(snapshot["party"].get("marked_id", session_state.marked_member_id()))
var coin_now := int(snapshot["resources"].get("coin", coin))
var company_size := members.size()
var lead_hp := snapshot_member_hp(snapshot, lead_id)
var marked_hp := snapshot_member_hp(snapshot, marked_id)
var result_time := snapshot_result_time(snapshot, result_index)
var clauses: Array[String] = []
match result_index:
1:
clauses = [
tr_text("系统让%s先行;%s。" % [candidate_name(lead_id), member_result_action(lead_id, lead_id)], "The system sent %s first; %s." % [candidate_name(lead_id), member_result_action(lead_id, lead_id)]),
tr_text("%s随后%s。" % [candidate_name(second_id), member_result_action(second_id, lead_id)], "%s then %s." % [candidate_name(second_id), member_result_action(second_id, lead_id)]),
tr_text("职责优先级决定位置;点击顺序不会改变编队。", "Role priority assigned positions; tap order did not change formation."),
tr_text("%s %d/100;金币 0 → %d(仅一次)。" % [candidate_name(lead_id), lead_hp, coin_now], "%s %d/100 HP; Coin 0 -> %d (once)." % [candidate_name(lead_id), lead_hp, coin_now]),
tr_text("比较钉阵胫甲与晨钟,选择下一战的安全或节奏。", "Compare Frontpin Greaves and Early Bell for safety or tempo."),
]
2:
var other_item := "EQ-061" if adaptation_choice == "EQ-002" else "EQ-002"
clauses = [
tr_text("%s改变了第二战的开局。" % adaptation_name(adaptation_choice), "%s changed the opening of battle two." % adaptation_name(adaptation_choice)),
second_result_explanation().replace("\n", " "),
tr_text("选择%s,放弃%s的效果。" % [adaptation_name(adaptation_choice), adaptation_name(other_item)], "Chose %s and gave up %s's effect." % [adaptation_name(adaptation_choice), adaptation_name(other_item)]),
tr_text("%s %d/100;用时 %.2f 秒;金币 %d。" % [candidate_name(lead_id), lead_hp, result_time, coin_now], "%s %d/100 HP; %.2f seconds; %d Coin." % [candidate_name(lead_id), lead_hp, result_time, coin_now]),
tr_text("返回公会,比较保留装备与招募%s。" % candidate_name(session_service.mapped_guild_recruit()), "Return to the Guild; compare keeping the item with recruiting %s." % candidate_name(session_service.mapped_guild_recruit())),
]
3:
var recruited := guild_choice == "recruit_rear"
clauses = [
tr_text("选择了%s。" % ("招募%s" % candidate_name(added_rear) if recruited else "保留%s" % adaptation_name(retained_item)), "Chose %s." % ("recruit %s" % candidate_name(added_rear) if recruited else "keep %s" % adaptation_name(retained_item))),
session_result_explanation().replace("\n", " "),
tr_text("%s;另一方案会%s。" % [("小队扩为三人并重新编队" if recruited else "两人小队保留已验证效果"), ("保留两人装备构筑" if recruited else "归还装备并扩为三人")], "%s; the alternative would %s." % [("Company grew to three and re-formed" if recruited else "The two-person Company kept its proven effect"), ("keep the two-person item build" if recruited else "return the item and grow to three")]),
tr_text("金币 %d;小队 %d 人;%s %d/100;用时 %.2f 秒。" % [coin_now, company_size, candidate_name(lead_id), lead_hp, result_time], "%d Coin; Company %d; %s %d/100 HP; %.2f seconds." % [coin_now, company_size, candidate_name(lead_id), lead_hp, result_time]),
tr_text("打开路线板,比较压力、应对与奖励。", "Open the route board; compare pressure, coverage, and reward."),
]
4:
clauses = [
tr_text("小队通过%s,获得 %d 金币。" % [route_name(route_choice), route_reward(route_choice)], "The Company cleared %s and gained %d Coin." % [route_name(route_choice), route_reward(route_choice)]),
tr_text("压力是%s;%s" % [route_pressure(route_choice), route_coverage_reason(route_choice)], "Pressure was %s; %s" % [route_pressure(route_choice), route_coverage_reason(route_choice)]),
tr_text("选择%s,以%s换取 +%d 金币。" % [route_name(route_choice), route_exposure(route_choice), route_reward(route_choice)], "Chose %s, accepting %s for +%d Coin." % [route_name(route_choice), route_exposure(route_choice), route_reward(route_choice)]),
tr_text("%s %d/100;%s %d/100;金币 %d;用时 %.2f 秒。" % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, result_time], "%s %d/100 HP; %s %d/100 HP; %d Coin; %.2f seconds." % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, result_time]),
tr_text("用唯一历练印记选择一名当前成员升至 Lv.2。", "Use the one Training Mark to raise one current member to Lv.2."),
]
5:
var trained_id := trained_profession()
clauses = [
tr_text("五场远征全部完成;%s升至 Lv.2。" % candidate_name(trained_id), "All five expeditions are complete; %s reached Lv.2." % candidate_name(trained_id)),
tr_text("%s;公会选择决定队伍结构,%s决定金币与压力。" % [training_result_explanation().replace("\n", " "), route_name(route_choice)], "%s The Guild choice set Company structure, and %s set reward and pressure." % [training_result_explanation().replace("\n", " "), route_name(route_choice)]),
tr_text("培养%s获得%s;其他成员保持 Lv.1。" % [candidate_name(trained_id), training_effect_short(visible_member_slot(trained_id))], "Training %s gained %s; other members stayed Lv.1." % [candidate_name(trained_id), training_effect_short(visible_member_slot(trained_id))]),
tr_text("%s %d/100;%s %d/100;金币 %d;小队 %d 人;用时 %.2f 秒。" % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, company_size, result_time], "%s %d/100 HP; %s %d/100 HP; %d Coin; Company %d; %.2f seconds." % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, company_size, result_time]),
tr_text("首次体验到此结束;可重新开始尝试另一组搭配,或返回主菜单。", "The first session ends here; restart with another pair or return to the main menu."),
]
return clauses
func snapshot_member_ids(snapshot: Dictionary) -> Array[String]:
var result: Array[String] = []
for member_id in snapshot["party"].get("ordered_ids", []):
if not String(member_id).is_empty():
result.append(String(member_id))
return result if not result.is_empty() else visible_member_ids()
func snapshot_member_hp(snapshot: Dictionary, member_id: String) -> int:
var health: Dictionary = snapshot["party"].get("member_hp", {})
return int(health.get(member_id, session_state.member_health(member_id)))
func snapshot_result_time(snapshot: Dictionary, result_index: int) -> float:
var encounter_id: StringName = [&"battle_1", &"battle_2", &"battle_3", &"battle_4", &"battle_5"][result_index - 1]
var compatibility_times := [0.0, second_result_time, third_result_time, fourth_result_time, fifth_result_time]
return float(snapshot["result_times"].get(encounter_id, compatibility_times[result_index - 1]))
func member_result_action(member_id: String, target_id: String) -> String:
match member_id:
"Hearthguard": return tr_text("挡住了第一击", "blocked the opening hit")
"Wayrunner": return tr_text("闪避后反击", "evaded and countered")
"Farstring": return tr_text("完成远程射击", "fired a ranged shot")
"Kindhand": return tr_text("为%s恢复生命" % candidate_name(target_id), "restored %s" % candidate_name(target_id))
return tr_text("完成职责行动", "completed the role action")
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:
var was_selected := selected_recruits.has(profession)
var result := session_service.toggle_recruit(profession)
if not result["ok"]:
if result["code"] == &"already_committed":
notice = tr_text("小队已满;请先移出一名成员。", "Company full; remove one Recruit first.")
render()
return
if was_selected:
notice = tr_text("已移出%s。" % candidate_name(profession), "%s removed." % candidate_name(profession))
else:
notice = tr_text("已选择%s。" % candidate_name(profession), "%s selected." % candidate_name(profession))
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:
var result := session_service.review_formation()
if not result["ok"]:
return
notice = ""
play_cue("confirm")
render()
func revise_recruit_selection() -> void:
var result := session_service.revise_formation()
if not result["ok"]:
return
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:
var result := session_service.commit_formation()
if not result["ok"]:
return
_start_first_battle_presentation()
func _start_first_battle_presentation() -> void:
battle_clock = 0.0
guard_events = 0
rear_events = 0
victory_events = 0
reset_battle_feedback()
victory_clock = 0.0
departure_transition_active = true
departure_transition_clock = 0.0
play_cue("depart")
render()
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:
if not session_service.begin_compat_first_battle()["ok"]:
return
battle_clock = 0.0
reset_battle_feedback()
victory_clock = 0.0
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
if departure_transition_active:
departure_transition_active = false
departure_transition_clock = 0.0
commit_phase_impact(battle_phase, false)
var encounter_id := session_state.encounter_id()
var previous_page := page
var result := session_service.complete_phase(encounter_id, battle_phase)
if not result["ok"]:
return
battle_clock = 0.0
if not battle_playing:
match previous_page:
AppPage.SECOND_BATTLE: second_result_time = second_battle_elapsed
AppPage.THIRD_BATTLE: third_result_time = third_battle_elapsed
AppPage.FOURTH_BATTLE: fourth_result_time = fourth_battle_elapsed
AppPage.FIFTH_BATTLE: fifth_result_time = fifth_battle_elapsed
render()
func phase_impact_time(phase: int) -> float:
return battle_fixture.impact_time(session_state.encounter_id(), phase, _battle_decisions())
func phase_duration(phase: int) -> float:
return battle_fixture.duration_for_phase(session_state.encounter_id(), phase, _battle_decisions())
func _battle_decisions() -> Dictionary:
return {
"adaptation": adaptation_choice,
"retained_item": retained_item,
"added_id": added_rear,
"route": route_choice,
"profile": selected_build_profile(),
"trained_profession": trained_profession(),
}
func commit_phase_impact(phase: int, refresh_runtime := true) -> void:
var result := session_service.resolve_compat_impact(session_state.encounter_id(), phase, {
"profile": selected_build_profile(),
"trained_slot": trained_member,
"trained_profession": trained_profession(),
})
if not result["ok"] or result["events"].is_empty():
return
var event: Dictionary = result["events"][0]
play_cue(String(event["cue"]))
var feedback_target := legacy_feedback_target(String(event["target_member_id"]))
emit_battle_feedback(
phase,
feedback_target,
int(event["delta"]),
String(event["qualifier_zh"]),
String(event["qualifier_en"]),
String(event["tier"]),
{
"encounter_id": String(event["encounter_id"]),
"source_member_id": String(event["source_member_id"]),
"target_member_id": String(event["target_member_id"]),
"cue": String(event["cue"]),
"vfx": battle_feedback_vfx_kind(event),
}
)
if refresh_runtime and page in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE]:
render()
func legacy_feedback_target(member_id: String) -> String:
if member_id in ["enemy", "front", "rear", "added"]:
return member_id
var slot := visible_member_slot(member_id)
return slot if not slot.is_empty() else member_id
func battle_feedback_vfx_kind(event: Dictionary) -> String:
if int(event.get("delta", 0)) > 0:
return "heal"
var target_id := String(event.get("target_member_id", ""))
if target_id == visible_member_id(0) and profession_defense_state(target_id) != "idle":
return "guard"
return "hit"
func commit_second_phase_impact(phase: int, refresh_runtime := true) -> void:
commit_phase_impact(phase, refresh_runtime)
func commit_third_phase_impact(phase: int, refresh_runtime := true) -> void:
commit_phase_impact(phase, refresh_runtime)
func commit_fourth_phase_impact(phase: int, refresh_runtime := true) -> void:
commit_phase_impact(phase, refresh_runtime)
func commit_fifth_phase_impact(phase: int, refresh_runtime := true) -> void:
commit_phase_impact(phase, refresh_runtime)
func emit_battle_feedback(phase: int, target: String, delta: int, qualifier_zh: String, qualifier_en: String, tier: String, binding: Dictionary = {}) -> void:
var payload := {
"phase": phase,
"target": target,
"delta": delta,
"qualifier_zh": qualifier_zh,
"qualifier_en": qualifier_en,
"tier": tier,
}
for key in binding:
payload[key] = binding[key]
feedback_presenter.present(payload)
func reset_battle_feedback() -> void:
feedback_presenter.clear()
battle_feedback_label = null
battle_feedback_vfx = null
func expire_battle_feedback() -> void:
feedback_presenter.expire()
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 departure_transition_active:
if page != AppPage.BATTLE or not battle_playing:
departure_transition_active = false
departure_transition_clock = 0.0
departure_sprites.clear()
return
departure_transition_clock += delta
var departure_duration := REDUCED_DEPARTURE_DURATION if reduced_motion else DEPARTURE_TRANSITION_DURATION
var departure_progress := 1.0 if reduced_motion else smoothstep(0.0, 1.0, clampf(departure_transition_clock / departure_duration, 0.0, 1.0))
for member_id in departure_sprites:
var departure_actor: TextureRect = departure_sprites[member_id]
if departure_actor != null and is_instance_valid(departure_actor):
departure_actor.position = Vector2(departure_start_positions[member_id]).lerp(Vector2(departure_target_positions[member_id]), departure_progress)
departure_actor.texture = animation_texture(member_id, "idle" if reduced_motion else "advance", departure_transition_clock)
if departure_threat_sprite != null and is_instance_valid(departure_threat_sprite):
departure_threat_sprite.texture = animation_texture("Riftblade", "idle", departure_transition_clock)
if departure_transition_clock >= departure_duration:
departure_transition_active = false
departure_sprites.clear()
departure_transition_clock = 0.0
render()
return
if page in [AppPage.MAIN_MENU, AppPage.FIRST_MISSION, AppPage.RECRUIT_SELECTION, AppPage.FORMATION_REVIEW] and not portrait_layout():
yard_preview_clock += delta
for profession in yard_preview_sprites:
var actor: TextureRect = yard_preview_sprites[profession]
if actor != null and is_instance_valid(actor):
actor.texture = animation_texture(profession, "idle", yard_preview_clock)
if yard_threat_sprite != null and is_instance_valid(yard_threat_sprite):
yard_threat_sprite.texture = animation_texture("Riftblade", "idle", yard_preview_clock)
return
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()
var impact_at := phase_impact_time(battle_phase)
var duration := phase_duration(battle_phase)
var clock_result := battle_clock_owner.advance(delta, render_snapshot()["encounter"], impact_at, duration, restart_confirmation)
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
for request in clock_result["impact_requests"]:
commit_phase_impact(int(request["phase"]))
if clock_result["phase_complete"]:
advance_battle_phase()
return
if front_sprite == null or not is_instance_valid(front_sprite):
return
var progress: float = float(clock_result["phase_progress"])
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)
var later_cycle := page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE]
var visible_front := visible_member_id(0)
var visible_rear := visible_member_id(1)
var visible_third := visible_member_id(2)
front_sprite.texture = animation_texture(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock)
rear_sprite.texture = animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else rear_animation_state(), battle_clock)
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
added_rear_sprite.texture = animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock)
enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock)
var projectile_actor_is_active := false
for member_id in visible_member_ids():
if (later_battle_animation_state(member_id) if later_cycle else (rear_animation_state() if member_id == visible_rear else front_animation_state())) == "draw_release":
projectile_actor_is_active = true
break
if projectile != null and is_instance_valid(projectile) and projectile_actor_is_active and not reduced_motion:
var projectile_start := 0.08
var projectile_end := phase_impact_time(battle_phase) + 0.10
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:
session_service.commit_reward(&"battle_1", FIRST_VICTORY_COIN, &"battle_1")
func open_adaptation() -> void:
if not session_service.open_adaptation()["ok"]:
return
play_cue("confirm")
render()
func choose_adaptation(id: String) -> void:
if not session_service.choose_adaptation(id)["ok"]:
return
play_cue("select")
render()
func begin_second_battle() -> void:
if not session_service.commit_adaptation()["ok"]:
return
battle_clock = 0.0
guard_events = 0
rear_events = 0
reset_battle_feedback()
second_battle_elapsed = 0.0
second_result_time = 0.0
play_cue("depart")
render()
func open_guild_return() -> void:
if not session_service.open_guild_response()["ok"]:
return
play_cue("confirm")
render()
func choose_guild_option(id: String) -> void:
if not session_service.choose_guild_response(domain_guild_response(id))["ok"]:
return
play_cue("select")
render()
func domain_guild_response(id: String) -> String:
match id:
"retain_item": return "retain_loan"
"recruit_rear": return "add_recruit"
return id
func legacy_guild_choice(id: String) -> String:
match id:
"retain_loan": return "retain_item"
"add_recruit": return "recruit_rear"
return id
func set_legacy_guild_choice(id: String) -> void:
var response := domain_guild_response(id)
if response.is_empty() or response in P91ContractsScript.GUILD_RESPONSE_IDS:
session_state.guild_response = response
func begin_third_battle() -> void:
if not session_service.commit_guild_response()["ok"]:
return
battle_clock = 0.0
guard_events = 0
rear_events = 0
reset_battle_feedback()
third_battle_elapsed = 0.0
third_result_time = 0.0
play_cue("depart")
render()
func open_route_board() -> void:
if not session_service.open_route_board()["ok"]:
return
play_cue("confirm")
render()
func choose_route(id: String) -> void:
if not session_service.choose_route(id)["ok"]:
return
play_cue("select")
render()
func open_route_review() -> void:
if not session_service.review_route()["ok"]:
return
play_cue("confirm")
render()
func cancel_route_review() -> void:
if not session_service.cancel_route_review()["ok"]:
return
play_cue("select")
render()
func commit_route() -> void:
if not session_service.commit_route()["ok"]:
return
battle_clock = 0.0
guard_events = 0
rear_events = 0
reset_battle_feedback()
fourth_battle_elapsed = 0.0
fourth_result_time = 0.0
play_cue("depart")
render()
func commit_route_reward() -> void:
session_service.commit_reward(&"route", route_reward(route_choice), &"battle_4")
func open_training() -> void:
if not session_service.open_growth()["ok"]:
return
play_cue("confirm")
render()
func choose_training(slot: String) -> void:
var member_id := member_profession(slot)
if not session_service.choose_growth(member_id)["ok"]:
return
training_choice = slot
play_cue("select")
render()
func open_training_review() -> void:
if not session_service.review_growth()["ok"]:
return
play_cue("confirm")
render()
func cancel_training_review() -> void:
if not session_service.cancel_growth_review()["ok"]:
return
play_cue("select")
render()
func commit_training() -> void:
if not session_service.commit_growth()["ok"]:
return
trained_member = training_choice
battle_clock = 0.0
guard_events = 0
rear_events = 0
reset_battle_feedback()
fifth_battle_elapsed = 0.0
fifth_result_time = 0.0
play_cue("depart")
render()
func commit_fifth_reward() -> void:
session_service.commit_reward(&"battle_5", FIFTH_BATTLE_REWARD, &"battle_5")
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:
session_service.reset_transient(&"compat_clear")
battle_clock_owner.reset()
second_battle_elapsed = 0.0
third_battle_elapsed = 0.0
fourth_battle_elapsed = 0.0
fifth_battle_elapsed = 0.0
reset_battle_feedback()
victory_clock = 0.0
departure_transition_active = false
departure_transition_clock = 0.0
departure_sprites.clear()
departure_start_positions.clear()
departure_target_positions.clear()
departure_threat_sprite = null
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:
var lead_id := visible_member_id(0)
var second_id := visible_member_id(1)
var third_id := visible_member_id(2)
match battle_phase:
0:
return tr_text("小队向裂隙队长推进…", "The squad advances on the Rift Captain…")
1:
return tr_text("晨钟先手:%s抢在队长之前行动" % candidate_name(lead_id), "Early Bell: %s acts before the captain" % candidate_name(lead_id)) if retained_item == "EQ-061" else tr_text("队长蓄力,准备攻击先行者%s" % candidate_name(lead_id), "The captain gathers force against lead %s" % candidate_name(lead_id))
2:
return tr_text("钉阵胫甲让%s守住阵线,重击只造成 -10" % candidate_name(lead_id), "Frontpin lets %s hold; the strike deals only -10" % candidate_name(lead_id)) if retained_item == "EQ-002" else tr_text("队长重击%s;现在需要队友支援" % candidate_name(lead_id), "The captain hits %s; support is needed" % candidate_name(lead_id))
3:
return support_action_text(second_id, lead_id, "第二位", "second position")
4:
if not third_id.is_empty():
return support_action_text(third_id, lead_id, "第三位", "third position")
return tr_text("保留装备继续放大两人连携", "The retained item amplifies the two-person follow-up")
5:
return tr_text("三人阵线合力压制队长", "The three-person line overwhelms the captain") if not added_rear.is_empty() else tr_text("两人阵线守住装备优势", "The two-person line keeps its item advantage")
6:
return tr_text("裂隙队长被击败!", "Rift Captain defeated!")
return ""
func fourth_battle_event_text() -> String:
var lead_id := visible_member_id(0)
var marked_id := session_state.marked_member_id()
var second_id := visible_member_id(1)
var third_id := visible_member_id(2)
match battle_phase:
0:
return tr_text("小队进入%s…" % route_name(route_choice), "The Company enters %s…" % route_name(route_choice))
1:
if route_choice == "R6-01":
return tr_text("路线守卫蓄力:正面粉碎即将命中%s" % candidate_name(lead_id), "The Warden charges a front crush against %s" % candidate_name(lead_id))
if route_choice == "R6-02":
return tr_text("晨钟抢到先手" if selected_build_profile() == "tempo" else "敌方速度爆发抢先行动", "Early Bell seizes initiative" if selected_build_profile() == "tempo" else "Enemy tempo bursts first")
return tr_text("双辉标记锁定了%s" % candidate_name(marked_id), "Twinflare marks %s" % candidate_name(marked_id))
2:
if route_choice == "R6-03":
return tr_text("分裂打击越过%s,命中%s -35" % [candidate_name(lead_id), candidate_name(marked_id)], "The split strike bypasses %s and hits %s for -35" % [candidate_name(lead_id), candidate_name(marked_id)])
return tr_text("钉阵胫甲把%s受到的粉碎降为 -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "路线压力命中%s" % candidate_name(lead_id), "Frontpin reduces %s's crush to -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "Route pressure hits %s" % candidate_name(lead_id))
3:
return support_action_text(second_id, marked_id if route_choice == "R6-03" else lead_id, "第二位", "second position")
4:
return support_action_text(third_id, marked_id if route_choice == "R6-03" else lead_id, "第三位", "third position") if not third_id.is_empty() else tr_text("保留构筑发动第二次反击", "The retained build follows up")
5:
return tr_text("小队突破路线守卫的防线", "The Company breaks the route defense")
6:
return tr_text("路线守卫被击败,奖励已结算", "Route Warden defeated; reward settled")
return ""
func fifth_battle_event_text() -> String:
var lead_id := visible_member_id(0)
var marked_id := session_state.marked_member_id()
var supports := support_member_ids()
var first_support := supports[0] if not supports.is_empty() else ""
var second_support := supports[1] if supports.size() > 1 else ""
match battle_phase:
0:
return tr_text("Lv.2 成员带队进入双压关隘…", "The Lv.2 member leads into Twin-Pressure Pass…")
1:
return tr_text("历练弓手%s提前射击,但本场仍只行动一次" % candidate_name(trained_profession()), "Trained Archer %s fires early, but still acts only once" % candidate_name(trained_profession())) if trained_profession() == "Farstring" else tr_text("双压守卫同时锁定%s与%s" % [candidate_name(lead_id), candidate_name(marked_id)], "The Twin Warden marks %s and %s" % [candidate_name(lead_id), candidate_name(marked_id)])
2:
return tr_text("%s的历练额外减少 20 伤害" % candidate_name(lead_id), "%s's training removes 20 more damage" % candidate_name(lead_id)) if trained_profession() == lead_id else tr_text("%s承受第一道正面重压" % candidate_name(lead_id), "%s absorbs the first pressure" % candidate_name(lead_id))
3:
return tr_text("分裂压力越过阵线,%s受到 -40" % candidate_name(marked_id), "Split pressure bypasses the line; %s takes -40" % candidate_name(marked_id))
4:
if trained_profession() == first_support and first_support == "Farstring":
return tr_text("历练弓手已在第一步行动,本步不重复攻击", "The trained Archer already acted; no duplicate attack")
return support_action_text(first_support, marked_id, "第二位", "second position", trained_profession() == first_support)
5:
if not second_support.is_empty():
if trained_profession() == second_support and second_support == "Farstring":
return tr_text("历练弓手已提前行动,不会重复射击", "The trained Archer acted early and does not fire twice")
return support_action_text(second_support, marked_id, "第三位", "third position", trained_profession() == second_support)
return tr_text("两人小队发动保留构筑追击", "The two-person line triggers its retained-build follow-up")
6:
return tr_text("双压守卫被击败;成长结果已验证", "Twin Warden defeated; growth result verified")
return ""
func support_action_text(member_id: String, target_id: String, position_zh: String, position_en: String, trained := false) -> String:
match member_id:
"Wayrunner":
return tr_text("%s%s发动侧翼反击" % [position_zh, candidate_name(member_id)], "%s in %s launches a flank counter" % [candidate_name(member_id), position_en])
"Farstring":
return tr_text("%s%s完成远程射击" % [position_zh, candidate_name(member_id)], "%s in %s fires a ranged shot" % [candidate_name(member_id), position_en])
"Kindhand":
var amount := 25 if trained else 12
return tr_text("%s%s为%s恢复 +%d" % [position_zh, candidate_name(member_id), candidate_name(target_id), amount], "%s in %s restores +%d to %s" % [candidate_name(member_id), position_en, amount, candidate_name(target_id)])
return tr_text("%s%s完成职责行动" % [position_zh, candidate_name(member_id)], "%s in %s completes the role action" % [candidate_name(member_id), position_en])
func selected_build_profile() -> String:
if not added_rear.is_empty():
return "full_line"
return "guarded" if retained_item == "EQ-002" else "tempo"
func member_slots() -> Array[String]:
var slots: Array[String] = []
for index in range(visible_member_ids().size()):
slots.append(["front", "rear", "added"][index])
return slots
func member_profession(slot: String) -> String:
match slot:
"front":
return visible_member_id(0)
"rear":
return visible_member_id(1)
"added":
return visible_member_id(2)
return ""
func member_slot_label(slot: String) -> String:
var member_id := member_profession(slot)
var origin := tr_text("公会加入", "Guild recruit") if member_id == added_rear and not added_rear.is_empty() else tr_text("初始成员", "Starting member")
match slot:
"front":
return tr_text("先行位 · %s" % origin, "Lead · %s" % origin)
"rear":
return tr_text("第二位 · %s" % origin, "Second · %s" % origin)
"added":
return tr_text("第三位 · %s" % origin, "Third · %s" % origin)
return ""
func member_level(slot: String) -> int:
if slot not in member_slots():
return 0
var member_id := member_profession(slot)
return int(session_state.member_levels.get(member_id, 2 if training_committed and slot == trained_member else 1))
func trained_profession() -> String:
if not training_committed:
return ""
return session_state.trained_member_id if not session_state.trained_member_id.is_empty() else member_profession(trained_member)
func training_color(slot: String) -> Color:
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_test_short(slot: String) -> String:
match member_profession(slot):
"Hearthguard", "Wayrunner":
return tr_text("承受正面重压后的剩余生命", "HP after the front pressure")
"Farstring":
return tr_text("射击提前且攻击次数不变", "earlier shot with no extra attack")
"Kindhand":
return tr_text("被标记成员获得 +25 恢复", "the marked member receives +25 healing")
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 gain, cost, and opportunity cost. Cannot confirm yet: choose one member.")
return tr_text("已选择“%s”。确认前仍可更换;金币、路线与等级尚未改变。" % candidate_name(member_profession(training_choice)), "“%s” selected. You can still change it; Coin, route, and levels are unchanged." % candidate_name(member_profession(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 route_reason_short(id: String) -> String:
return tr_text("当前理由:%s" % route_coverage_label(id), "Why now: %s" % route_coverage_label(id))
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 reason, gain, cost, and opportunity cost. Cannot confirm yet: choose a route.")
return tr_text("已选择“%s”。确认前仍可更换;金币与路线尚未改变。" % route_name(route_choice), "“%s” selected. You can still change it; Coin and route are unchanged." % 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_option_title(id: String) -> String:
return tr_text("钉阵胫甲 · 稳住第一击", "Frontpin Greaves · Steady the First Hit") if id == "EQ-002" else tr_text("晨钟 · 抢到先手", "Early Bell · Seize Initiative")
func adaptation_reason(id: String) -> String:
return tr_text("当前理由:下一场会重击先行者", "Why now: next battle strikes the lead") if id == "EQ-002" else tr_text("当前理由:先在重击前造成 -28", "Why now: deal -28 before the heavy strike")
func adaptation_gain(id: String) -> String:
return tr_text("得到:先行者第一次受伤降为 -10", "Gain: the lead's first damage becomes -10") if id == "EQ-002" else tr_text("得到:先行者抢在敌人之前行动", "Gain: the lead acts before the enemy")
func adaptation_cost(id: String) -> String:
return tr_text("代价:小队下一次攻击更晚", "Cost: the Company's next strike is later") if id == "EQ-002" else tr_text("代价:先行者承受完整 -30 重击", "Cost: the lead takes the full -30 strike")
func adaptation_foregone(id: String) -> String:
return tr_text("放弃:先行者抢在敌人之前行动", "Give up: the lead acts before the enemy") if id == "EQ-002" else tr_text("放弃:第一次受伤降为 -10", "Give up: first damage becomes -10")
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("没有预选。比较当前理由、得到、代价与放弃。还不能确认:先选择一件装备。", "Nothing is preselected. Compare reason, gain, cost, and opportunity cost. Cannot confirm yet: choose one item.")
return tr_text("已选择“%s”。确认前仍可更换;金币与小队尚未改变。" % adaptation_name(adaptation_choice), "“%s” selected. You can still change it; Coin and Company are unchanged." % 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 formation_commit_count > 0 or (front_choice in ["Hearthguard", "Wayrunner"] and rear_choice in ["Farstring", "Kindhand"])
func guild_preview_order() -> Array[String]:
if guild_choice != "recruit_rear":
return visible_member_ids()
return session_service.ordered_with_addition(session_service.mapped_guild_recruit())
func guild_recruit_position(recruit_id: String) -> String:
var index := session_service.ordered_with_addition(recruit_id).find(recruit_id)
var labels_zh := ["先行位", "第二位", "第三位"]
var labels_en := ["lead", "second", "third"]
return (labels_zh if locale == "zh_CN" else labels_en)[index] if index >= 0 and index < 3 else ""
func guild_choice_guidance() -> String:
if guild_choice.is_empty():
return tr_text("没有预选。比较当前理由、得到、代价与放弃。还不能确认:先选择一种公会应对。", "Nothing is preselected. Compare reason, gain, cost, and opportunity cost. Cannot confirm yet: choose one Guild response.")
if guild_choice == "retain_item":
return tr_text("已选择“保留已验证装备”。确认前仍可更换;金币与小队尚未改变。", "“Keep the Proven Item” selected. You can still change it; Coin and Company are unchanged.")
var recruit_id := session_service.mapped_guild_recruit()
var order_names: Array[String] = []
for member_id in guild_preview_order():
order_names.append(candidate_name(member_id))
return tr_text("已选择“招募第三名成员”。%s成为%s;新顺序:%s;原两人相对顺序不变。确认前金币与小队尚未改变。" % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " → ".join(order_names)], "“Recruit a Third Member” selected. %s becomes %s; new order: %s; the original pair keeps its relative order. Coin and Company are unchanged before commit." % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " -> ".join(order_names)])
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 render_visible_party(prefix: String, start_position: Vector2, actor_height: float, spacing := -1.0) -> Array[TextureRect]:
var actors: Array[TextureRect] = []
var resolved_spacing := spacing if spacing >= 0.0 else actor_height * 0.88
var node_suffixes := ["Front", "Rear", "AddedRear"]
for index in range(visible_member_ids().size()):
var member_id := visible_member_id(index)
var scale: float = [1.0, 0.90, 0.84][index]
var actor_size: float = actor_height * scale
var actor := actor_at(
animation_texture(member_id, "victory" if index == 0 and member_id == "Hearthguard" else "idle", victory_clock),
start_position + Vector2(resolved_spacing * index, actor_height - actor_size),
Vector2(actor_size, actor_size),
prefix + node_suffixes[index]
)
actor.set_meta("member_id", member_id)
actors.append(actor)
return actors
func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> TextureRect:
var image := TextureRect.new()
image.name = "BattleVFX"
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 (added_rear_base_position if target == "added" else enemy_base_position))
var target_scale := 1.0 if target == "front" else (0.90 if target == "rear" else (0.86 if target == "added" else 0.82))
var feedback_width := clampf(size.x * 0.20, 170.0, 260.0)
battle_feedback_origin = Vector2(
clampf(target_position.x + actor_width * target_scale * 0.5 - feedback_width * 0.5, 12.0, size.x - feedback_width - 12.0),
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_kind := String(active_battle_feedback.get("vfx", "heal" if delta > 0 else ("guard" if int(active_battle_feedback["phase"]) == 2 else "hit")))
var vfx_texture: Texture2D = VFX.get(vfx_kind, 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"
for key in ["encounter_id", "source_member_id", "target_member_id", "cue", "vfx"]:
if active_battle_feedback.has(key):
battle_feedback_label.set_meta(key, active_battle_feedback[key])
battle_feedback_vfx.set_meta(key, active_battle_feedback[key])
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"] == "added" and added_rear_sprite != null and is_instance_valid(added_rear_sprite):
added_rear_sprite.position.x = added_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 := WidgetsScript.label(value, scaled(font_size), color)
label.add_theme_font_size_override("font_size", scaled(font_size))
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 := WidgetsScript.button(node_name, label, callback)
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"))
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:
return WidgetsScript.style(fill, border, width)
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:
audio_adapter.play(StringName(kind), muted)