Files
aetherbound-guild/runtime/main.gd
T

2547 lines
107 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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 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, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT, ROUTE_BOARD, ROUTE_REVIEW, FOURTH_BATTLE, SECOND_CYCLE_RESULT }
const COLOR_INK := Color("#10182c")
const COLOR_INDIGO := Color("#172745")
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 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 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.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:
return "guard" if front_choice == "Hearthguard" else "evade_counter"
4:
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
return "idle"
func rear_animation_state() -> String:
if page == AppPage.FOURTH_BATTLE and battle_phase == 3:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
if page == AppPage.THIRD_BATTLE and battle_phase == 3:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
match battle_phase:
0:
return "advance"
3, 4:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
return "idle"
func added_rear_animation_state() -> String:
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.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] 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.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()
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 != AppPage.TITLE and page != AppPage.BOOT:
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 tools := HBoxContainer.new()
tools.name = "TitleTools"
tools.position = Vector2(size.x - 286, 22)
tools.size = Vector2(264, 50)
tools.add_theme_constant_override("separation", 8)
display_root.add_child(tools)
tools.add_child(compact_button("LocaleButton", "简 / EN", toggle_locale))
tools.add_child(compact_button("TextScaleButton", "Aa", toggle_text_scale))
tools.add_child(compact_button("MotionButton", "~", toggle_motion))
tools.add_child(compact_button("MuteButton", "♪", toggle_mute))
var copy := VBoxContainer.new()
copy.name = "TitleCopy"
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("StartAdventure", "◆ " + tr_text("进入公会庭院", "Enter the Guild Yard") + " ◆", start_adventure, 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_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_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 battle_title := tr_text("第四次远征 · %s" % route_name(route_choice), "Fourth Expedition · %s" % route_name(route_choice)) if fourth_cycle else (tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins")))
render_hud(battle_title)
var world_bottom := size.y - 58.0
var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0)
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) and not added_rear.is_empty():
added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor")
enemy_sprite = actor_at(animation_texture("Riftblade", enemy_animation_state(), battle_clock), enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor")
add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), front_hp, 100, candidate_name(front_choice))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 130 if third_cycle else (120 if fourth_cycle else 100), tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade")))
if fourth_cycle and route_choice == "R6-03":
add_health_bar("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) and not retained_item.is_empty()):
var visible_item := retained_item if (third_cycle or fourth_cycle) else adaptation_choice
var loan := centered_label(("▣ " if visible_item == "EQ-002" else "◷ ") + adaptation_name(visible_item), 11, COLOR_GOLD_LIGHT)
loan.name = "ActiveAdaptation"
loan.position = Vector2(size.x * 0.02, 116)
loan.size = Vector2(size.x * 0.23, 48)
loan.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
display_root.add_child(loan)
elif third_cycle or fourth_cycle:
var company := centered_label(tr_text("◆ 三人小队\n弓手 + 治疗", "◆ THREE-PERSON LINE\nArcher + Healer"), 11, COLOR_GOLD_LIGHT)
company.name = "ActiveCompany"
company.position = Vector2(size.x * 0.02, 116)
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 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)
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("ReplaySecondCycleButton", tr_text("重新开始完整体验", "Restart the full experience"), request_restart, true))
box.add_child(game_button("SecondCycleHomeButton", tr_text("回到主页", "Home"), go_home, false))
func render_restart_confirmation() -> void:
var wash := ColorRect.new()
wash.name = "RestartWash"
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 start_adventure() -> void:
clear_experience()
page = AppPage.YARD
notice = tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.")
play_cue("confirm")
render()
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
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] 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
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.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.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.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
target = "front"
delta = front_hp - previous_front_hp
qualifier_zh = "格挡" if front_choice == "Hearthguard" else "闪避减伤"
qualifier_en = "blocked" if front_choice == "Hearthguard" else "evaded"
guard_events += 1
play_cue("hit")
3:
if rear_choice == "Farstring":
var previous_enemy_hp := enemy_hp
enemy_hp = 64
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
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 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]:
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] 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
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 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 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.TITLE
play_cue("confirm")
render()
func go_home() -> void:
restart_confirmation = false
clear_experience()
page = AppPage.TITLE
play_cue("confirm")
render()
func clear_experience() -> void:
front_choice = ""
rear_choice = ""
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
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.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:
return tr_text("盾卫挡住重击,伤害降低", "The Guardian braces and reduces the blow") if front_choice == "Hearthguard" else tr_text("游侠侧身闪避并准备反击", "The Runner evades and prepares a counter")
3:
return tr_text("弓手从后排射中敌人", "The Archer strikes safely from the rear") if rear_choice == "Farstring" else tr_text("治疗举起灵灯,为前排恢复生命", "The Healer raises the lantern and restores the front")
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 front_line := tr_text("%s在前排挡住了敌人的攻击。" % candidate_name(front_choice), "%s held the enemy at the front." % candidate_name(front_choice))
var rear_line := tr_text("%s从后排持续造成伤害。" % candidate_name(rear_choice), "%s dealt damage from the rear." % candidate_name(rear_choice)) if rear_choice == "Farstring" else tr_text("%s从后排恢复了前排生命。" % candidate_name(rear_choice), "%s restored the front from the rear." % candidate_name(rear_choice))
return front_line + "\n" + rear_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 selected_build_profile() -> String:
if not added_rear.is_empty():
return "full_line"
return "guarded" if retained_item == "EQ-002" else "tempo"
func route_name(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["name_zh"] if locale == "zh_CN" else ROUTES[id]["name_en"]
func route_pressure(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["pressure_zh"] if locale == "zh_CN" else ROUTES[id]["pressure_en"]
func route_exposure(id: String) -> String:
if not ROUTES.has(id):
return ""
return ROUTES[id]["exposure_zh"] if locale == "zh_CN" else ROUTES[id]["exposure_en"]
func route_reward(id: String) -> int:
return int(ROUTES[id]["reward"]) if ROUTES.has(id) else 0
func route_coverage_state(id: String) -> String:
var profile := selected_build_profile()
if id == "R6-01":
return "covered" if profile == "guarded" else ("partial" if profile == "full_line" else "gap")
if id == "R6-02":
return "covered" if profile == "tempo" else ("partial" if profile == "full_line" else "gap")
if id == "R6-03":
return "covered" if profile == "full_line" else "gap"
return "gap"
func route_coverage_label(id: String) -> String:
match route_coverage_state(id):
"covered":
return tr_text("应对充分", "COVERED")
"partial":
return tr_text("部分应对", "PARTIAL")
return tr_text("存在缺口", "GAP")
func route_coverage_reason(id: String) -> String:
var profile := selected_build_profile()
if id == "R6-01":
if profile == "guarded":
return tr_text("应对充分:钉阵胫甲直接降低正面粉碎。", "Covered: Frontpin directly reduces the front crush.")
if profile == "full_line":
return tr_text("部分应对:三人阵线能治疗,但不能减少首次重击。", "Partial: the full line heals after impact but cannot reduce it.")
return tr_text("存在缺口:晨钟加快行动,但不会减少正面伤害。", "Gap: Early Bell adds speed but does not reduce front damage.")
if id == "R6-02":
if profile == "tempo":
return tr_text("应对充分:晨钟让前排抢在速度爆发之前行动。", "Covered: Early Bell acts before the tempo burst.")
if profile == "full_line":
return tr_text("部分应对:完整后排能恢复,但不能打断爆发。", "Partial: the full rear line recovers but cannot interrupt the burst.")
return tr_text("存在缺口:钉阵胫甲抗重击,但不改变敌方先手。", "Gap: Frontpin resists force but does not answer enemy initiative.")
if profile == "full_line":
return tr_text("应对充分:弓手与治疗分担后排打击和恢复。", "Covered: Archer and Healer divide rear damage and recovery.")
return tr_text("存在缺口:两件前排装备都无法保护被标记的后排。", "Gap: neither front item protects the marked rear.")
func coverage_color(id: String) -> Color:
match route_coverage_state(id):
"covered":
return COLOR_TEAL
"partial":
return COLOR_GOLD
return COLOR_CRIMSON
func build_summary_short() -> String:
match selected_build_profile():
"guarded":
return tr_text("钉阵两人小队", "Frontpin two-person line")
"tempo":
return tr_text("晨钟两人小队", "Early Bell two-person line")
return tr_text("弓手 + 治疗三人小队", "Archer + Healer three-person line")
func build_summary_text() -> String:
var company_size := 3 if selected_build_profile() == "full_line" else 2
return tr_text("金币 %d | 小队 %d 人\n%s" % [coin, company_size, build_summary_short()], "Coin %d | Company %d\n%s" % [coin, company_size, build_summary_short()])
func route_board_guidance() -> String:
if route_choice.is_empty():
return tr_text("没有预选或推荐。先比较压力、奖励和当前应对。", "Nothing is preselected or recommended. Compare pressure, reward and coverage.")
return tr_text("已选择%s;确认前仍可切换。" % route_name(route_choice), "%s selected; you can still switch before review." % route_name(route_choice))
func route_result_headline() -> String:
return tr_text("%s在%s的作用" % [build_summary_short(), route_name(route_choice)], "%s on %s" % [build_summary_short(), route_name(route_choice)])
func route_result_explanation() -> String:
return tr_text("压力:%s\n%s\n结果来自上一轮构筑与本轮压力;奖励只结算一次。" % [route_pressure(route_choice), route_coverage_reason(route_choice)], "Pressure: %s\n%s\nThe inherited build and route pressure caused this result; reward settled once." % [route_pressure(route_choice), route_coverage_reason(route_choice)])
func adaptation_name(id: String) -> String:
return tr_text("钉阵胫甲", "Frontpin Greaves") if id == "EQ-002" else tr_text("晨钟", "Early Bell")
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))