Files
aetherbound-guild/runtime/main.gd
T

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