936 lines
36 KiB
GDScript
936 lines
36 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 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 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/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 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 display_root: Control
|
||
var front_sprite: TextureRect
|
||
var rear_sprite: TextureRect
|
||
var 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)
|
||
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 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
|
||
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
|
||
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(ACTORS[front_choice], front_base_position, Vector2(actor_width, actor_height), "FrontActor")
|
||
rear_sprite = actor_at(ACTORS[rear_choice], rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor")
|
||
var enemy_texture: Texture2D = RIFTBLADE["death"] if battle_phase >= 5 else RIFTBLADE["hit"] if battle_phase == 4 else RIFTBLADE["attack"] if battle_phase in [1, 2] else RIFTBLADE["idle"]
|
||
enemy_sprite = actor_at(enemy_texture, 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 == 2:
|
||
add_vfx(VFX["guard"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.33), actor_height * 0.25)
|
||
elif battle_phase == 3:
|
||
if rear_choice == "Farstring":
|
||
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
|
||
else:
|
||
add_vfx(VFX["heal"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.3), actor_height * 0.25)
|
||
elif battle_phase == 4:
|
||
add_vfx(VFX["hit"], enemy_base_position + Vector2(actor_width * 0.28, actor_height * 0.25), actor_height * 0.27)
|
||
|
||
|
||
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)
|
||
actor_at(ACTORS[front_choice], Vector2(size.x * 0.13, size.y - actor_height - 45), Vector2(actor_height, actor_height), "VictoryFront")
|
||
actor_at(ACTORS[rear_choice], Vector2(size.x * 0.29, size.y - actor_height * 0.92 - 45), Vector2(actor_height * 0.92, actor_height * 0.92), "VictoryRear")
|
||
actor_at(RIFTBLADE["death"], 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
|
||
page = AppPage.BATTLE
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func advance_battle_phase() -> void:
|
||
if page != AppPage.BATTLE or not battle_playing:
|
||
return
|
||
battle_phase += 1
|
||
battle_clock = 0.0
|
||
match battle_phase:
|
||
2:
|
||
front_hp = 82 if front_choice == "Hearthguard" else 88
|
||
guard_events += 1
|
||
play_cue("hit")
|
||
3:
|
||
if rear_choice == "Farstring":
|
||
enemy_hp = 64
|
||
else:
|
||
front_hp = min(100, front_hp + 12)
|
||
rear_events += 1
|
||
play_cue("role")
|
||
4:
|
||
enemy_hp = 34
|
||
play_cue("hit")
|
||
5:
|
||
enemy_hp = 0
|
||
play_cue("victory")
|
||
6:
|
||
battle_playing = false
|
||
victory_events += 1
|
||
page = AppPage.VICTORY
|
||
render()
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if page != AppPage.BATTLE or not battle_playing:
|
||
return
|
||
battle_clock += delta
|
||
var duration := 2.5 if battle_phase == 0 else 2.2
|
||
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)
|
||
var bob := 0.0 if reduced_motion else sin(battle_clock * 5.0) * 3.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.position.y = front_base_position.y + bob
|
||
rear_sprite.position.y = rear_base_position.y - bob * 0.7
|
||
enemy_sprite.position.y = enemy_base_position.y + bob * 0.5
|
||
if projectile != null and is_instance_valid(projectile) and battle_phase == 3 and rear_choice == "Farstring" and not reduced_motion:
|
||
projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, progress)
|
||
|
||
|
||
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
|
||
|
||
|
||
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 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))
|