1265 lines
49 KiB
GDScript
1265 lines
49 KiB
GDScript
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))
|