Files
aetherbound-guild/runtime/main.gd
T

1265 lines
49 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 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 }
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 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 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 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:
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:
match battle_phase:
0:
return "advance"
3, 4:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
return "idle"
func enemy_animation_state() -> String:
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
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] 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()
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))
render_hud(tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))
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.24 if contact_reached else 0.07), world_bottom - actor_height * 0.92)
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")
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, 100, tr_text("裂影刃手", "Riftblade"))
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 battle_phase == 1:
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif battle_phase == 3:
if rear_choice == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), 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("第一次远征完成", "First Expedition Complete") + " ◆", 22, COLOR_INK))
box.add_child(centered_label(victory_explanation(), 12, Color("#4c402d")))
box.add_child(centered_label(tr_text("你已经看懂前排与后排的职责。", "You have learned what front and rear roles do."), 11, Color("#6d5523")))
var again := game_button("PlayAgainButton", tr_text("重新体验", "Play Again"), request_restart, 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_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 != AppPage.BATTLE or not battle_playing:
return
commit_phase_impact(battle_phase, false)
battle_phase += 1
battle_clock = 0.0
match battle_phase:
6:
battle_playing = false
victory_events += 1
page = AppPage.VICTORY
render()
func phase_impact_time(phase: int) -> float:
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 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"
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 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 == AppPage.VICTORY:
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 != AppPage.BATTLE or not battle_playing or restart_confirmation:
return
var feedback_was_active := not active_battle_feedback.is_empty()
battle_clock += 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)
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)
enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock)
if projectile != null and is_instance_valid(projectile) and battle_phase == 3 and rear_choice == "Farstring" and not reduced_motion:
var projectile_start := 0.08
var projectile_end := phase_impact_time(3) + 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 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
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:
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 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 enemy_base_position
var target_scale := 1.0 if target == "front" 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"] == "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 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))