3757 lines
180 KiB
GDScript
3757 lines
180 KiB
GDScript
extends Control
|
||
|
||
signal authoritative_battle_requested
|
||
|
||
const FirstSessionStateScript = preload("res://domain/p9_1/session_state.gd")
|
||
const FirstSessionServiceScript = preload("res://domain/p9_1/session_service.gd")
|
||
const P91ContractsScript = preload("res://domain/p9_1/contracts.gd")
|
||
const PresentationSettingsScript = preload("res://presentation/presentation_settings.gd")
|
||
const BattleClockScript = preload("res://presentation/battle_clock.gd")
|
||
const AnimationAdapterScript = preload("res://presentation/animation_adapter.gd")
|
||
const AudioAdapterScript = preload("res://presentation/audio_adapter.gd")
|
||
const FeedbackPresenterScript = preload("res://presentation/feedback_presenter.gd")
|
||
const SessionPresenterScript = preload("res://ui/session_presenter.gd")
|
||
const BattleFixtureScript = preload("res://domain/p9_1/battle_fixture.gd")
|
||
const WidgetsScript = preload("res://ui/widgets.gd")
|
||
|
||
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 := BattleFixtureScript.FIRST_DURATIONS
|
||
const SECOND_BATTLE_PHASE_DURATIONS := BattleFixtureScript.SECOND_DURATIONS
|
||
const THIRD_BATTLE_PHASE_DURATIONS := BattleFixtureScript.THIRD_DURATIONS
|
||
const FOURTH_BATTLE_PHASE_DURATIONS := BattleFixtureScript.FOURTH_DURATIONS
|
||
const FIFTH_BATTLE_PHASE_DURATIONS := BattleFixtureScript.FIFTH_DURATIONS
|
||
const ROUTES := {
|
||
"R6-01": {
|
||
"name_zh": "风墙长堤", "name_en": "Windwall Causeway",
|
||
"pressure_zh": "队长正面粉碎", "pressure_en": "Captain front crush",
|
||
"exposure_zh": "低暴露", "exposure_en": "Low exposure",
|
||
"reward": 4,
|
||
},
|
||
"R6-02": {
|
||
"name_zh": "钟晶疾道", "name_en": "Bellglass Run",
|
||
"pressure_zh": "敌方速度爆发", "pressure_en": "Enemy tempo burst",
|
||
"exposure_zh": "中暴露", "exposure_en": "Medium exposure",
|
||
"reward": 7,
|
||
},
|
||
"R6-03": {
|
||
"name_zh": "双辉裂谷", "name_en": "Twinflare Ravine",
|
||
"pressure_zh": "分裂后排打击", "pressure_en": "Split rear strike",
|
||
"exposure_zh": "高暴露", "exposure_en": "High exposure",
|
||
"reward": 10,
|
||
},
|
||
}
|
||
const FIRST_VICTORY_COIN := 20
|
||
const RETAIN_ITEM_COST := 12
|
||
const RECRUIT_REAR_COST := 14
|
||
const FIFTH_BATTLE_REWARD := 8
|
||
const BATTLE_FEEDBACK_DURATION := 0.68
|
||
const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24
|
||
const DEPARTURE_TRANSITION_DURATION := 0.52
|
||
const REDUCED_DEPARTURE_DURATION := 0.12
|
||
|
||
const BOOT_REQUIRED_PATHS: Array[String] = [
|
||
"res://main.tscn",
|
||
"res://main.gd",
|
||
"res://assets/backgrounds/windglass_verge.webp",
|
||
"res://assets/backgrounds/first_expedition_field.png",
|
||
"res://assets/actors/hearthguard.png",
|
||
"res://assets/actors/wayrunner.png",
|
||
"res://assets/actors/farstring.png",
|
||
"res://assets/actors/kindhand.png",
|
||
"res://assets/enemies/riftblade/idle.png",
|
||
"res://assets/enemies/riftblade/attack.png",
|
||
"res://assets/animations/hearthguard/advance/frame-0.png",
|
||
"res://assets/animations/wayrunner/evade_counter/frame-0.png",
|
||
"res://assets/animations/farstring/draw_release/frame-0.png",
|
||
"res://assets/animations/kindhand/lantern_heal/frame-0.png",
|
||
"res://assets/animations/riftblade/death/frame-0.png",
|
||
"res://assets/fonts/NotoSansSC.ttf",
|
||
]
|
||
|
||
const CANDIDATES := {
|
||
"Hearthguard": {
|
||
"slot": "front",
|
||
"name_zh": "艾连",
|
||
"name_en": "Elian",
|
||
"role_zh": "盾卫 · 稳稳挡住敌人",
|
||
"role_en": "Guardian · holds the enemy",
|
||
},
|
||
"Wayrunner": {
|
||
"slot": "front",
|
||
"name_zh": "托马斯",
|
||
"name_en": "Tomas",
|
||
"role_zh": "游侠 · 闪避后反击",
|
||
"role_en": "Runner · evades and counters",
|
||
},
|
||
"Farstring": {
|
||
"slot": "rear",
|
||
"name_zh": "妮娅",
|
||
"name_en": "Nia",
|
||
"role_zh": "弓手 · 后排持续射击",
|
||
"role_en": "Archer · fires from the rear",
|
||
},
|
||
"Kindhand": {
|
||
"slot": "rear",
|
||
"name_zh": "赛布尔",
|
||
"name_en": "Sable",
|
||
"role_zh": "治疗 · 为前排恢复生命",
|
||
"role_en": "Healer · restores the front",
|
||
},
|
||
}
|
||
|
||
enum AppPage { BOOT, TITLE, MAIN_MENU, GAME_INTRO, SETTINGS, FIRST_MISSION, RECRUIT_SELECTION, FRONT_SELECTION, REAR_SELECTION, FORMATION_REVIEW, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT, ROUTE_BOARD, ROUTE_REVIEW, FOURTH_BATTLE, SECOND_CYCLE_RESULT, TRAINING, TRAINING_REVIEW, FIFTH_BATTLE, THIRD_CYCLE_RESULT }
|
||
|
||
const BATTLE_PRESENTATION_PAGES := [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.FOURTH_BATTLE, AppPage.SECOND_CYCLE_RESULT, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.FIFTH_BATTLE, AppPage.THIRD_CYCLE_RESULT]
|
||
|
||
const AUTO_FORMATION_ORDER: Array[String] = ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]
|
||
|
||
const COLOR_INK := Color("#10182c")
|
||
const COLOR_INDIGO := Color("#172745")
|
||
const COLOR_INDIGO_HOVER := Color("#24446c")
|
||
const COLOR_PARCHMENT := Color("#f1e2b5")
|
||
const COLOR_PARCHMENT_DARK := Color("#d6bc78")
|
||
const COLOR_GOLD := Color("#dcae43")
|
||
const COLOR_GOLD_LIGHT := Color("#ffe49a")
|
||
const COLOR_TEAL := Color("#67d9ce")
|
||
const COLOR_CRIMSON := Color("#9d3348")
|
||
const COLOR_WHITE := Color("#fffaf0")
|
||
|
||
@export var auto_boot := true
|
||
|
||
var session_state := FirstSessionStateScript.new()
|
||
var session_service := FirstSessionServiceScript.new(session_state)
|
||
var presentation_settings := PresentationSettingsScript.new()
|
||
var battle_clock_owner := BattleClockScript.new()
|
||
var battle_fixture := BattleFixtureScript.new()
|
||
var animation_adapter := AnimationAdapterScript.new()
|
||
var audio_adapter := AudioAdapterScript.new()
|
||
var feedback_presenter := FeedbackPresenterScript.new()
|
||
var session_presenter: P91SessionPresenter
|
||
|
||
var page: int:
|
||
get: return session_state.legacy_page
|
||
set(value): session_state.set_legacy_page(value)
|
||
var locale: String:
|
||
get: return presentation_settings.locale
|
||
set(value): presentation_settings.locale = value
|
||
var text_scale: float:
|
||
get: return presentation_settings.text_scale
|
||
set(value): presentation_settings.text_scale = value
|
||
var reduced_motion: bool:
|
||
get: return presentation_settings.reduced_motion
|
||
set(value): presentation_settings.reduced_motion = value
|
||
var muted: bool:
|
||
get: return presentation_settings.muted
|
||
set(value): presentation_settings.muted = value
|
||
var audio_enabled: bool:
|
||
get: return presentation_settings.audio_enabled
|
||
set(value): presentation_settings.audio_enabled = value
|
||
|
||
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: String:
|
||
get: return session_state.front_choice
|
||
set(value): session_state.front_choice = value
|
||
var rear_choice: String:
|
||
get: return session_state.rear_choice
|
||
set(value): session_state.rear_choice = value
|
||
var selected_recruits: Array[String]:
|
||
get: return session_state.selected_recruits
|
||
set(value): session_state.selected_recruits = value
|
||
var formation_commit_count: int:
|
||
get: return session_state.formation_commit_count
|
||
set(value): session_state.formation_commit_count = value
|
||
var notice: String:
|
||
get: return session_state.notice
|
||
set(value): session_state.notice = value
|
||
var restart_confirmation: bool:
|
||
get: return session_state.restart_confirmation
|
||
set(value): session_state.restart_confirmation = value
|
||
|
||
var battle_phase: int:
|
||
get: return session_state.battle_phase
|
||
set(value): session_state.battle_phase = value
|
||
var battle_clock: float:
|
||
get: return battle_clock_owner.elapsed
|
||
set(value): battle_clock_owner.elapsed = value
|
||
var battle_playing: bool:
|
||
get: return session_state.battle_playing
|
||
set(value): session_state.battle_playing = value
|
||
var front_hp: int:
|
||
get: return session_state.front_hp
|
||
set(value): session_state.front_hp = value
|
||
var enemy_hp: int:
|
||
get: return session_state.enemy_hp
|
||
set(value): session_state.enemy_hp = value
|
||
var guard_events: int:
|
||
get: return session_state.guard_events
|
||
set(value): session_state.guard_events = value
|
||
var rear_events: int:
|
||
get: return session_state.rear_events
|
||
set(value): session_state.rear_events = value
|
||
var victory_events: int:
|
||
get: return session_state.victory_events
|
||
set(value): session_state.victory_events = value
|
||
var phase_impact_committed: Dictionary:
|
||
get: return session_state.phase_impact_committed
|
||
set(value): session_state.phase_impact_committed = value
|
||
var coin: int:
|
||
get: return session_state.coin
|
||
set(value): session_state.coin = value
|
||
var first_reward_committed: bool:
|
||
get: return session_state.first_reward_committed
|
||
set(value): session_state.first_reward_committed = value
|
||
var adaptation_choice: String:
|
||
get: return session_state.adaptation_choice
|
||
set(value): session_state.adaptation_choice = value
|
||
var adaptation_committed: bool:
|
||
get: return session_state.adaptation_committed
|
||
set(value): session_state.adaptation_committed = value
|
||
var second_battle_elapsed: float:
|
||
get: return battle_clock_owner.second_elapsed
|
||
set(value): battle_clock_owner.second_elapsed = value
|
||
var second_result_time: float:
|
||
get: return battle_clock_owner.second_result_time
|
||
set(value): battle_clock_owner.second_result_time = value
|
||
var guild_choice: String:
|
||
get: return legacy_guild_choice(session_state.guild_response)
|
||
set(value): set_legacy_guild_choice(value)
|
||
var guild_committed: bool:
|
||
get: return session_state.guild_committed
|
||
set(value): session_state.guild_committed = value
|
||
var retained_item: String:
|
||
get: return session_state.retained_item
|
||
set(value): session_state.retained_item = value
|
||
var added_rear: String:
|
||
get: return session_state.added_rear
|
||
set(value): session_state.added_rear = value
|
||
var third_battle_elapsed: float:
|
||
get: return battle_clock_owner.third_elapsed
|
||
set(value): battle_clock_owner.third_elapsed = value
|
||
var third_result_time: float:
|
||
get: return battle_clock_owner.third_result_time
|
||
set(value): battle_clock_owner.third_result_time = value
|
||
var rear_hp: int:
|
||
get: return session_state.rear_hp
|
||
set(value): session_state.rear_hp = value
|
||
var route_choice: String:
|
||
get: return session_state.route_choice
|
||
set(value): session_state.route_choice = value
|
||
var route_committed: bool:
|
||
get: return session_state.route_committed
|
||
set(value): session_state.route_committed = value
|
||
var route_reward_committed: bool:
|
||
get: return session_state.route_reward_committed
|
||
set(value): session_state.route_reward_committed = value
|
||
var fourth_battle_elapsed: float:
|
||
get: return battle_clock_owner.fourth_elapsed
|
||
set(value): battle_clock_owner.fourth_elapsed = value
|
||
var fourth_result_time: float:
|
||
get: return battle_clock_owner.fourth_result_time
|
||
set(value): battle_clock_owner.fourth_result_time = value
|
||
var training_choice: String:
|
||
get: return session_state.training_choice
|
||
set(value): session_state.training_choice = value
|
||
var training_committed: bool:
|
||
get: return session_state.training_committed
|
||
set(value): session_state.training_committed = value
|
||
var trained_member: String:
|
||
get: return session_state.trained_member
|
||
set(value): session_state.trained_member = value
|
||
var fifth_reward_committed: bool:
|
||
get: return session_state.fifth_reward_committed
|
||
set(value): session_state.fifth_reward_committed = value
|
||
var fifth_battle_elapsed: float:
|
||
get: return battle_clock_owner.fifth_elapsed
|
||
set(value): battle_clock_owner.fifth_elapsed = value
|
||
var fifth_result_time: float:
|
||
get: return battle_clock_owner.fifth_result_time
|
||
set(value): battle_clock_owner.fifth_result_time = value
|
||
var animation_library: Dictionary:
|
||
get: return animation_adapter.library
|
||
set(value): animation_adapter.library = value
|
||
var victory_clock: float:
|
||
get: return battle_clock_owner.result_elapsed
|
||
set(value): battle_clock_owner.result_elapsed = value
|
||
var active_battle_feedback: Dictionary:
|
||
get: return feedback_presenter.active
|
||
set(value): feedback_presenter.active = value
|
||
var battle_feedback_clock: float:
|
||
get: return feedback_presenter.clock
|
||
set(value): feedback_presenter.clock = value
|
||
var battle_feedback_history: Array[Dictionary]:
|
||
get: return feedback_presenter.history
|
||
set(value): feedback_presenter.history = value
|
||
|
||
var display_root: Control
|
||
var front_sprite: TextureRect
|
||
var rear_sprite: TextureRect
|
||
var added_rear_sprite: TextureRect
|
||
var enemy_sprite: TextureRect
|
||
var battle_feedback_label: Label
|
||
var battle_feedback_vfx: TextureRect
|
||
var battle_feedback_origin := Vector2.ZERO
|
||
var yard_preview_sprites: Dictionary = {}
|
||
var yard_threat_sprite: TextureRect
|
||
var yard_preview_clock := 0.0
|
||
var departure_transition_active := false
|
||
var departure_transition_clock := 0.0
|
||
var departure_sprites: Dictionary = {}
|
||
var departure_start_positions: Dictionary = {}
|
||
var departure_target_positions: Dictionary = {}
|
||
var departure_threat_sprite: TextureRect
|
||
var victory_front_sprite: TextureRect
|
||
var victory_rear_sprite: TextureRect
|
||
var victory_enemy_sprite: TextureRect
|
||
var projectile: Control
|
||
var front_base_position := Vector2.ZERO
|
||
var rear_base_position := Vector2.ZERO
|
||
var added_rear_base_position := Vector2.ZERO
|
||
var enemy_base_position := Vector2.ZERO
|
||
var audio_player: AudioStreamPlayer:
|
||
get: return audio_adapter.player
|
||
set(value): audio_adapter.player = value
|
||
var sfx_playback: AudioStreamGeneratorPlayback:
|
||
get: return audio_adapter.playback
|
||
set(value): audio_adapter.playback = value
|
||
var render_pending := false
|
||
|
||
|
||
func _ready() -> void:
|
||
session_presenter = SessionPresenterScript.new(self)
|
||
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:
|
||
audio_adapter.cleanup()
|
||
|
||
|
||
func setup_audio() -> void:
|
||
audio_adapter.attach(self)
|
||
|
||
|
||
func load_animation_library() -> void:
|
||
animation_adapter.load_specs(ANIMATION_SPECS)
|
||
|
||
|
||
func animation_frame_index(character: String, state: String, elapsed: float) -> int:
|
||
if reduced_motion and animation_library.has(character) and animation_library[character].has(state) and bool(animation_library[character][state]["loop"]):
|
||
return 0
|
||
return animation_adapter.frame_index(character, state, elapsed)
|
||
|
||
|
||
func animation_texture(character: String, state: String, elapsed: float) -> Texture2D:
|
||
if animation_library.has(character):
|
||
var states: Dictionary = animation_library[character]
|
||
var resolved_state := state if states.has(state) else "idle"
|
||
if states.has(resolved_state):
|
||
var animation: Dictionary = states[resolved_state]
|
||
var frames: Array = animation["frames"]
|
||
return frames[animation_frame_index(character, resolved_state, elapsed)]
|
||
return RIFTBLADE.get(state, RIFTBLADE["idle"]) if character == "Riftblade" else ACTORS.get(character, ACTORS["Hearthguard"])
|
||
|
||
|
||
func front_animation_state() -> String:
|
||
if page == AppPage.FIFTH_BATTLE and battle_phase == 2:
|
||
return "guard" if front_choice == "Hearthguard" else "evade_counter"
|
||
if page == AppPage.FOURTH_BATTLE and selected_build_profile() == "tempo" and route_choice == "R6-02" and battle_phase == 1:
|
||
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
|
||
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
|
||
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
|
||
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1:
|
||
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
2:
|
||
if front_choice == "Hearthguard":
|
||
return "guard"
|
||
if front_choice == "Wayrunner":
|
||
return "evade_counter"
|
||
return "idle"
|
||
4:
|
||
if front_choice == "Hearthguard":
|
||
return "basic_attack"
|
||
if front_choice == "Wayrunner":
|
||
return "evade_counter"
|
||
return "draw_release" if front_choice == "Farstring" else "lantern_heal"
|
||
return "idle"
|
||
|
||
|
||
func rear_animation_state() -> String:
|
||
if page == AppPage.FIFTH_BATTLE:
|
||
if battle_phase == 1 and trained_member == "rear" and rear_choice == "Farstring":
|
||
return "draw_release"
|
||
if battle_phase == 4 and not (trained_member == "rear" and rear_choice == "Farstring"):
|
||
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
|
||
if page == AppPage.FOURTH_BATTLE and battle_phase == 3:
|
||
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
|
||
if page == AppPage.THIRD_BATTLE and battle_phase == 3:
|
||
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
3, 4:
|
||
if rear_choice == "Farstring":
|
||
return "draw_release"
|
||
if rear_choice == "Kindhand":
|
||
return "lantern_heal"
|
||
return "evade_counter" if rear_choice == "Wayrunner" else "idle"
|
||
return "idle"
|
||
|
||
|
||
func added_rear_animation_state() -> String:
|
||
if page == AppPage.FIFTH_BATTLE:
|
||
if battle_phase == 1 and trained_member == "added" and added_rear == "Farstring":
|
||
return "draw_release"
|
||
if battle_phase == 5 and not (trained_member == "added" and added_rear == "Farstring"):
|
||
return "draw_release" if added_rear == "Farstring" else "lantern_heal"
|
||
if page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE] and battle_phase == 4:
|
||
return "draw_release" if added_rear == "Farstring" else "lantern_heal"
|
||
if battle_phase == 0:
|
||
return "advance"
|
||
return "idle"
|
||
|
||
|
||
func visible_member_ids() -> Array[String]:
|
||
var members := session_state.current_member_ids()
|
||
if members.is_empty():
|
||
for member_id in [front_choice, rear_choice, added_rear]:
|
||
if not member_id.is_empty() and member_id not in members:
|
||
members.append(member_id)
|
||
return members
|
||
|
||
|
||
func visible_member_id(index: int) -> String:
|
||
var members := visible_member_ids()
|
||
return members[index] if index >= 0 and index < members.size() else ""
|
||
|
||
|
||
func visible_member_slot(member_id: String) -> String:
|
||
var index := visible_member_ids().find(member_id)
|
||
return ["front", "rear", "added"][index] if index >= 0 and index < 3 else ""
|
||
|
||
|
||
func support_member_ids() -> Array[String]:
|
||
var members := visible_member_ids()
|
||
return members.slice(1) if members.size() > 1 else []
|
||
|
||
|
||
func profession_action_state(member_id: String) -> String:
|
||
match member_id:
|
||
"Hearthguard": return "basic_attack"
|
||
"Wayrunner": return "evade_counter"
|
||
"Farstring": return "draw_release"
|
||
"Kindhand": return "lantern_heal"
|
||
return "idle"
|
||
|
||
|
||
func profession_defense_state(member_id: String) -> String:
|
||
if member_id == "Hearthguard":
|
||
return "guard"
|
||
if member_id == "Wayrunner":
|
||
return "evade_counter"
|
||
return "idle"
|
||
|
||
|
||
func later_battle_animation_state(member_id: String) -> String:
|
||
var lead_id := visible_member_id(0)
|
||
var supports := support_member_ids()
|
||
if battle_phase == 0:
|
||
return "advance"
|
||
if page == AppPage.THIRD_BATTLE:
|
||
if battle_phase == 1 and retained_item == "EQ-061" and member_id == lead_id:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 2 and member_id == lead_id:
|
||
return profession_defense_state(member_id)
|
||
if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 5 and member_id == lead_id:
|
||
return profession_action_state(member_id)
|
||
if page == AppPage.FOURTH_BATTLE:
|
||
if battle_phase == 1 and selected_build_profile() == "tempo" and route_choice == "R6-02" and member_id == lead_id:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 2 and member_id == (session_state.marked_member_id() if route_choice == "R6-03" else lead_id):
|
||
return profession_defense_state(member_id)
|
||
if battle_phase == 3 and not supports.is_empty() and member_id == supports[0]:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 4 and supports.size() > 1 and member_id == supports[1]:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 5 and member_id == lead_id:
|
||
return profession_action_state(member_id)
|
||
if page == AppPage.FIFTH_BATTLE:
|
||
var pending_supports := supports.duplicate()
|
||
if trained_profession() == "Farstring":
|
||
pending_supports.erase(trained_profession())
|
||
if battle_phase == 1 and trained_profession() == "Farstring" and member_id == trained_profession():
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 2 and member_id == lead_id:
|
||
return profession_defense_state(member_id)
|
||
if battle_phase == 3 and member_id == session_state.marked_member_id():
|
||
return profession_defense_state(member_id)
|
||
if battle_phase == 4 and not pending_supports.is_empty() and member_id == pending_supports[0]:
|
||
return profession_action_state(member_id)
|
||
if battle_phase == 5 and pending_supports.size() > 1 and member_id == pending_supports[1]:
|
||
return profession_action_state(member_id)
|
||
return "idle"
|
||
|
||
|
||
func enemy_animation_state() -> String:
|
||
if page == AppPage.FIFTH_BATTLE:
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
1:
|
||
return "hit" if trained_profession() == "Farstring" else "telegraph"
|
||
2, 3:
|
||
return "attack"
|
||
4, 5:
|
||
return "hit"
|
||
6:
|
||
return "death"
|
||
if page == AppPage.FOURTH_BATTLE:
|
||
if selected_build_profile() == "tempo" and route_choice == "R6-02" and battle_phase == 1:
|
||
return "hit"
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
1:
|
||
return "telegraph"
|
||
2:
|
||
return "attack"
|
||
3, 4, 5:
|
||
return "hit"
|
||
6:
|
||
return "death"
|
||
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
|
||
return "hit"
|
||
if page == AppPage.THIRD_BATTLE:
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
1:
|
||
return "telegraph"
|
||
2:
|
||
return "attack"
|
||
3, 4, 5:
|
||
return "hit"
|
||
6:
|
||
return "death"
|
||
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1:
|
||
return "hit"
|
||
match battle_phase:
|
||
0:
|
||
return "advance"
|
||
1:
|
||
return "telegraph"
|
||
2:
|
||
return "attack"
|
||
3:
|
||
return "hit" if rear_choice in ["Farstring", "Wayrunner"] 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:
|
||
if session_presenter == null:
|
||
render_legacy_page()
|
||
else:
|
||
session_presenter.render(render_snapshot(), {})
|
||
|
||
|
||
func render_snapshot() -> Dictionary:
|
||
var result := session_service.snapshot()
|
||
result["settings"] = presentation_settings.snapshot()
|
||
result["encounter"]["elapsed"] = battle_clock
|
||
return result.duplicate(true)
|
||
|
||
|
||
func render_binding_error() -> void:
|
||
for child in display_root.get_children():
|
||
child.free()
|
||
var error_label := game_label("P91-BINDING-ERROR", 24, COLOR_CRIMSON)
|
||
error_label.name = "P91BindingError"
|
||
error_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||
display_root.add_child(error_label)
|
||
|
||
|
||
func render_legacy_page() -> void:
|
||
render_pending = false
|
||
front_sprite = null
|
||
rear_sprite = null
|
||
added_rear_sprite = null
|
||
enemy_sprite = null
|
||
battle_feedback_label = null
|
||
battle_feedback_vfx = null
|
||
yard_preview_sprites.clear()
|
||
yard_threat_sprite = null
|
||
victory_front_sprite = null
|
||
victory_rear_sprite = null
|
||
victory_enemy_sprite = null
|
||
projectile = null
|
||
for child in display_root.get_children():
|
||
child.free()
|
||
var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.FOURTH_BATTLE, AppPage.SECOND_CYCLE_RESULT, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.FIFTH_BATTLE, AppPage.THIRD_CYCLE_RESULT] else YARD_BACKGROUND
|
||
add_background(background_texture)
|
||
if portrait_layout():
|
||
render_portrait_notice()
|
||
else:
|
||
match page:
|
||
AppPage.BOOT:
|
||
render_boot()
|
||
AppPage.TITLE:
|
||
render_title()
|
||
AppPage.MAIN_MENU:
|
||
render_main_menu()
|
||
AppPage.GAME_INTRO:
|
||
render_game_intro()
|
||
AppPage.SETTINGS:
|
||
render_settings()
|
||
AppPage.FIRST_MISSION:
|
||
render_first_mission()
|
||
AppPage.RECRUIT_SELECTION:
|
||
render_recruit_selection()
|
||
AppPage.FRONT_SELECTION:
|
||
render_role_selection(true)
|
||
AppPage.REAR_SELECTION:
|
||
render_role_selection(false)
|
||
AppPage.FORMATION_REVIEW:
|
||
render_formation_review()
|
||
AppPage.YARD:
|
||
render_yard()
|
||
AppPage.BATTLE:
|
||
render_battle()
|
||
AppPage.VICTORY:
|
||
render_victory()
|
||
AppPage.ADAPTATION:
|
||
render_adaptation()
|
||
AppPage.SECOND_BATTLE:
|
||
render_battle()
|
||
AppPage.SECOND_RESULT:
|
||
render_second_result()
|
||
AppPage.GUILD_RETURN:
|
||
render_guild_return()
|
||
AppPage.THIRD_BATTLE:
|
||
render_battle()
|
||
AppPage.SESSION_RESULT:
|
||
render_session_result()
|
||
AppPage.ROUTE_BOARD:
|
||
render_route_board()
|
||
AppPage.ROUTE_REVIEW:
|
||
render_route_review()
|
||
AppPage.FOURTH_BATTLE:
|
||
render_battle()
|
||
AppPage.SECOND_CYCLE_RESULT:
|
||
render_second_cycle_result()
|
||
AppPage.TRAINING:
|
||
render_training()
|
||
AppPage.TRAINING_REVIEW:
|
||
render_training_review()
|
||
AppPage.FIFTH_BATTLE:
|
||
render_battle()
|
||
AppPage.THIRD_CYCLE_RESULT:
|
||
render_third_cycle_result()
|
||
if restart_confirmation:
|
||
render_restart_confirmation()
|
||
|
||
|
||
func add_background(texture: Texture2D) -> void:
|
||
session_presenter.add_background(texture)
|
||
|
||
|
||
func render_portrait_notice() -> void:
|
||
add_full_wash(Color(0.015, 0.04, 0.07, 0.72))
|
||
var panel := PanelContainer.new()
|
||
panel.name = "RotateNotice"
|
||
panel.position = Vector2(24, size.y * 0.32)
|
||
panel.size = Vector2(size.x - 48, 226)
|
||
panel.add_theme_stylebox_override("panel", parchment_style())
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 10)
|
||
panel.add_child(box)
|
||
box.add_child(centered_label("↻", 34, COLOR_GOLD))
|
||
box.add_child(centered_label(tr_text("请将手机旋转为横屏", "Rotate your phone to landscape"), 21, COLOR_INK))
|
||
var guidance := centered_label(tr_text("角色、选择和战斗会在横屏中完整显示", "Characters, choices and battle will fit in landscape"), 12, Color("#55472f"))
|
||
guidance.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
box.add_child(guidance)
|
||
display_root.add_child(panel)
|
||
if page not in [AppPage.TITLE, AppPage.BOOT, AppPage.MAIN_MENU, AppPage.GAME_INTRO, AppPage.SETTINGS]:
|
||
var home := game_button("PortraitHome", tr_text("回到主页", "Home"), go_home, false)
|
||
home.position = Vector2(70, size.y * 0.32 + 244)
|
||
home.size = Vector2(size.x - 140, 52)
|
||
display_root.add_child(home)
|
||
|
||
|
||
func render_boot() -> void:
|
||
add_full_wash(Color(0.015, 0.035, 0.07, 0.58))
|
||
var shell := VBoxContainer.new()
|
||
shell.name = "BootPanel"
|
||
shell.position = Vector2(size.x * 0.12, size.y * 0.25)
|
||
shell.size = Vector2(size.x * 0.76, size.y * 0.5)
|
||
shell.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
shell.add_theme_constant_override("separation", 16)
|
||
display_root.add_child(shell)
|
||
shell.add_child(centered_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 38, COLOR_WHITE))
|
||
var status := tr_text("远征装备已经就绪", "Expedition kit ready") if boot_verified else tr_text("正在整理远征装备…", "Preparing the expedition…")
|
||
shell.add_child(centered_label(status, 18, COLOR_TEAL))
|
||
var progress := ProgressBar.new()
|
||
progress.name = "BootProgress"
|
||
progress.custom_minimum_size = Vector2(0, 18)
|
||
progress.max_value = max(1, active_boot_paths().size())
|
||
progress.value = boot_index
|
||
progress.show_percentage = false
|
||
progress.add_theme_stylebox_override("background", lacquer_style(Color("#121d34"), Color("#6d7f95"), 1))
|
||
progress.add_theme_stylebox_override("fill", lacquer_style(COLOR_GOLD, COLOR_GOLD_LIGHT, 1))
|
||
shell.add_child(progress)
|
||
if not boot_failed_path.is_empty():
|
||
shell.add_child(centered_label(tr_text("有一件装备没有准备好", "One required asset could not be prepared"), 14, Color("#ffb4b4")))
|
||
var retry := game_button("BootRetry", tr_text("重新检查", "Check again"), retry_boot, true)
|
||
retry.custom_minimum_size = Vector2(260, 56)
|
||
shell.add_child(retry)
|
||
|
||
|
||
func render_title() -> void:
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.26))
|
||
var copy := VBoxContainer.new()
|
||
copy.name = "TitleScreen"
|
||
copy.position = Vector2(size.x * 0.065, size.y * 0.47)
|
||
copy.size = Vector2(size.x * 0.57, size.y * 0.31)
|
||
copy.add_theme_constant_override("separation", 7)
|
||
display_root.add_child(copy)
|
||
copy.add_child(game_label(tr_text("风路断裂,公会正在召集第一支小队", "The wind road is broken. The guild needs its first squad."), 15, COLOR_TEAL))
|
||
copy.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 46, COLOR_WHITE))
|
||
copy.add_child(game_label(tr_text("选择两名成员,系统自动编队并执行战斗。", "Choose two Recruits; the system forms the line and runs the battle."), 17, COLOR_WHITE))
|
||
|
||
var start := game_button("EnterGameButton", "◆ " + tr_text("进入游戏", "Enter Game") + " ◆", enter_game, true)
|
||
start.position = Vector2(size.x * 0.065, size.y * 0.82)
|
||
start.size = Vector2(min(380.0, size.x * 0.44), 58)
|
||
display_root.add_child(start)
|
||
|
||
|
||
func render_main_menu() -> void:
|
||
render_gameful_yard_stage(false)
|
||
|
||
|
||
func render_gameful_yard_stage(mission_ready: bool) -> void:
|
||
yard_preview_sprites.clear()
|
||
yard_threat_sprite = null
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
|
||
var world_space := Control.new()
|
||
world_space.name = "GuildYardWorldSpace"
|
||
world_space.position = Vector2(0, 54)
|
||
world_space.size = Vector2(size.x, size.y - 54)
|
||
world_space.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
world_space.set_meta("visible_world_ratio", 0.82)
|
||
display_root.add_child(world_space)
|
||
|
||
var top_shade := ColorRect.new()
|
||
top_shade.name = "GuildYardTopShade"
|
||
top_shade.position = Vector2.ZERO
|
||
top_shade.size = Vector2(size.x, 58)
|
||
top_shade.color = Color(0.015, 0.035, 0.075, 0.78)
|
||
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(top_shade)
|
||
var identity := game_label(tr_text("裂脉远征团 · 风路公会庭院", "AETHERBOUND GUILD · WIND-ROAD YARD"), 15, COLOR_WHITE)
|
||
identity.name = "MainMenuIdentity" if not mission_ready else "FirstMissionIdentity"
|
||
identity.position = Vector2(18, 8)
|
||
identity.size = Vector2(size.x * 0.55, 42)
|
||
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
display_root.add_child(identity)
|
||
|
||
if mission_ready:
|
||
var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home)
|
||
home.position = Vector2(size.x - 184, 7)
|
||
home.size = Vector2(52, 44)
|
||
display_root.add_child(home)
|
||
var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart)
|
||
restart.position = Vector2(size.x - 126, 7)
|
||
restart.size = Vector2(52, 44)
|
||
display_root.add_child(restart)
|
||
var mute := icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)
|
||
mute.position = Vector2(size.x - 68, 7)
|
||
mute.size = Vector2(52, 44)
|
||
display_root.add_child(mute)
|
||
else:
|
||
var intro := icon_button("GameIntroButton", "?", tr_text("游戏介绍", "Game Introduction"), open_game_intro)
|
||
intro.position = Vector2(size.x - 126, 7)
|
||
intro.size = Vector2(52, 44)
|
||
display_root.add_child(intro)
|
||
var settings := icon_button("SettingsButton", "⚙", tr_text("设置", "Settings"), open_settings)
|
||
settings.position = Vector2(size.x - 68, 7)
|
||
settings.size = Vector2(52, 44)
|
||
display_root.add_child(settings)
|
||
|
||
var commission := game_label(
|
||
tr_text("第一份委托 · 守住断裂的风路\n裂影刃手正在逼近钟门", "FIRST COMMISSION · HOLD THE BROKEN WIND ROAD\nA Riftblade is closing on the bell gate")
|
||
if not mission_ready
|
||
else tr_text("钟门警报 · 裂影刃手即将抵达\n从庭院中的四名成员里召集任意两人", "BELL-GATE ALERT · RIFTBLADE APPROACHING\nRally any two of the four Recruits in the yard"),
|
||
13,
|
||
COLOR_WHITE
|
||
)
|
||
commission.name = "GuildYardCommissionRibbon"
|
||
commission.position = Vector2(18, 68)
|
||
commission.size = Vector2(minf(size.x * 0.54, 470.0), 62)
|
||
commission.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
commission.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.86), COLOR_GOLD, 2))
|
||
display_root.add_child(commission)
|
||
|
||
var stage_bottom := size.y - 54.0
|
||
var actor_height: float = clampf(size.y * 0.37, 126.0, 224.0)
|
||
var actor_spacing: float = minf(size.x * 0.145, actor_height * 0.93)
|
||
var actor_start := maxf(24.0, size.x * 0.055)
|
||
for index in range(AUTO_FORMATION_ORDER.size()):
|
||
var profession: String = AUTO_FORMATION_ORDER[index]
|
||
var actor_position := Vector2(actor_start + actor_spacing * index, stage_bottom - actor_height)
|
||
var actor := actor_at(animation_texture(profession, "idle", yard_preview_clock), actor_position, Vector2(actor_height, actor_height), "YardRecruit_%s" % profession)
|
||
actor.set_meta("profession", profession)
|
||
yard_preview_sprites[profession] = actor
|
||
var nameplate := centered_label("%s · %s" % [candidate_name(profession), candidate_role_short(profession)], 10, COLOR_WHITE)
|
||
nameplate.name = "YardRecruitLabel_%s" % profession
|
||
nameplate.position = Vector2(actor_position.x + actor_height * 0.02, stage_bottom - 35)
|
||
nameplate.size = Vector2(actor_height * 0.96, 34)
|
||
nameplate.add_theme_stylebox_override("normal", lacquer_style(Color(0.025, 0.045, 0.09, 0.84), COLOR_TEAL, 1))
|
||
display_root.add_child(nameplate)
|
||
|
||
var breach_path := ColorRect.new()
|
||
breach_path.name = "GuildYardBreachPath"
|
||
breach_path.position = Vector2(size.x * 0.66, size.y * 0.67)
|
||
breach_path.size = Vector2(size.x * 0.27, 4)
|
||
breach_path.color = Color(0.99, 0.61, 0.23, 0.82)
|
||
breach_path.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(breach_path)
|
||
var threat_size := clampf(size.y * 0.29, 102.0, 176.0)
|
||
var threat_position := Vector2(size.x - threat_size - 36, size.y * 0.31)
|
||
yard_threat_sprite = actor_at(animation_texture("Riftblade", "idle", yard_preview_clock), threat_position, Vector2(threat_size, threat_size), "GuildYardThreat")
|
||
var threat_label := centered_label(tr_text("钟门威胁\n裂影刃手", "BELL-GATE THREAT\nRIFTBLADE"), 10, Color("#ffd2b1"))
|
||
threat_label.name = "GuildYardThreatLabel"
|
||
threat_label.position = Vector2(threat_position.x - 4, threat_position.y + threat_size * 0.78)
|
||
threat_label.size = Vector2(threat_size + 8, 43)
|
||
threat_label.add_theme_stylebox_override("normal", lacquer_style(Color(0.10, 0.025, 0.045, 0.88), COLOR_CRIMSON, 2))
|
||
display_root.add_child(threat_label)
|
||
|
||
var action_name := "OpenRecruitSelectionButton" if mission_ready else "StartNewGameButton"
|
||
var action_label := tr_text("集结两名成员", "Assemble Company") if mission_ready else tr_text("接受风路委托", "Accept Commission")
|
||
var action_callback: Callable = open_recruit_selection if mission_ready else start_new_game
|
||
var action := game_button(action_name, action_label, action_callback, true)
|
||
action.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
|
||
action.size = Vector2(minf(222.0, size.x * 0.28), 54)
|
||
display_root.add_child(action)
|
||
|
||
func render_game_intro() -> void:
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.48))
|
||
var heading := centered_label(tr_text("远征团长要做什么?", "WHAT DOES THE CAPTAIN DO?"), 24, COLOR_WHITE)
|
||
heading.name = "GameIntroductionScreen"
|
||
heading.position = Vector2(32, 24)
|
||
heading.size = Vector2(size.x - 64, 48)
|
||
display_root.add_child(heading)
|
||
var beats := [
|
||
[tr_text("1 组建阵线", "1 BUILD THE LINE"), tr_text("选择一名前排与一名后排。\n前排承压,后排输出或治疗。", "Choose one front and one rear.\nThe front holds; the rear attacks or heals.")],
|
||
[tr_text("2 观察战斗", "2 WATCH THE BATTLE"), tr_text("战斗自动执行你的组队决定。\n看清谁受伤、谁先手、谁提供恢复。", "Combat executes your committed formation.\nRead damage, initiative and recovery.")],
|
||
[tr_text("3 战后调整", "3 ADAPT AFTER BATTLE"), tr_text("根据结果选择装备或补齐队伍。\n不是操作角色,而是改变下一场条件。", "Choose equipment or complete the line.\nChange the next encounter, not each attack.")],
|
||
[tr_text("4 路线与成长", "4 ROUTE AND GROWTH"), tr_text("比较路线压力,再培养一名成员。\n下一战会验证你的选择。", "Compare route pressure, then train one member.\nThe next battle proves that choice.")],
|
||
]
|
||
var gap := 10.0
|
||
var card_width := (size.x - 50.0 - gap * 3.0) / 4.0
|
||
for index in range(beats.size()):
|
||
var card := PanelContainer.new()
|
||
card.name = "IntroductionBeat%d" % (index + 1)
|
||
card.position = Vector2(25.0 + float(index) * (card_width + gap), 88)
|
||
card.size = Vector2(card_width, size.y - 162)
|
||
card.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(card)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 8)
|
||
card.add_child(box)
|
||
box.add_child(centered_label(beats[index][0], 14, COLOR_INK))
|
||
box.add_child(centered_label(beats[index][1], 10, Color("#4c402d")))
|
||
var back := game_button("IntroductionBackButton", tr_text("返回主菜单", "Back to Main Menu"), back_to_main_menu, false)
|
||
back.position = Vector2(size.x * 0.35, size.y - 62)
|
||
back.size = Vector2(size.x * 0.30, 48)
|
||
display_root.add_child(back)
|
||
|
||
|
||
func render_settings() -> void:
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.48))
|
||
var panel := PanelContainer.new()
|
||
panel.name = "SettingsScreen"
|
||
panel.position = Vector2(size.x * 0.10, 14)
|
||
panel.size = Vector2(size.x * 0.80, size.y - 28)
|
||
panel.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 7)
|
||
panel.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("设置", "SETTINGS") + " ◆", 22, COLOR_INK))
|
||
box.add_child(setting_button("SettingsLocaleButton", tr_text("语言", "Language"), tr_text("简体中文", "English"), tr_text("切换全部界面文字", "Switch all interface text"), toggle_locale))
|
||
box.add_child(setting_button("SettingsTextScaleButton", tr_text("文字大小", "Text Size"), "130%" if text_scale > 1.0 else "100%", tr_text("放大说明与按钮文字", "Enlarge labels and button text"), toggle_text_scale))
|
||
box.add_child(setting_button("SettingsMotionButton", tr_text("动态效果", "Motion"), tr_text("减少动态", "Reduced") if reduced_motion else tr_text("标准", "Standard"), tr_text("减少循环动作与弹跳", "Reduce looping motion and bounce"), toggle_motion))
|
||
box.add_child(setting_button("SettingsMuteButton", tr_text("声音", "Sound"), tr_text("静音", "Muted") if muted else tr_text("开启", "On"), tr_text("控制战斗与确认音效", "Control battle and confirmation cues"), toggle_mute))
|
||
box.add_child(game_button("SettingsBackButton", tr_text("返回主菜单", "Back to Main Menu"), back_to_main_menu, false))
|
||
|
||
|
||
func setting_button(node_name: String, category: String, value: String, explanation: String, callback: Callable) -> Button:
|
||
var button := game_button(node_name, "%s %s\n%s" % [category, value, explanation], callback, false)
|
||
button.custom_minimum_size.y = 51
|
||
button.add_theme_font_size_override("font_size", scaled(11))
|
||
return button
|
||
|
||
|
||
func render_hud(context: String) -> void:
|
||
var bar := PanelContainer.new()
|
||
bar.name = "GameHUD"
|
||
bar.position = Vector2(12, 10)
|
||
bar.size = Vector2(size.x - 24, 54)
|
||
bar.add_theme_stylebox_override("panel", lacquer_style(Color(0.045, 0.075, 0.14, 0.94), COLOR_GOLD, 2))
|
||
display_root.add_child(bar)
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 6)
|
||
bar.add_child(row)
|
||
var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home)
|
||
row.add_child(home)
|
||
var identity := VBoxContainer.new()
|
||
identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(identity)
|
||
identity.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 15, COLOR_WHITE))
|
||
identity.add_child(game_label(context, 10, COLOR_TEAL))
|
||
var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart)
|
||
row.add_child(restart)
|
||
row.add_child(icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute))
|
||
|
||
|
||
func render_first_mission() -> void:
|
||
render_gameful_yard_stage(true)
|
||
|
||
|
||
func render_recruit_selection() -> void:
|
||
render_gameful_recruit_rally(false)
|
||
|
||
|
||
func render_gameful_recruit_rally(review_mode: bool) -> void:
|
||
yard_preview_sprites.clear()
|
||
yard_threat_sprite = null
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
|
||
|
||
var top_shade := ColorRect.new()
|
||
top_shade.name = "RecruitRallyTopShade"
|
||
top_shade.position = Vector2.ZERO
|
||
top_shade.size = Vector2(size.x, 58)
|
||
top_shade.color = Color(0.015, 0.035, 0.075, 0.80)
|
||
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(top_shade)
|
||
var identity := game_label(
|
||
tr_text("公会庭院 · 队伍顺序", "GUILD YARD · COMPANY ORDER")
|
||
if review_mode
|
||
else tr_text("公会庭院 · 集结队伍", "GUILD YARD · RALLY COMPANY"),
|
||
15,
|
||
COLOR_WHITE
|
||
)
|
||
identity.name = "FormationReviewIdentity" if review_mode else "RecruitSelectionIdentity"
|
||
identity.position = Vector2(18, 8)
|
||
identity.size = Vector2(size.x * 0.55, 42)
|
||
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
display_root.add_child(identity)
|
||
var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home)
|
||
home.position = Vector2(size.x - 184, 7)
|
||
home.size = Vector2(52, 44)
|
||
display_root.add_child(home)
|
||
var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart)
|
||
restart.position = Vector2(size.x - 126, 7)
|
||
restart.size = Vector2(52, 44)
|
||
display_root.add_child(restart)
|
||
var mute := icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)
|
||
mute.position = Vector2(size.x - 68, 7)
|
||
mute.size = Vector2(52, 44)
|
||
display_root.add_child(mute)
|
||
|
||
var order := ordered_selected_recruits()
|
||
if review_mode:
|
||
order.assign([front_choice, rear_choice])
|
||
var context_text := tr_text("集结 %d / 2 · 直接点击庭院中的成员\n你只选人;系统会自动安排顺序。" % selected_recruits.size(), "RALLY %d / 2 · TAP A RECRUIT IN THE YARD\nChoose people; the system assigns their order." % selected_recruits.size())
|
||
if selected_recruits.size() == 1:
|
||
context_text = tr_text("集结 1 / 2 · %s 已响应\n再点击一名成员;再次点击已选成员可移出。" % candidate_name(order[0]), "RALLY 1 / 2 · %s ANSWERED\nTap one more Recruit; tap the selected Recruit to remove." % candidate_name(order[0]))
|
||
elif selected_recruits.size() == 2:
|
||
context_text = tr_text("队伍已组成 · 系统顺序:%s → %s\n要换人,请先点击一名已选成员。" % [candidate_name(order[0]), candidate_name(order[1])], "COMPANY READY · SYSTEM ORDER: %s -> %s\nTo replace someone, first tap a selected Recruit." % [candidate_name(order[0]), candidate_name(order[1])])
|
||
if review_mode:
|
||
context_text = tr_text("系统已排好行动顺序 · 尚未出发\n1 先行 %s → 2 支援 %s" % [candidate_name(front_choice), candidate_name(rear_choice)], "SYSTEM ORDER READY · NOT DEPARTED\n1 Lead %s -> 2 Support %s" % [candidate_name(front_choice), candidate_name(rear_choice)])
|
||
if not notice.is_empty() and not review_mode and selected_recruits.size() < 2:
|
||
context_text = notice + "\n" + tr_text("直接点击角色可继续调整。", "Tap a Recruit directly to keep adjusting.")
|
||
var context := game_label(context_text, 12, COLOR_WHITE)
|
||
context.name = "RecruitSelectionGuide" if not review_mode else "FormationOrderGuide"
|
||
context.position = Vector2(18, 68)
|
||
context.size = Vector2(minf(size.x * 0.57, 490.0), 62)
|
||
context.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
context.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.88), COLOR_GOLD, 2))
|
||
display_root.add_child(context)
|
||
var count_label := game_label(tr_text("已选 %d / 2" % selected_recruits.size(), "%d / 2 SELECTED" % selected_recruits.size()), 13, COLOR_GOLD_LIGHT)
|
||
count_label.name = "RecruitSelectionCount"
|
||
count_label.position = Vector2(size.x * 0.59, 74)
|
||
count_label.size = Vector2(size.x * 0.18, 42)
|
||
count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
count_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
display_root.add_child(count_label)
|
||
|
||
var threat_size := clampf(size.y * 0.24, 94.0, 152.0)
|
||
var threat_position := Vector2(size.x - threat_size - 22, 76)
|
||
yard_threat_sprite = actor_at(animation_texture("Riftblade", "idle", yard_preview_clock), threat_position, Vector2(threat_size, threat_size), "RecruitRallyThreat")
|
||
yard_threat_sprite.modulate = Color(1.0, 0.80, 0.86, 0.86)
|
||
var threat_label := centered_label(tr_text("裂口逼近", "BREACH CLOSING"), 9, Color("#ffd2b1"))
|
||
threat_label.name = "RecruitRallyThreatLabel"
|
||
threat_label.position = Vector2(threat_position.x - 2, threat_position.y + threat_size * 0.76)
|
||
threat_label.size = Vector2(threat_size + 4, 30)
|
||
threat_label.add_theme_stylebox_override("normal", lacquer_style(Color(0.10, 0.025, 0.045, 0.86), COLOR_CRIMSON, 1))
|
||
display_root.add_child(threat_label)
|
||
|
||
var actor_height := clampf(size.y * 0.33, 112.0, 200.0)
|
||
var candidate_spacing := minf(size.x * 0.132, actor_height * 0.88)
|
||
var candidate_start := maxf(24.0, size.x * 0.035)
|
||
var stage_bottom := size.y - 52.0
|
||
var rally_start := size.x - actor_height * 2.08 - 84.0
|
||
for index in range(AUTO_FORMATION_ORDER.size()):
|
||
var profession: String = AUTO_FORMATION_ORDER[index]
|
||
var selected := order.has(profession)
|
||
var order_index := order.find(profession)
|
||
var candidate_position := Vector2(candidate_start + candidate_spacing * index, stage_bottom - actor_height)
|
||
var actor_position := candidate_position
|
||
if selected:
|
||
actor_position = Vector2(
|
||
rally_start + actor_height * 0.88 * (float(order_index) if order.size() > 1 else 0.5),
|
||
stage_bottom - actor_height - (8.0 if order_index == 0 else 0.0)
|
||
)
|
||
var rally_mark := ColorRect.new()
|
||
rally_mark.name = "RallyMark_%s" % profession
|
||
rally_mark.position = Vector2(actor_position.x + actor_height * 0.14, stage_bottom - 9)
|
||
rally_mark.size = Vector2(actor_height * 0.72, 5)
|
||
rally_mark.color = COLOR_GOLD_LIGHT
|
||
rally_mark.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(rally_mark)
|
||
var actor := actor_at(animation_texture(profession, "idle", yard_preview_clock), actor_position, Vector2(actor_height, actor_height), "RallyActor_%s" % profession)
|
||
actor.set_meta("profession", profession)
|
||
actor.set_meta("selected", selected)
|
||
actor.set_meta("order_index", order_index)
|
||
var capacity_blocked := selected_recruits.size() >= 2 and not selected
|
||
if capacity_blocked or review_mode and not selected:
|
||
actor.modulate = Color(0.48, 0.52, 0.58, 0.70)
|
||
yard_preview_sprites[profession] = actor
|
||
|
||
var target := Button.new()
|
||
target.name = "Recruit_%s" % profession
|
||
target.position = actor_position
|
||
target.size = Vector2(actor_height, actor_height)
|
||
target.tooltip_text = "%s · %s" % [candidate_name(profession), candidate_role(profession)]
|
||
target.focus_mode = Control.FOCUS_ALL
|
||
target.disabled = review_mode or capacity_blocked
|
||
target.add_theme_stylebox_override("normal", recruit_actor_target_style(selected, false, false))
|
||
target.add_theme_stylebox_override("hover", recruit_actor_target_style(selected, true, false))
|
||
target.add_theme_stylebox_override("pressed", recruit_actor_target_style(true, true, false))
|
||
target.add_theme_stylebox_override("focus", recruit_actor_target_style(selected, true, false))
|
||
target.add_theme_stylebox_override("disabled", recruit_actor_target_style(false, false, true))
|
||
target.pressed.connect(toggle_recruit.bind(profession), CONNECT_DEFERRED)
|
||
display_root.add_child(target)
|
||
|
||
if selected:
|
||
var sigil := centered_label("◆", 20, COLOR_GOLD_LIGHT)
|
||
sigil.name = "SelectedSigil_%s" % profession
|
||
sigil.position = Vector2(actor_position.x + actor_height * 0.36, actor_position.y - 3)
|
||
sigil.size = Vector2(actor_height * 0.28, 34)
|
||
sigil.add_theme_color_override("font_outline_color", COLOR_INK)
|
||
sigil.add_theme_constant_override("outline_size", 6)
|
||
display_root.add_child(sigil)
|
||
|
||
var actor_label := candidate_name(profession) + " · " + candidate_role_short(profession)
|
||
if selected and order.size() == 2:
|
||
actor_label = tr_text("%d %s · %s" % [order_index + 1, "先行" if order_index == 0 else "支援", candidate_name(profession)], "%d %s · %s" % [order_index + 1, "LEAD" if order_index == 0 else "SUPPORT", candidate_name(profession)])
|
||
elif selected:
|
||
actor_label = tr_text("已集结 · %s" % candidate_name(profession), "RALLIED · %s" % candidate_name(profession))
|
||
elif review_mode:
|
||
actor_label = tr_text("留守 · %s" % candidate_name(profession), "STANDBY · %s" % candidate_name(profession))
|
||
elif capacity_blocked:
|
||
actor_label = tr_text("队伍已满 · %s" % candidate_name(profession), "FULL · %s" % candidate_name(profession))
|
||
var nameplate := centered_label(actor_label, 9, COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
nameplate.name = "RallyLabel_%s" % profession
|
||
nameplate.position = Vector2(actor_position.x, stage_bottom - 32)
|
||
nameplate.size = Vector2(actor_height, 31)
|
||
nameplate.add_theme_stylebox_override("normal", lacquer_style(Color(0.025, 0.045, 0.09, 0.86), COLOR_GOLD if selected else COLOR_TEAL, 2 if selected else 1))
|
||
display_root.add_child(nameplate)
|
||
|
||
if review_mode:
|
||
var revise := game_button("ReviseRecruitSelectionButton", tr_text("调整成员", "Revise Recruits"), revise_recruit_selection, false)
|
||
revise.position = Vector2(18, size.y - 62)
|
||
revise.size = Vector2(minf(188.0, size.x * 0.24), 50)
|
||
display_root.add_child(revise)
|
||
var commit := game_button("CommitFormationButton", tr_text("按此顺序出发", "Depart in This Order"), commit_formation, true)
|
||
commit.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
|
||
commit.size = Vector2(minf(222.0, size.x * 0.28), 54)
|
||
commit.disabled = not formation_is_valid() or formation_commit_count > 0
|
||
display_root.add_child(commit)
|
||
else:
|
||
var review := game_button("ReviewFormationButton", tr_text("确认系统顺序", "Review System Order"), review_automatic_formation, true)
|
||
review.position = Vector2(size.x - minf(222.0, size.x * 0.28) - 18, size.y - 66)
|
||
review.size = Vector2(minf(222.0, size.x * 0.28), 54)
|
||
review.disabled = selected_recruits.size() != 2
|
||
review.visible = selected_recruits.size() == 2
|
||
display_root.add_child(review)
|
||
|
||
|
||
func recruit_actor_target_style(selected: bool, hover: bool, disabled: bool) -> StyleBoxFlat:
|
||
var fill := Color(0.02, 0.05, 0.10, 0.04)
|
||
var border := Color(0.0, 0.0, 0.0, 0.0)
|
||
var width := 0
|
||
if disabled:
|
||
fill = Color(0.02, 0.03, 0.05, 0.12)
|
||
elif hover:
|
||
fill = Color(0.02, 0.11, 0.14, 0.18)
|
||
border = COLOR_GOLD_LIGHT if selected else COLOR_TEAL
|
||
width = 2
|
||
elif selected:
|
||
fill = Color(0.12, 0.09, 0.02, 0.04)
|
||
var style := lacquer_style(fill, border, width)
|
||
style.set_corner_radius_all(16)
|
||
style.shadow_size = 0
|
||
return style
|
||
|
||
|
||
func render_recruit_card(profession: String, card_position: Vector2, card_size: Vector2) -> void:
|
||
var selected := selected_recruits.has(profession)
|
||
var full := selected_recruits.size() >= 2 and not selected
|
||
var role_color := Color("#7fb5ff") if profession in ["Hearthguard", "Wayrunner"] else Color("#70d6b0")
|
||
var panel := PanelContainer.new()
|
||
panel.name = "RecruitCard_%s" % profession
|
||
panel.position = card_position
|
||
panel.size = card_size
|
||
panel.add_theme_stylebox_override("panel", candidate_style(selected, false, role_color))
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 1)
|
||
panel.add_child(box)
|
||
var portrait := TextureRect.new()
|
||
portrait.name = "RecruitPortrait_%s" % profession
|
||
portrait.texture = ACTORS[profession]
|
||
portrait.custom_minimum_size = Vector2(0, 98)
|
||
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
box.add_child(portrait)
|
||
box.add_child(centered_label(("✓ " if selected else "") + candidate_name(profession), 14, COLOR_GOLD_LIGHT if selected else COLOR_WHITE))
|
||
box.add_child(centered_label(candidate_role_short(profession), 10, COLOR_PARCHMENT))
|
||
box.add_child(centered_label(recruit_behavior_short(profession), 8, COLOR_PARCHMENT))
|
||
var label := tr_text("移出", "Remove") if selected else (tr_text("小队已满", "Full") if full else tr_text("选择", "Choose"))
|
||
var choose := game_button("Recruit_%s" % profession, label, toggle_recruit.bind(profession), selected)
|
||
choose.custom_minimum_size.y = 46
|
||
choose.disabled = full
|
||
box.add_child(choose)
|
||
|
||
|
||
func render_role_selection(selecting_front: bool) -> void:
|
||
add_full_wash(Color(0.015, 0.025, 0.055, 0.16))
|
||
var slot_label := tr_text("前排", "FRONT") if selecting_front else tr_text("后排", "REAR")
|
||
render_hud(tr_text("组建小队 · 选择%s" % slot_label, "Build the Company · Choose %s" % slot_label))
|
||
var choices: Array[String] = []
|
||
choices.assign(["Hearthguard", "Wayrunner"] if selecting_front else ["Farstring", "Kindhand"])
|
||
var selected := front_choice if selecting_front else rear_choice
|
||
var guide := PanelContainer.new()
|
||
guide.name = "FrontSelectionGuide" if selecting_front else "RearSelectionGuide"
|
||
guide.position = Vector2(18, 78)
|
||
guide.size = Vector2(208, size.y - 96)
|
||
guide.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(guide)
|
||
var guide_box := VBoxContainer.new()
|
||
guide_box.add_theme_constant_override("separation", 7)
|
||
guide.add_child(guide_box)
|
||
guide_box.add_child(game_label(tr_text("第 1 步 / 2", "STEP 1 / 2") if selecting_front else tr_text("第 2 步 / 2", "STEP 2 / 2"), 11, Color("#7a5b1c")))
|
||
guide_box.add_child(game_label(tr_text("谁站在最前面?", "Who holds the line?") if selecting_front else tr_text("谁在后方支援?", "Who supports from the rear?"), 19, COLOR_INK))
|
||
guide_box.add_child(game_label(tr_text("敌人会先攻击前排。选择更稳定的格挡,或更灵活的闪避反击。", "The enemy strikes the front first. Choose steady blocking or evasive counterplay.") if selecting_front else tr_text("后排不会承受第一击。选择提前削弱敌人,或在受击后恢复前排。", "The rear avoids the first strike. Choose early damage or recovery after impact."), 11, Color("#4c402d")))
|
||
guide_box.add_child(game_label(tr_text("没有默认答案。点选后仍可替换。", "Nothing is preselected. You can still replace your choice."), 10, Color("#7a5b1c")))
|
||
|
||
for index in range(choices.size()):
|
||
render_role_choice_card(choices[index], Vector2(242 + index * 196, 78), Vector2(180, size.y - 96), selecting_front)
|
||
|
||
var action := PanelContainer.new()
|
||
action.name = "SelectionAction"
|
||
action.position = Vector2(638, 78)
|
||
action.size = Vector2(size.x - 656, size.y - 96)
|
||
action.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2))
|
||
display_root.add_child(action)
|
||
var action_box := VBoxContainer.new()
|
||
action_box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
action_box.add_theme_constant_override("separation", 8)
|
||
action.add_child(action_box)
|
||
action_box.add_child(centered_label(tr_text("当前选择", "CURRENT CHOICE"), 12, COLOR_TEAL))
|
||
action_box.add_child(centered_label(tr_text("尚未选择", "Not selected") if selected.is_empty() else "✓ " + candidate_name(selected), 16, COLOR_WHITE))
|
||
if not selected.is_empty():
|
||
action_box.add_child(centered_label(formation_contribution(selected), 10, COLOR_PARCHMENT))
|
||
var next_name := "ContinueRearSelectionButton" if selecting_front else "ReviewFormationButton"
|
||
var next_label := tr_text("继续选择后排", "Continue to Rear") if selecting_front else tr_text("检查完整阵型", "Review Formation")
|
||
var next_callback := continue_to_rear_selection if selecting_front else review_formation
|
||
var next_button := game_button(next_name, next_label, next_callback, true)
|
||
next_button.custom_minimum_size.y = 54
|
||
next_button.disabled = selected.is_empty()
|
||
action_box.add_child(next_button)
|
||
if not selecting_front:
|
||
action_box.add_child(game_button("BackToFrontSelectionButton", tr_text("返回修改前排", "Back to Front"), open_front_selection, false))
|
||
|
||
|
||
func render_role_choice_card(profession: String, card_position: Vector2, card_size: Vector2, selecting_front: bool) -> void:
|
||
var selected := (front_choice if selecting_front else rear_choice) == profession
|
||
var slot_color := Color("#7fb5ff") if selecting_front else Color("#70d6b0")
|
||
var panel := PanelContainer.new()
|
||
panel.name = "RoleCard_%s" % profession
|
||
panel.position = card_position
|
||
panel.size = card_size
|
||
panel.add_theme_stylebox_override("panel", candidate_style(selected, false, slot_color))
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 2)
|
||
panel.add_child(box)
|
||
var portrait := TextureRect.new()
|
||
portrait.name = "RolePortrait_%s" % profession
|
||
portrait.texture = ACTORS[profession]
|
||
portrait.custom_minimum_size = Vector2(0, 116)
|
||
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
box.add_child(portrait)
|
||
box.add_child(centered_label(("✓ " if selected else "") + candidate_name(profession), 15, COLOR_GOLD_LIGHT if selected else COLOR_WHITE))
|
||
box.add_child(centered_label(candidate_role(profession), 9, COLOR_PARCHMENT))
|
||
var button_name := ("Front_" if selecting_front else "Rear_") + profession
|
||
var callback: Callable = choose_front_candidate.bind(profession) if selecting_front else choose_rear_candidate.bind(profession)
|
||
var choose := game_button(button_name, tr_text("已选择", "Selected") if selected else tr_text("选择", "Choose"), callback, selected)
|
||
choose.custom_minimum_size.y = 46
|
||
box.add_child(choose)
|
||
|
||
|
||
func render_formation_review() -> void:
|
||
render_gameful_recruit_rally(true)
|
||
|
||
|
||
func render_formation_review_card(slot: String, profession: String, card_position: Vector2, card_size: Vector2) -> void:
|
||
var color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0")
|
||
var panel := PanelContainer.new()
|
||
panel.name = "Formation_%s" % slot
|
||
panel.position = card_position
|
||
panel.size = card_size
|
||
panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.92), color, 3))
|
||
display_root.add_child(panel)
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 5)
|
||
panel.add_child(row)
|
||
var portrait := TextureRect.new()
|
||
portrait.texture = ACTORS.get(profession)
|
||
portrait.custom_minimum_size = Vector2(112, 150)
|
||
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_child(portrait)
|
||
var copy := VBoxContainer.new()
|
||
copy.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(copy)
|
||
copy.add_child(game_label(tr_text("系统先行位", "SYSTEM LEAD") if slot == "front" else tr_text("系统第二位", "SYSTEM SECOND"), 10, color))
|
||
copy.add_child(game_label(candidate_name(profession), 17, COLOR_WHITE))
|
||
copy.add_child(game_label(candidate_role_short(profession), 11, COLOR_GOLD_LIGHT))
|
||
copy.add_child(game_label(formation_position_contribution(profession, slot), 9, COLOR_PARCHMENT))
|
||
|
||
|
||
func render_yard() -> void:
|
||
add_full_wash(Color(0.015, 0.025, 0.055, 0.12))
|
||
render_hud(tr_text("公会庭院 · 组建两人小队", "Guild Yard · Build a two-person squad"))
|
||
var direction := PanelContainer.new()
|
||
direction.name = "YardDirection"
|
||
direction.position = Vector2(22, 76)
|
||
direction.size = Vector2(min(390.0, size.x * 0.44), 66)
|
||
direction.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), COLOR_GOLD, 2))
|
||
var direction_box := VBoxContainer.new()
|
||
direction_box.add_theme_constant_override("separation", 1)
|
||
direction.add_child(direction_box)
|
||
direction_box.add_child(game_label(tr_text("组建第一支两人小队", "Build your first two-person squad"), 18, COLOR_WHITE))
|
||
direction_box.add_child(game_label(tr_text("先选蓝色前排,再选绿色后排;可随时替换。", "Choose a blue front, then a green rear. You can replace either."), 11, COLOR_PARCHMENT))
|
||
display_root.add_child(direction)
|
||
|
||
var stage_bottom := size.y - 98.0
|
||
var actor_height: float = clampf(size.y * 0.29, 106.0, 210.0)
|
||
render_yard_slot("front", front_choice, Vector2(size.x * 0.39, stage_bottom - actor_height), actor_height)
|
||
render_yard_slot("rear", rear_choice, Vector2(size.x * 0.57, stage_bottom - actor_height * 0.92), actor_height * 0.92)
|
||
|
||
var candidate_dock := HBoxContainer.new()
|
||
candidate_dock.name = "CandidateLine"
|
||
candidate_dock.position = Vector2(18, size.y - 78)
|
||
candidate_dock.size = Vector2(size.x * 0.69, 62)
|
||
candidate_dock.add_theme_constant_override("separation", 6)
|
||
display_root.add_child(candidate_dock)
|
||
for profession in ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]:
|
||
candidate_dock.add_child(candidate_button(profession))
|
||
|
||
var status := game_label(notice if not notice.is_empty() else tr_text("蓝色=前排 绿色=后排", "Blue = front Green = rear"), 11, COLOR_WHITE)
|
||
status.name = "YardStatus"
|
||
status.position = Vector2(22, size.y - 110)
|
||
status.size = Vector2(size.x * 0.62, 28)
|
||
status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
display_root.add_child(status)
|
||
var depart := game_button("DepartButton", tr_text("出发:第一次远征", "Depart: First Expedition"), depart, true)
|
||
depart.position = Vector2(size.x * 0.71, size.y - 78)
|
||
depart.size = Vector2(size.x * 0.275, 62)
|
||
depart.disabled = front_choice.is_empty() or rear_choice.is_empty()
|
||
display_root.add_child(depart)
|
||
|
||
|
||
func render_yard_slot(slot: String, profession: String, actor_position: Vector2, actor_height: float) -> void:
|
||
var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0")
|
||
var label_text := tr_text("前排", "FRONT") if slot == "front" else tr_text("后排", "REAR")
|
||
if profession.is_empty():
|
||
var empty := centered_label("+\n" + label_text, 18, COLOR_WHITE)
|
||
empty.name = "Empty_%s" % slot
|
||
empty.position = actor_position + Vector2(actor_height * 0.16, actor_height * 0.18)
|
||
empty.size = Vector2(actor_height * 0.68, actor_height * 0.62)
|
||
empty.add_theme_stylebox_override("normal", dashed_slot_style(slot_color))
|
||
display_root.add_child(empty)
|
||
return
|
||
actor_at(ACTORS[profession], actor_position, Vector2(actor_height, actor_height), "Yard_%s" % slot)
|
||
var plaque := centered_label(label_text + " · " + candidate_name(profession), 11, COLOR_WHITE)
|
||
plaque.position = Vector2(actor_position.x - actor_height * 0.05, actor_position.y + actor_height * 0.76)
|
||
plaque.size = Vector2(actor_height * 1.1, 34)
|
||
plaque.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), slot_color, 2))
|
||
display_root.add_child(plaque)
|
||
|
||
|
||
func candidate_button(profession: String) -> Button:
|
||
var data: Dictionary = CANDIDATES[profession]
|
||
var selected := front_choice == profession or rear_choice == profession
|
||
var slot: String = data["slot"]
|
||
var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0")
|
||
var button := Button.new()
|
||
button.name = "Candidate_%s" % profession
|
||
var role := tr_text("前", "F") if slot == "front" else tr_text("后", "R")
|
||
button.text = ("✓ " if selected else "") + role + " · " + candidate_name(profession) + "\n" + candidate_role_short(profession)
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.size_flags_stretch_ratio = 1.0
|
||
button.custom_minimum_size = Vector2(104, 60)
|
||
button.add_theme_font_size_override("font_size", scaled(10))
|
||
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
button.add_theme_color_override("font_hover_color", COLOR_WHITE)
|
||
button.add_theme_color_override("font_pressed_color", COLOR_WHITE)
|
||
button.add_theme_stylebox_override("normal", candidate_style(selected, false, slot_color))
|
||
button.add_theme_stylebox_override("hover", candidate_style(selected, true, slot_color))
|
||
button.add_theme_stylebox_override("pressed", candidate_style(true, true, slot_color))
|
||
button.pressed.connect(func(): choose_candidate(profession), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func render_battle() -> void:
|
||
if departure_transition_active and page == AppPage.BATTLE:
|
||
render_departure_transition()
|
||
return
|
||
add_full_wash(Color(0.015, 0.025, 0.055, 0.1))
|
||
var second_cycle := page == AppPage.SECOND_BATTLE
|
||
var third_cycle := page == AppPage.THIRD_BATTLE
|
||
var fourth_cycle := page == AppPage.FOURTH_BATTLE
|
||
var fifth_cycle := page == AppPage.FIFTH_BATTLE
|
||
var later_cycle := third_cycle or fourth_cycle or fifth_cycle
|
||
var members := visible_member_ids()
|
||
var visible_front := visible_member_id(0)
|
||
var visible_rear := visible_member_id(1)
|
||
var visible_third := visible_member_id(2)
|
||
var battle_title := tr_text("第五次远征 · 双压关隘", "Fifth Expedition · Twin-Pressure Pass") if fifth_cycle else (tr_text("第四次远征 · %s" % route_name(route_choice), "Fourth Expedition · %s" % route_name(route_choice)) if fourth_cycle else (tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))))
|
||
render_hud(battle_title)
|
||
var world_bottom := size.y - 58.0
|
||
var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0)
|
||
var actor_width: float = actor_height
|
||
var contact_reached := battle_phase > 0 or reduced_motion
|
||
front_base_position = Vector2(size.x * (0.39 if contact_reached else 0.18), world_bottom - actor_height)
|
||
rear_base_position = Vector2(size.x * (0.26 if contact_reached else 0.08), world_bottom - actor_height * 0.92)
|
||
added_rear_base_position = Vector2(size.x * (0.12 if contact_reached else -0.06), world_bottom - actor_height * 0.86)
|
||
enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84)
|
||
front_sprite = actor_at(animation_texture(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor")
|
||
rear_sprite = actor_at(animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor")
|
||
front_sprite.set_meta("member_id", visible_front)
|
||
rear_sprite.set_meta("member_id", visible_rear)
|
||
if later_cycle and members.size() > 2:
|
||
added_rear_sprite = actor_at(animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor")
|
||
added_rear_sprite.set_meta("member_id", visible_third)
|
||
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), session_state.member_health(visible_front), 100, candidate_name(visible_front))
|
||
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 140 if fifth_cycle else (130 if third_cycle else (120 if fourth_cycle else 100)), tr_text("双压守卫", "Twin Warden") if fifth_cycle else (tr_text("路线守卫", "Route Warden") if fourth_cycle else (tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade"))))
|
||
if fifth_cycle or (fourth_cycle and route_choice == "R6-03"):
|
||
var marked_id := session_state.marked_member_id()
|
||
add_health_bar("RearHealth", Vector2(22, 101), Vector2(size.x * 0.20, 12), session_state.member_health(marked_id), 100, candidate_name(marked_id))
|
||
var event := battle_event_text()
|
||
var ribbon := PanelContainer.new()
|
||
ribbon.name = "BattleEvent"
|
||
ribbon.position = Vector2(size.x * 0.27, 116)
|
||
ribbon.size = Vector2(size.x * 0.46, 48)
|
||
ribbon.add_theme_stylebox_override("panel", lacquer_style(Color(0.04, 0.07, 0.13, 0.94), COLOR_GOLD, 2))
|
||
ribbon.add_child(centered_label(event, 12, COLOR_WHITE))
|
||
display_root.add_child(ribbon)
|
||
if second_cycle or ((third_cycle or fourth_cycle or fifth_cycle) and not retained_item.is_empty()):
|
||
var visible_item := retained_item if (third_cycle or fourth_cycle or fifth_cycle) else adaptation_choice
|
||
var loan := centered_label(("▣ " if visible_item == "EQ-002" else "◷ ") + adaptation_name(visible_item), 11, COLOR_GOLD_LIGHT)
|
||
loan.name = "ActiveAdaptation"
|
||
loan.position = Vector2(size.x * 0.02, 116)
|
||
loan.size = Vector2(size.x * 0.23, 48)
|
||
loan.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
|
||
display_root.add_child(loan)
|
||
elif third_cycle or fourth_cycle or fifth_cycle:
|
||
var company := centered_label(tr_text("◆ 三人小队\n弓手 + 治疗", "◆ THREE-PERSON LINE\nArcher + Healer"), 11, COLOR_GOLD_LIGHT)
|
||
company.name = "ActiveCompany"
|
||
company.position = Vector2(size.x * 0.02, 116)
|
||
company.size = Vector2(size.x * 0.23, 48)
|
||
company.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
|
||
display_root.add_child(company)
|
||
|
||
if fifth_cycle and battle_phase == 1:
|
||
if trained_profession() == "Farstring":
|
||
projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.50), COLOR_GOLD_LIGHT)
|
||
else:
|
||
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
|
||
elif fifth_cycle and battle_phase == 3:
|
||
add_signal_label("!", Vector2(size.x * 0.20, size.y * 0.31), COLOR_CRIMSON)
|
||
elif fourth_cycle and battle_phase == 1:
|
||
var cue_x := size.x * (0.20 if route_choice == "R6-03" else 0.61)
|
||
add_signal_label("!", Vector2(cue_x, size.y * 0.31), COLOR_CRIMSON)
|
||
elif third_cycle and battle_phase == 1 and retained_item != "EQ-061":
|
||
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
|
||
elif battle_phase == 1 and not third_cycle and (not second_cycle or adaptation_choice == "EQ-002"):
|
||
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
|
||
elif second_cycle and adaptation_choice == "EQ-061" and battle_phase == 2:
|
||
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
|
||
elif not later_cycle and battle_phase == 3 and rear_choice == "Farstring":
|
||
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
|
||
elif later_cycle:
|
||
for member_id in members:
|
||
if later_battle_animation_state(member_id) == "draw_release":
|
||
projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT)
|
||
break
|
||
render_battle_feedback(actor_width, actor_height)
|
||
update_battle_feedback_visual()
|
||
|
||
|
||
func render_departure_transition() -> void:
|
||
add_background(YARD_BACKGROUND)
|
||
departure_sprites.clear()
|
||
departure_start_positions.clear()
|
||
departure_target_positions.clear()
|
||
departure_threat_sprite = null
|
||
add_full_wash(Color(0.01, 0.025, 0.055, 0.10))
|
||
|
||
var top_shade := ColorRect.new()
|
||
top_shade.name = "DepartureTopShade"
|
||
top_shade.position = Vector2.ZERO
|
||
top_shade.size = Vector2(size.x, 58)
|
||
top_shade.color = Color(0.015, 0.035, 0.075, 0.82)
|
||
top_shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(top_shade)
|
||
var identity := game_label(tr_text("公会庭院 · 队伍出发", "GUILD YARD · COMPANY DEPARTING"), 15, COLOR_WHITE)
|
||
identity.name = "DepartureIdentity"
|
||
identity.position = Vector2(18, 8)
|
||
identity.size = Vector2(size.x * 0.62, 42)
|
||
identity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
display_root.add_child(identity)
|
||
|
||
var callout := game_label(tr_text("钟门已开启\n队伍正在接近裂影刃手", "BELL GATE OPEN\nThe Company is closing on the Riftblade"), 13, COLOR_WHITE)
|
||
callout.name = "DepartureCallout"
|
||
callout.position = Vector2(18, 68)
|
||
callout.size = Vector2(minf(size.x * 0.48, 420.0), 60)
|
||
callout.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
callout.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.86), COLOR_GOLD, 2))
|
||
display_root.add_child(callout)
|
||
|
||
var gate_pulse := centered_label("◆", 28, COLOR_GOLD_LIGHT)
|
||
gate_pulse.name = "DepartureGatePulse"
|
||
gate_pulse.position = Vector2(size.x - 132, size.y * 0.34)
|
||
gate_pulse.size = Vector2(48, 48)
|
||
gate_pulse.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02, 0.92))
|
||
gate_pulse.add_theme_constant_override("outline_size", 5)
|
||
display_root.add_child(gate_pulse)
|
||
var threat_size := clampf(size.y * 0.27, 100.0, 166.0)
|
||
var threat_position := Vector2(size.x - threat_size - 20, size.y * 0.35)
|
||
departure_threat_sprite = actor_at(animation_texture("Riftblade", "idle", departure_transition_clock), threat_position, Vector2(threat_size, threat_size), "DepartureThreat")
|
||
departure_threat_sprite.modulate = Color(1.0, 0.78, 0.84, 0.92)
|
||
|
||
var order: Array[String] = [front_choice, rear_choice]
|
||
var actor_height := clampf(size.y * 0.36, 126.0, 214.0)
|
||
var stage_bottom := size.y - 48.0
|
||
var start_x := size.x - actor_height * 2.18 - 98.0
|
||
var target_x := size.x - actor_height * 1.68
|
||
var duration := REDUCED_DEPARTURE_DURATION if reduced_motion else DEPARTURE_TRANSITION_DURATION
|
||
var progress := 1.0 if reduced_motion else smoothstep(0.0, 1.0, clampf(departure_transition_clock / duration, 0.0, 1.0))
|
||
for index in range(order.size()):
|
||
var member_id: String = order[index]
|
||
var start_position := Vector2(start_x + actor_height * 0.90 * index, stage_bottom - actor_height - (8.0 if index == 0 else 0.0))
|
||
var target_position := Vector2(target_x + actor_height * 0.68 * index, stage_bottom - actor_height - (8.0 if index == 0 else 0.0))
|
||
departure_start_positions[member_id] = start_position
|
||
departure_target_positions[member_id] = target_position
|
||
var actor_position := start_position.lerp(target_position, progress)
|
||
var actor := actor_at(animation_texture(member_id, "idle" if reduced_motion else "advance", departure_transition_clock), actor_position, Vector2(actor_height, actor_height), "DepartureActor_%s" % member_id)
|
||
actor.set_meta("member_id", member_id)
|
||
actor.set_meta("order_index", index)
|
||
departure_sprites[member_id] = actor
|
||
|
||
var path_line := ColorRect.new()
|
||
path_line.name = "DeparturePathLine"
|
||
path_line.position = Vector2(size.x * 0.43, stage_bottom - 8)
|
||
path_line.size = Vector2(size.x * 0.47, 5)
|
||
path_line.color = Color(0.99, 0.70, 0.28, 0.86)
|
||
path_line.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(path_line)
|
||
|
||
|
||
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.53, (size.y - banner_height) * 0.5 + 14.0)
|
||
banner.size = Vector2(size.x * 0.44, 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", 4)
|
||
banner.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("战报 1 / 5", "RESULT 1 / 5") + " ◆", 17, COLOR_INK))
|
||
add_causal_result_lines(box, 1, "Result1CausalLines", {0: "VictoryDiagnosis", 3: "FirstReward"})
|
||
var again := game_button("ContinueAdaptationButton", tr_text("查看下一场威胁", "Read the next threat"), open_adaptation, true)
|
||
again.custom_minimum_size.y = 48
|
||
box.add_child(again)
|
||
var home := game_button("VictoryHomeButton", tr_text("回到主页", "Home"), go_home, false)
|
||
home.custom_minimum_size.y = 46
|
||
box.add_child(home)
|
||
|
||
|
||
func render_adaptation() -> void:
|
||
add_full_wash(Color(0.015, 0.03, 0.07, 0.26))
|
||
render_hud(tr_text("战术借用 · 第二次远征前", "Tactical loan · Before the second expedition"))
|
||
var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0)
|
||
actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(size.x * 0.06, size.y - actor_height - 36), Vector2(actor_height, actor_height), "AdaptFront")
|
||
actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.19, size.y - actor_height * 0.88 - 36), Vector2(actor_height * 0.88, actor_height * 0.88), "AdaptRear")
|
||
actor_at(animation_texture("Riftblade", "attack", 0.35), Vector2(size.x * 0.28, size.y - actor_height * 0.75 - 38), Vector2(actor_height * 0.75, actor_height * 0.75), "AdaptEnemy")
|
||
var threat := PanelContainer.new()
|
||
threat.name = "NextThreat"
|
||
threat.position = Vector2(20, 76)
|
||
threat.size = Vector2(size.x * 0.37, 92)
|
||
threat.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_CRIMSON, 2))
|
||
var threat_copy := VBoxContainer.new()
|
||
threat.add_child(threat_copy)
|
||
threat_copy.add_child(game_label(tr_text("下一场已知威胁", "NEXT KNOWN THREAT"), 10, COLOR_GOLD_LIGHT))
|
||
threat_copy.add_child(game_label(tr_text("老兵的第一击会推开并重创先行者%s" % candidate_name(front_choice), "The veteran's first strike pushes and heavily hits lead %s" % candidate_name(front_choice)), 14, COLOR_WHITE))
|
||
display_root.add_child(threat)
|
||
|
||
var choice_panel := PanelContainer.new()
|
||
choice_panel.name = "AdaptationConsole"
|
||
choice_panel.position = Vector2(size.x * 0.39, 72)
|
||
choice_panel.size = Vector2(size.x * 0.59, size.y - 88)
|
||
choice_panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.94), COLOR_GOLD, 2))
|
||
display_root.add_child(choice_panel)
|
||
var choice_shell := VBoxContainer.new()
|
||
choice_shell.name = "AdaptationChoices"
|
||
choice_shell.add_theme_constant_override("separation", 6)
|
||
choice_panel.add_child(choice_shell)
|
||
choice_shell.add_child(game_label(tr_text("借一件装备,只用于下一场", "Borrow one item for the next battle"), 17, COLOR_WHITE))
|
||
choice_shell.add_child(game_label(adaptation_guidance(), 10, COLOR_TEAL))
|
||
var choices := HBoxContainer.new()
|
||
choices.add_theme_constant_override("separation", 8)
|
||
choice_shell.add_child(choices)
|
||
choices.add_child(adaptation_button("EQ-002", "▣"))
|
||
choices.add_child(adaptation_button("EQ-061", "◷"))
|
||
var begin := game_button("BeginSecondBattle", tr_text("带着这件装备出发", "Depart with this item"), begin_second_battle, true)
|
||
begin.disabled = adaptation_choice.is_empty()
|
||
begin.custom_minimum_size.y = 48
|
||
choice_shell.add_child(begin)
|
||
|
||
|
||
func adaptation_button(id: String, symbol: String) -> Button:
|
||
var selected := adaptation_choice == id
|
||
var button := Button.new()
|
||
button.name = "Adaptation_%s" % id.replace("-", "_")
|
||
button.text = "%s %s\n%s\n%s\n%s\n%s" % [symbol, adaptation_option_title(id), adaptation_reason(id), adaptation_gain(id), adaptation_cost(id), adaptation_foregone(id)]
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
button.clip_text = true
|
||
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.custom_minimum_size = Vector2(210, 142)
|
||
button.add_theme_font_size_override("font_size", scaled(8))
|
||
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
button.add_theme_stylebox_override("normal", candidate_style(selected, false, COLOR_TEAL))
|
||
button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL))
|
||
button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL))
|
||
button.pressed.connect(func(): choose_adaptation(id), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func render_second_result() -> void:
|
||
add_full_wash(Color(0.02, 0.035, 0.075, 0.18))
|
||
render_hud(tr_text("第二次远征完成", "Second Expedition Complete"))
|
||
var actor_height: float = clampf(size.y * 0.30, 100.0, 188.0)
|
||
actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.11, size.y - actor_height - 42), Vector2(actor_height, actor_height), "SecondResultFront")
|
||
actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.27, size.y - actor_height * 0.9 - 42), Vector2(actor_height * 0.9, actor_height * 0.9), "SecondResultRear")
|
||
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.47, size.y - actor_height * 0.54 - 40), Vector2(actor_height * 0.54, actor_height * 0.54), "SecondResultEnemy")
|
||
var result := PanelContainer.new()
|
||
result.name = "SecondResultPanel"
|
||
result.position = Vector2(size.x * 0.53, 76)
|
||
result.size = Vector2(size.x * 0.44, size.y - 96)
|
||
result.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(result)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 4)
|
||
result.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("战报 2 / 5", "RESULT 2 / 5") + " ◆", 16, COLOR_INK))
|
||
add_causal_result_lines(box, 2, "Result2CausalLines", {1: "SecondResultExplanation", 3: "SecondResultFacts"})
|
||
if later_preview_supports_pair():
|
||
var return_button := game_button("OpenGuildReturnButton", tr_text("带着经验返回公会", "Return to the Guild"), open_guild_return, true)
|
||
return_button.custom_minimum_size.y = 44
|
||
box.add_child(return_button)
|
||
else:
|
||
var boundary := centered_label(tr_text("这组搭配已验证到第二战", "This pair preview ends after battle two"), 10, Color("#7a5b1c"))
|
||
boundary.name = "PairPreviewBoundary"
|
||
box.add_child(boundary)
|
||
var home := game_button("SecondResultHomeButton", tr_text("回到主页", "Home"), go_home, false)
|
||
home.custom_minimum_size.y = 44
|
||
box.add_child(home)
|
||
|
||
|
||
func render_guild_return() -> void:
|
||
add_full_wash(Color(0.012, 0.028, 0.06, 0.16))
|
||
render_hud(tr_text("公会庭院 · 第三次远征准备", "Guild Yard · Prepare the third expedition"))
|
||
var actor_height: float = clampf(size.y * 0.29, 98.0, 178.0)
|
||
var preview_order := guild_preview_order()
|
||
var preview_names := ["GuildFront", "GuildRear", "GuildRecruitPreview"]
|
||
for index in range(preview_order.size()):
|
||
var member_id: String = preview_order[index]
|
||
var actor_scale: float = [1.0, 0.9, 0.82][index]
|
||
var actor := actor_at(
|
||
animation_texture(member_id, "idle", victory_clock),
|
||
Vector2(size.x * (0.055 + 0.135 * index), size.y - actor_height * actor_scale - 34),
|
||
Vector2(actor_height * actor_scale, actor_height * actor_scale),
|
||
preview_names[index]
|
||
)
|
||
actor.set_meta("member_id", member_id)
|
||
|
||
var resources := PanelContainer.new()
|
||
resources.name = "GuildResources"
|
||
resources.position = Vector2(18, 74)
|
||
resources.size = Vector2(size.x * 0.35, 76)
|
||
resources.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2))
|
||
display_root.add_child(resources)
|
||
var resource_copy := VBoxContainer.new()
|
||
resources.add_child(resource_copy)
|
||
resource_copy.add_child(game_label(tr_text("带回公会的远征资源", "EXPEDITION RESOURCES RETURNED"), 10, COLOR_TEAL))
|
||
resource_copy.add_child(game_label(tr_text("金币 %d | 小队 2 人\n借用装备:%s" % [coin, adaptation_name(adaptation_choice)], "Coin %d | Company 2\nLoaned item: %s" % [coin, adaptation_name(adaptation_choice)]), 12, COLOR_WHITE))
|
||
|
||
var bench := PanelContainer.new()
|
||
bench.name = "PreparationBench"
|
||
bench.position = Vector2(size.x * 0.40, 72)
|
||
bench.size = Vector2(size.x * 0.58, size.y - 88)
|
||
bench.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.95), COLOR_GOLD, 2))
|
||
display_root.add_child(bench)
|
||
var shell := VBoxContainer.new()
|
||
shell.add_theme_constant_override("separation", 5)
|
||
bench.add_child(shell)
|
||
shell.add_child(game_label(tr_text("队长更强:把金币花在哪?", "THE CAPTAIN IS STRONGER: where should Coin go?"), 16, COLOR_WHITE))
|
||
shell.add_child(game_label(guild_choice_guidance(), 10, COLOR_TEAL))
|
||
var choices := HBoxContainer.new()
|
||
choices.add_theme_constant_override("separation", 7)
|
||
shell.add_child(choices)
|
||
choices.add_child(guild_choice_button("retain_item"))
|
||
choices.add_child(guild_choice_button("recruit_rear"))
|
||
var depart_button := game_button("BeginThirdBattle", tr_text("确认花费并出发", "Confirm spend and depart"), begin_third_battle, true)
|
||
depart_button.disabled = guild_choice.is_empty()
|
||
depart_button.custom_minimum_size.y = 48
|
||
shell.add_child(depart_button)
|
||
|
||
|
||
func guild_choice_button(id: String) -> Button:
|
||
var selected := guild_choice == id
|
||
var button := Button.new()
|
||
button.name = "GuildChoice_%s" % id
|
||
var recruit_id := session_service.mapped_guild_recruit()
|
||
if id == "retain_item":
|
||
button.text = tr_text("▣ 保留已验证装备\n当前理由:第二战已证明%s有效\n得到:继续%s\n代价:12 金币;小队仍为 2 人\n放弃:%s与第三名成员\n确认后:金币 %d → %d;小队 2 → 2" % [adaptation_name(adaptation_choice), adaptation_gain(adaptation_choice).trim_prefix("得到:"), candidate_name(recruit_id), coin, coin - RETAIN_ITEM_COST], "▣ KEEP THE PROVEN ITEM\nWhy now: battle two proved %s\nGain: keep %s\nCost: 12 Coin; Company stays at 2\nGive up: %s and a third member\nOn commit: Coin %d -> %d; Company 2 -> 2" % [adaptation_name(adaptation_choice), adaptation_gain(adaptation_choice).trim_prefix("Gain: "), candidate_name(recruit_id), coin, coin - RETAIN_ITEM_COST])
|
||
else:
|
||
var position := guild_recruit_position(recruit_id)
|
||
button.text = tr_text("+ 招募第三名成员:%s\n当前理由:补上当前阵线缺少的职责\n得到:%s加入%s并重新编队\n代价:14 金币;归还借用装备\n放弃:已验证的%s效果\n确认后:金币 %d → %d;小队 2 → 3" % [candidate_name(recruit_id), candidate_name(recruit_id), position, adaptation_name(adaptation_choice), coin, coin - RECRUIT_REAR_COST], "+ RECRUIT A THIRD MEMBER: %s\nWhy now: fills the current role gap\nGain: %s joins as %s; line re-forms\nCost: 14 Coin; return the loaned item\nGive up: proven %s effect\nOn commit: Coin %d -> %d; Company 2 -> 3" % [candidate_name(recruit_id), candidate_name(recruit_id), position, adaptation_name(adaptation_choice), coin, coin - RECRUIT_REAR_COST])
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
button.clip_text = true
|
||
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.custom_minimum_size = Vector2(215, 148)
|
||
button.add_theme_font_size_override("font_size", scaled(8))
|
||
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
button.add_theme_stylebox_override("normal", candidate_style(selected, false, COLOR_TEAL))
|
||
button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL))
|
||
button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL))
|
||
button.pressed.connect(func(): choose_guild_option(id), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func render_session_result() -> void:
|
||
add_full_wash(Color(0.02, 0.035, 0.075, 0.18))
|
||
render_hud(tr_text("首轮远征完成", "First Expedition Cycle Complete"))
|
||
var actor_height: float = clampf(size.y * 0.29, 98.0, 180.0)
|
||
render_visible_party("Session", Vector2(size.x * 0.05, size.y - actor_height - 38), actor_height)
|
||
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.52 - 36), Vector2(actor_height * 0.52, actor_height * 0.52), "SessionEnemy")
|
||
var result := PanelContainer.new()
|
||
result.name = "SessionResultPanel"
|
||
result.position = Vector2(size.x * 0.52, 74)
|
||
result.size = Vector2(size.x * 0.46, size.y - 92)
|
||
result.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(result)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 5)
|
||
result.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("战报 3 / 5", "RESULT 3 / 5") + " ◆", 16, COLOR_INK))
|
||
add_causal_result_lines(box, 3, "Result3CausalLines", {1: "SessionResultExplanation", 3: "SessionResultFacts"})
|
||
box.add_child(game_button("OpenRouteBoardButton", tr_text("查看下一轮三条路线", "Compare the next three routes"), open_route_board, true))
|
||
box.add_child(game_button("SessionResultHomeButton", tr_text("回到主页", "Home"), go_home, false))
|
||
|
||
|
||
func render_route_board() -> void:
|
||
add_full_wash(Color(0.015, 0.03, 0.07, 0.25))
|
||
render_hud(tr_text("第二轮 · 选择下一条路线", "Second Cycle · Choose the next route"))
|
||
var actor_height: float = clampf(size.y * 0.22, 82.0, 138.0)
|
||
render_visible_party("Route", Vector2(20, size.y - actor_height - 24), actor_height, 80.0)
|
||
var build_panel := PanelContainer.new()
|
||
build_panel.name = "RouteBuildSummary"
|
||
build_panel.position = Vector2(16, 72)
|
||
build_panel.size = Vector2(size.x * 0.30, 86)
|
||
build_panel.add_theme_stylebox_override("panel", lacquer_style(Color(0.03, 0.055, 0.10, 0.94), COLOR_GOLD, 2))
|
||
display_root.add_child(build_panel)
|
||
var build_copy := VBoxContainer.new()
|
||
build_panel.add_child(build_copy)
|
||
build_copy.add_child(game_label(tr_text("上一轮留下的构筑", "BUILD INHERITED FROM LAST CYCLE"), 9, COLOR_TEAL))
|
||
build_copy.add_child(game_label(build_summary_text(), 12, COLOR_WHITE))
|
||
|
||
var board := PanelContainer.new()
|
||
board.name = "RouteMap"
|
||
board.position = Vector2(size.x * 0.32, 72)
|
||
board.size = Vector2(size.x * 0.66, size.y - 88)
|
||
board.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.82), COLOR_GOLD, 2))
|
||
display_root.add_child(board)
|
||
var shell := VBoxContainer.new()
|
||
shell.add_theme_constant_override("separation", 3)
|
||
board.add_child(shell)
|
||
shell.add_child(game_label(tr_text("三条路线都能前进,但当前构筑的应对不同", "All routes advance, but current coverage differs"), 15, COLOR_WHITE))
|
||
shell.add_child(game_label(route_board_guidance(), 9, COLOR_TEAL))
|
||
var route_map := Control.new()
|
||
route_map.name = "RouteNodeMap"
|
||
route_map.custom_minimum_size = Vector2(0, 142)
|
||
route_map.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
shell.add_child(route_map)
|
||
var map_width: float = board.size.x - 22.0
|
||
var node_size := Vector2(150, 108)
|
||
var node_positions := [
|
||
Vector2(6, 24),
|
||
Vector2((map_width - node_size.x) * 0.5, 6),
|
||
Vector2(map_width - node_size.x - 6, 24),
|
||
]
|
||
var route_path := Line2D.new()
|
||
route_path.name = "RoutePath"
|
||
route_path.width = 7.0
|
||
route_path.default_color = Color(0.69, 0.52, 0.24, 0.82)
|
||
route_path.joint_mode = Line2D.LINE_JOINT_ROUND
|
||
route_path.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||
route_path.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||
for route_position in node_positions:
|
||
route_path.add_point(route_position + node_size * 0.5)
|
||
route_map.add_child(route_path)
|
||
for index in range(3):
|
||
var id: String = ["R6-01", "R6-02", "R6-03"][index]
|
||
var node := route_button(id)
|
||
node.position = node_positions[index]
|
||
node.size = node_size
|
||
route_map.add_child(node)
|
||
var review := game_button("ReviewRouteButton", tr_text("查看所选路线并确认", "Review selected route"), open_route_review, true)
|
||
review.disabled = route_choice.is_empty()
|
||
review.custom_minimum_size.y = 48
|
||
shell.add_child(review)
|
||
|
||
|
||
func route_button(id: String) -> Button:
|
||
var selected := route_choice == id
|
||
var button := Button.new()
|
||
button.name = "Route_%s" % id.replace("-", "_")
|
||
button.text = "◆ %s ◆\n%s\n%s\n%s\n%s" % [route_name(id), tr_text("当前理由:%s" % route_coverage_label(id), "Why now: %s" % route_coverage_label(id)), tr_text("得到:+%d 金币" % route_reward(id), "Gain: +%d Coin" % route_reward(id)), tr_text("代价:%s / %s" % [route_exposure(id), route_pressure(id)], "Cost: %s / %s" % [route_exposure(id), route_pressure(id)]), tr_text("放弃:其他路线", "Give up: other routes")]
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
button.clip_text = true
|
||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
button.custom_minimum_size = Vector2(150, 108)
|
||
button.size = Vector2(150, 108)
|
||
button.add_theme_font_size_override("font_size", scaled(8))
|
||
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
button.add_theme_stylebox_override("normal", route_node_style(selected, false, coverage_color(id)))
|
||
button.add_theme_stylebox_override("hover", route_node_style(selected, true, coverage_color(id)))
|
||
button.add_theme_stylebox_override("pressed", route_node_style(true, true, coverage_color(id)))
|
||
button.add_theme_stylebox_override("focus", route_node_style(true, true, coverage_color(id)))
|
||
button.pressed.connect(func(): choose_route(id), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func render_route_review() -> void:
|
||
add_full_wash(Color(0.01, 0.025, 0.06, 0.38))
|
||
render_hud(tr_text("路线确认 · %s" % route_name(route_choice), "Route Review · %s" % route_name(route_choice)))
|
||
var panel := PanelContainer.new()
|
||
panel.name = "RouteReviewPanel"
|
||
panel.position = Vector2(size.x * 0.12, 76)
|
||
panel.size = Vector2(size.x * 0.76, size.y - 96)
|
||
panel.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 6)
|
||
panel.add_child(box)
|
||
box.add_child(centered_label("◆ " + route_name(route_choice) + " ◆", 22, COLOR_INK))
|
||
box.add_child(centered_label(tr_text("已知压力:%s\n暴露:%s 奖励:+%d 金币" % [route_pressure(route_choice), route_exposure(route_choice), route_reward(route_choice)], "Known pressure: %s\nExposure: %s Reward: +%d Coin" % [route_pressure(route_choice), route_exposure(route_choice), route_reward(route_choice)]), 12, Color("#4c402d")))
|
||
var coverage := centered_label(route_coverage_reason(route_choice), 12, Color("#6d5523"))
|
||
coverage.name = "RouteReviewCoverage"
|
||
box.add_child(coverage)
|
||
box.add_child(centered_label(tr_text("确认后锁定当前%s;路线入场不花金币。" % build_summary_short(), "Commit freezes the current %s; route entry costs no Coin." % build_summary_short()), 10, Color("#4c402d")))
|
||
var actions := HBoxContainer.new()
|
||
actions.add_theme_constant_override("separation", 8)
|
||
box.add_child(actions)
|
||
var cancel := game_button("CancelRouteReviewButton", tr_text("返回比较", "Back to compare"), cancel_route_review, false)
|
||
cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
actions.add_child(cancel)
|
||
var commit := game_button("CommitRouteButton", tr_text("锁定路线并进入战斗", "Commit route and enter battle"), commit_route, true)
|
||
commit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
actions.add_child(commit)
|
||
|
||
|
||
func render_second_cycle_result() -> void:
|
||
add_full_wash(Color(0.02, 0.035, 0.075, 0.18))
|
||
render_hud(tr_text("第二轮路线完成", "Second-Cycle Route Complete"))
|
||
var actor_height: float = clampf(size.y * 0.28, 96.0, 176.0)
|
||
render_visible_party("CycleResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height)
|
||
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "CycleResultEnemy")
|
||
var result := PanelContainer.new()
|
||
result.name = "SecondCycleResultPanel"
|
||
result.position = Vector2(size.x * 0.51, 74)
|
||
result.size = Vector2(size.x * 0.47, size.y - 92)
|
||
result.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(result)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 5)
|
||
result.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("战报 4 / 5", "RESULT 4 / 5") + " ◆", 16, COLOR_INK))
|
||
add_causal_result_lines(box, 4, "Result4CausalLines", {1: "SecondCycleExplanation", 3: "SecondCycleFacts"})
|
||
box.add_child(game_button("OpenTrainingButton", tr_text("使用历练印记培养一名成员", "Train one member with the Veteran Mark"), open_training, true))
|
||
box.add_child(game_button("SecondCycleHomeButton", tr_text("回到主页", "Home"), go_home, false))
|
||
|
||
|
||
func render_training() -> void:
|
||
add_full_wash(Color(0.015, 0.03, 0.07, 0.24))
|
||
render_hud(tr_text("第三轮 · 个人历练", "Third Cycle · Individual Training"))
|
||
var slots := member_slots()
|
||
var left_width := size.x * 0.65
|
||
var actor_height: float = clampf(size.y * 0.28, 96.0, 144.0)
|
||
var slot_width: float = (left_width - 42.0) / float(slots.size())
|
||
for index in range(slots.size()):
|
||
var slot: String = slots[index]
|
||
var profession := member_profession(slot)
|
||
var actor_x := 18.0 + slot_width * float(index) + (slot_width - actor_height) * 0.5
|
||
var actor := actor_at(animation_texture(profession, "idle", victory_clock), Vector2(actor_x, 82), Vector2(actor_height, actor_height), "TrainingActor_%s" % slot)
|
||
actor.set_meta("member_id", profession)
|
||
var choice := training_button(slot)
|
||
choice.position = Vector2(20.0 + slot_width * float(index), 205)
|
||
choice.size = Vector2(slot_width - 8.0, 118)
|
||
display_root.add_child(choice)
|
||
|
||
var panel := PanelContainer.new()
|
||
panel.name = "TrainingSummary"
|
||
panel.position = Vector2(size.x * 0.66, 76)
|
||
panel.size = Vector2(size.x * 0.32, size.y - 96)
|
||
panel.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 6)
|
||
panel.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("历练印记 × 1", "VETERAN MARK × 1") + " ◆", 18, COLOR_INK))
|
||
box.add_child(centered_label(tr_text("本轮只能培养一名实际成员。\n等级属于个人,不会分享给全队。", "Train one actual member this cycle.\nLevels belong to individuals, not the Company."), 11, Color("#4c402d")))
|
||
var guidance := centered_label(training_guidance(), 10, Color("#6d5523"))
|
||
guidance.name = "TrainingGuidance"
|
||
box.add_child(guidance)
|
||
var review := game_button("ReviewTrainingButton", tr_text("查看培养效果并确认", "Review the training effect"), open_training_review, true)
|
||
review.disabled = training_choice.is_empty()
|
||
box.add_child(review)
|
||
box.add_child(game_button("TrainingHomeButton", tr_text("回到主页", "Home"), go_home, false))
|
||
|
||
|
||
func training_button(slot: String) -> Button:
|
||
var profession := member_profession(slot)
|
||
var selected := training_choice == slot
|
||
var button := Button.new()
|
||
button.name = "Training_%s" % slot.capitalize()
|
||
button.set_meta("member_id", profession)
|
||
button.text = "%s · %s · %s\nLv.1 → Lv.2\n%s\n%s\n%s\n%s" % [member_slot_label(slot), candidate_name(profession), candidate_role_short(profession), tr_text("得到:%s" % training_effect_short(slot), "Gain: %s" % training_effect_short(slot)), tr_text("代价:消耗唯一历练印记", "Cost: consume the only Training Mark"), tr_text("下一战验证:%s" % training_test_short(slot), "Next battle tests: %s" % training_test_short(slot)), tr_text("放弃:其他成员本轮保持 Lv.1", "Give up: other members stay Lv.1 this cycle")]
|
||
button.focus_mode = Control.FOCUS_ALL
|
||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
button.clip_text = true
|
||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
button.custom_minimum_size = Vector2(146, 128)
|
||
button.add_theme_font_size_override("font_size", scaled(8))
|
||
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
|
||
button.add_theme_stylebox_override("normal", route_node_style(selected, false, training_color(slot)))
|
||
button.add_theme_stylebox_override("hover", route_node_style(selected, true, training_color(slot)))
|
||
button.add_theme_stylebox_override("pressed", route_node_style(true, true, training_color(slot)))
|
||
button.add_theme_stylebox_override("focus", route_node_style(true, true, training_color(slot)))
|
||
button.pressed.connect(func(): choose_training(slot), CONNECT_DEFERRED)
|
||
return button
|
||
|
||
|
||
func render_training_review() -> void:
|
||
add_full_wash(Color(0.01, 0.025, 0.06, 0.38))
|
||
render_hud(tr_text("培养确认 · %s" % candidate_name(member_profession(training_choice)), "Training Review · %s" % candidate_name(member_profession(training_choice))))
|
||
var panel := PanelContainer.new()
|
||
panel.name = "TrainingReviewPanel"
|
||
panel.position = Vector2(size.x * 0.12, 76)
|
||
panel.size = Vector2(size.x * 0.76, size.y - 96)
|
||
panel.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(panel)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 7)
|
||
panel.add_child(box)
|
||
box.add_child(centered_label("◆ %s · Lv.2 ◆" % candidate_name(member_profession(training_choice)), 22, COLOR_INK))
|
||
box.add_child(centered_label("%s · %s" % [member_slot_label(training_choice), candidate_role_short(member_profession(training_choice))], 13, Color("#4c402d")))
|
||
var effect := centered_label(training_effect_detail(training_choice), 13, Color("#6d5523"))
|
||
effect.name = "TrainingReviewEffect"
|
||
box.add_child(effect)
|
||
box.add_child(centered_label(tr_text("放弃:其他成员保持 Lv.1,本轮不获得其历练效果。\n确认前金币、路线与成员等级都不会改变。", "Give up: other members stay Lv.1; their training effects are not gained this cycle.\nCoin, route, and member levels do not change before commit."), 9, Color("#4c402d")))
|
||
var actions := HBoxContainer.new()
|
||
actions.add_theme_constant_override("separation", 8)
|
||
box.add_child(actions)
|
||
var cancel := game_button("CancelTrainingReviewButton", tr_text("返回改选", "Back to revise"), cancel_training_review, false)
|
||
cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
actions.add_child(cancel)
|
||
var commit := game_button("CommitTrainingButton", tr_text("培养并进入第五战", "Train and enter battle five"), commit_training, true)
|
||
commit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
actions.add_child(commit)
|
||
|
||
|
||
func render_third_cycle_result() -> void:
|
||
add_full_wash(Color(0.02, 0.035, 0.075, 0.18))
|
||
render_hud(tr_text("第三轮成长完成", "Third-Cycle Growth Complete"))
|
||
var actor_height: float = clampf(size.y * 0.28, 96.0, 176.0)
|
||
render_visible_party("GrowthResult", Vector2(size.x * 0.05, size.y - actor_height - 36), actor_height)
|
||
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.50 - 34), Vector2(actor_height * 0.50, actor_height * 0.50), "GrowthResultEnemy")
|
||
var result := PanelContainer.new()
|
||
result.name = "ThirdCycleResultPanel"
|
||
result.position = Vector2(size.x * 0.50, 72)
|
||
result.size = Vector2(size.x * 0.48, size.y - 88)
|
||
result.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(result)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 5)
|
||
result.add_child(box)
|
||
box.add_child(centered_label("◆ " + tr_text("首次体验完成", "FIRST SESSION COMPLETE") + " ◆", 16, COLOR_INK))
|
||
add_causal_result_lines(box, 5, "Result5CausalLines", {1: "ThirdCycleExplanation", 3: "ThirdCycleFacts"})
|
||
var levels := centered_label(member_levels_summary(), 8, Color("#6d5523"))
|
||
levels.name = "MemberLevels"
|
||
box.add_child(levels)
|
||
box.add_child(game_button("ContinueAuthoritativeBattleButton", tr_text("继续:裂隙守望战", "Continue: Breach Watch"), request_authoritative_battle, true))
|
||
var result_actions := HBoxContainer.new()
|
||
result_actions.name = "ThirdCycleResultActions"
|
||
result_actions.add_theme_constant_override("separation", 6)
|
||
box.add_child(result_actions)
|
||
var replay := game_button("ReplayGrowthButton", tr_text("重新开始", "Restart"), request_restart, false)
|
||
replay.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
result_actions.add_child(replay)
|
||
var home := game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)
|
||
home.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
result_actions.add_child(home)
|
||
|
||
|
||
func request_authoritative_battle() -> void:
|
||
if page != AppPage.THIRD_CYCLE_RESULT:
|
||
return
|
||
authoritative_battle_requested.emit()
|
||
|
||
|
||
func add_causal_result_lines(container: VBoxContainer, result_index: int, prefix: String, legacy_names: Dictionary = {}) -> void:
|
||
var snapshot := render_snapshot()
|
||
var clauses := causal_result_clauses(result_index, snapshot)
|
||
var labels_zh := ["发生了什么:", "为什么:", "决定造成的差异:", "现在改变了:", "下一步:"]
|
||
var labels_en := ["What happened: ", "Why: ", "Difference made by the decision: ", "Changed now: ", "Next: "]
|
||
if result_index == 1:
|
||
labels_zh[2] = "系统编队造成的差异:"
|
||
labels_en[2] = "Difference made by automatic formation: "
|
||
var labels: Array = labels_zh if locale == "zh_CN" else labels_en
|
||
for index in range(5):
|
||
var line := game_label(labels[index] + clauses[index], 8, COLOR_INK if index < 3 else Color("#6d5523"))
|
||
line.name = "%s_Line%d" % [prefix, index + 1]
|
||
line.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
container.add_child(line)
|
||
if legacy_names.has(index):
|
||
var legacy := game_label(clauses[index], 1, Color(0, 0, 0, 0))
|
||
legacy.name = String(legacy_names[index])
|
||
legacy.visible = false
|
||
legacy.custom_minimum_size = Vector2.ZERO
|
||
container.add_child(legacy)
|
||
|
||
|
||
func causal_result_clauses(result_index: int, snapshot: Dictionary) -> Array[String]:
|
||
var members := snapshot_member_ids(snapshot)
|
||
var lead_id := members[0] if not members.is_empty() else front_choice
|
||
var second_id := members[1] if members.size() > 1 else rear_choice
|
||
var marked_id := String(snapshot["party"].get("marked_id", session_state.marked_member_id()))
|
||
var coin_now := int(snapshot["resources"].get("coin", coin))
|
||
var company_size := members.size()
|
||
var lead_hp := snapshot_member_hp(snapshot, lead_id)
|
||
var marked_hp := snapshot_member_hp(snapshot, marked_id)
|
||
var result_time := snapshot_result_time(snapshot, result_index)
|
||
var clauses: Array[String] = []
|
||
match result_index:
|
||
1:
|
||
clauses = [
|
||
tr_text("系统让%s先行;%s。" % [candidate_name(lead_id), member_result_action(lead_id, lead_id)], "The system sent %s first; %s." % [candidate_name(lead_id), member_result_action(lead_id, lead_id)]),
|
||
tr_text("%s随后%s。" % [candidate_name(second_id), member_result_action(second_id, lead_id)], "%s then %s." % [candidate_name(second_id), member_result_action(second_id, lead_id)]),
|
||
tr_text("职责优先级决定位置;点击顺序不会改变编队。", "Role priority assigned positions; tap order did not change formation."),
|
||
tr_text("%s %d/100;金币 0 → %d(仅一次)。" % [candidate_name(lead_id), lead_hp, coin_now], "%s %d/100 HP; Coin 0 -> %d (once)." % [candidate_name(lead_id), lead_hp, coin_now]),
|
||
tr_text("比较钉阵胫甲与晨钟,选择下一战的安全或节奏。", "Compare Frontpin Greaves and Early Bell for safety or tempo."),
|
||
]
|
||
2:
|
||
var other_item := "EQ-061" if adaptation_choice == "EQ-002" else "EQ-002"
|
||
clauses = [
|
||
tr_text("%s改变了第二战的开局。" % adaptation_name(adaptation_choice), "%s changed the opening of battle two." % adaptation_name(adaptation_choice)),
|
||
second_result_explanation().replace("\n", " "),
|
||
tr_text("选择%s,放弃%s的效果。" % [adaptation_name(adaptation_choice), adaptation_name(other_item)], "Chose %s and gave up %s's effect." % [adaptation_name(adaptation_choice), adaptation_name(other_item)]),
|
||
tr_text("%s %d/100;用时 %.2f 秒;金币 %d。" % [candidate_name(lead_id), lead_hp, result_time, coin_now], "%s %d/100 HP; %.2f seconds; %d Coin." % [candidate_name(lead_id), lead_hp, result_time, coin_now]),
|
||
tr_text("返回公会,比较保留装备与招募%s。" % candidate_name(session_service.mapped_guild_recruit()), "Return to the Guild; compare keeping the item with recruiting %s." % candidate_name(session_service.mapped_guild_recruit())),
|
||
]
|
||
3:
|
||
var recruited := guild_choice == "recruit_rear"
|
||
clauses = [
|
||
tr_text("选择了%s。" % ("招募%s" % candidate_name(added_rear) if recruited else "保留%s" % adaptation_name(retained_item)), "Chose %s." % ("recruit %s" % candidate_name(added_rear) if recruited else "keep %s" % adaptation_name(retained_item))),
|
||
session_result_explanation().replace("\n", " "),
|
||
tr_text("%s;另一方案会%s。" % [("小队扩为三人并重新编队" if recruited else "两人小队保留已验证效果"), ("保留两人装备构筑" if recruited else "归还装备并扩为三人")], "%s; the alternative would %s." % [("Company grew to three and re-formed" if recruited else "The two-person Company kept its proven effect"), ("keep the two-person item build" if recruited else "return the item and grow to three")]),
|
||
tr_text("金币 %d;小队 %d 人;%s %d/100;用时 %.2f 秒。" % [coin_now, company_size, candidate_name(lead_id), lead_hp, result_time], "%d Coin; Company %d; %s %d/100 HP; %.2f seconds." % [coin_now, company_size, candidate_name(lead_id), lead_hp, result_time]),
|
||
tr_text("打开路线板,比较压力、应对与奖励。", "Open the route board; compare pressure, coverage, and reward."),
|
||
]
|
||
4:
|
||
clauses = [
|
||
tr_text("小队通过%s,获得 %d 金币。" % [route_name(route_choice), route_reward(route_choice)], "The Company cleared %s and gained %d Coin." % [route_name(route_choice), route_reward(route_choice)]),
|
||
tr_text("压力是%s;%s" % [route_pressure(route_choice), route_coverage_reason(route_choice)], "Pressure was %s; %s" % [route_pressure(route_choice), route_coverage_reason(route_choice)]),
|
||
tr_text("选择%s,以%s换取 +%d 金币。" % [route_name(route_choice), route_exposure(route_choice), route_reward(route_choice)], "Chose %s, accepting %s for +%d Coin." % [route_name(route_choice), route_exposure(route_choice), route_reward(route_choice)]),
|
||
tr_text("%s %d/100;%s %d/100;金币 %d;用时 %.2f 秒。" % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, result_time], "%s %d/100 HP; %s %d/100 HP; %d Coin; %.2f seconds." % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, result_time]),
|
||
tr_text("用唯一历练印记选择一名当前成员升至 Lv.2。", "Use the one Training Mark to raise one current member to Lv.2."),
|
||
]
|
||
5:
|
||
var trained_id := trained_profession()
|
||
clauses = [
|
||
tr_text("五场远征全部完成;%s升至 Lv.2。" % candidate_name(trained_id), "All five expeditions are complete; %s reached Lv.2." % candidate_name(trained_id)),
|
||
tr_text("%s;公会选择决定队伍结构,%s决定金币与压力。" % [training_result_explanation().replace("\n", " "), route_name(route_choice)], "%s The Guild choice set Company structure, and %s set reward and pressure." % [training_result_explanation().replace("\n", " "), route_name(route_choice)]),
|
||
tr_text("培养%s获得%s;其他成员保持 Lv.1。" % [candidate_name(trained_id), training_effect_short(visible_member_slot(trained_id))], "Training %s gained %s; other members stayed Lv.1." % [candidate_name(trained_id), training_effect_short(visible_member_slot(trained_id))]),
|
||
tr_text("%s %d/100;%s %d/100;金币 %d;小队 %d 人;用时 %.2f 秒。" % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, company_size, result_time], "%s %d/100 HP; %s %d/100 HP; %d Coin; Company %d; %.2f seconds." % [candidate_name(lead_id), lead_hp, candidate_name(marked_id), marked_hp, coin_now, company_size, result_time]),
|
||
tr_text("首次体验到此结束;可重新开始尝试另一组搭配,或返回主菜单。", "The first session ends here; restart with another pair or return to the main menu."),
|
||
]
|
||
return clauses
|
||
|
||
|
||
func snapshot_member_ids(snapshot: Dictionary) -> Array[String]:
|
||
var result: Array[String] = []
|
||
for member_id in snapshot["party"].get("ordered_ids", []):
|
||
if not String(member_id).is_empty():
|
||
result.append(String(member_id))
|
||
return result if not result.is_empty() else visible_member_ids()
|
||
|
||
|
||
func snapshot_member_hp(snapshot: Dictionary, member_id: String) -> int:
|
||
var health: Dictionary = snapshot["party"].get("member_hp", {})
|
||
return int(health.get(member_id, session_state.member_health(member_id)))
|
||
|
||
|
||
func snapshot_result_time(snapshot: Dictionary, result_index: int) -> float:
|
||
var encounter_id: StringName = [&"battle_1", &"battle_2", &"battle_3", &"battle_4", &"battle_5"][result_index - 1]
|
||
var compatibility_times := [0.0, second_result_time, third_result_time, fourth_result_time, fifth_result_time]
|
||
return float(snapshot["result_times"].get(encounter_id, compatibility_times[result_index - 1]))
|
||
|
||
|
||
func member_result_action(member_id: String, target_id: String) -> String:
|
||
match member_id:
|
||
"Hearthguard": return tr_text("挡住了第一击", "blocked the opening hit")
|
||
"Wayrunner": return tr_text("闪避后反击", "evaded and countered")
|
||
"Farstring": return tr_text("完成远程射击", "fired a ranged shot")
|
||
"Kindhand": return tr_text("为%s恢复生命" % candidate_name(target_id), "restored %s" % candidate_name(target_id))
|
||
return tr_text("完成职责行动", "completed the role action")
|
||
|
||
|
||
func render_restart_confirmation() -> void:
|
||
var wash := ColorRect.new()
|
||
wash.name = "RestartWash"
|
||
wash.color = Color(0.01, 0.015, 0.03, 0.76)
|
||
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
display_root.add_child(wash)
|
||
var dialog := PanelContainer.new()
|
||
dialog.name = "RestartDialog"
|
||
dialog.position = Vector2(size.x * 0.31, size.y * 0.24)
|
||
dialog.size = Vector2(size.x * 0.38, size.y * 0.48)
|
||
dialog.add_theme_stylebox_override("panel", parchment_style())
|
||
display_root.add_child(dialog)
|
||
var box := VBoxContainer.new()
|
||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
box.add_theme_constant_override("separation", 12)
|
||
dialog.add_child(box)
|
||
box.add_child(centered_label(tr_text("重新开始这次体验?", "Restart this experience?"), 20, COLOR_INK))
|
||
box.add_child(centered_label(tr_text("当前队伍和战斗进度会清空。", "The current squad and battle progress will be cleared."), 13, Color("#55472f")))
|
||
var confirm := game_button("RestartConfirmButton", tr_text("确认重新开始", "Restart"), confirm_restart, true)
|
||
confirm.custom_minimum_size.y = 52
|
||
box.add_child(confirm)
|
||
var cancel := game_button("RestartCancelButton", tr_text("继续当前体验", "Keep playing"), cancel_restart, false)
|
||
cancel.custom_minimum_size.y = 48
|
||
box.add_child(cancel)
|
||
|
||
|
||
func run_boot_verification() -> void:
|
||
if boot_running:
|
||
return
|
||
boot_running = true
|
||
boot_verified = false
|
||
boot_failed_path = ""
|
||
boot_index = 0
|
||
render()
|
||
for path in active_boot_paths():
|
||
if not ResourceLoader.exists(path) or ResourceLoader.load(path) == null:
|
||
boot_failed_path = path
|
||
boot_running = false
|
||
render()
|
||
return
|
||
boot_index += 1
|
||
render()
|
||
await get_tree().process_frame
|
||
boot_verified = true
|
||
boot_running = false
|
||
render()
|
||
await get_tree().create_timer(0.35).timeout
|
||
page = AppPage.TITLE
|
||
render()
|
||
|
||
|
||
func active_boot_paths() -> Array[String]:
|
||
return boot_paths_override if not boot_paths_override.is_empty() else BOOT_REQUIRED_PATHS
|
||
|
||
|
||
func retry_boot() -> void:
|
||
call_deferred("run_boot_verification")
|
||
|
||
|
||
func enter_game() -> void:
|
||
if page != AppPage.TITLE or not boot_verified:
|
||
return
|
||
page = AppPage.MAIN_MENU
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func open_game_intro() -> void:
|
||
if page != AppPage.MAIN_MENU:
|
||
return
|
||
page = AppPage.GAME_INTRO
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func open_settings() -> void:
|
||
if page != AppPage.MAIN_MENU:
|
||
return
|
||
page = AppPage.SETTINGS
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func back_to_main_menu() -> void:
|
||
if page not in [AppPage.GAME_INTRO, AppPage.SETTINGS]:
|
||
return
|
||
page = AppPage.MAIN_MENU
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func start_new_game() -> void:
|
||
if page != AppPage.MAIN_MENU:
|
||
return
|
||
clear_experience()
|
||
page = AppPage.FIRST_MISSION
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func start_adventure() -> void:
|
||
# Historical direct-entry helper retained for frozen battle regressions.
|
||
clear_experience()
|
||
page = AppPage.YARD
|
||
notice = tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.")
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func open_recruit_selection() -> void:
|
||
if page not in [AppPage.FIRST_MISSION, AppPage.FORMATION_REVIEW]:
|
||
return
|
||
page = AppPage.RECRUIT_SELECTION
|
||
notice = ""
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func open_front_selection() -> void:
|
||
# Compatibility alias for frozen R9 tests; the player path uses one page.
|
||
if page in [AppPage.FIRST_MISSION, AppPage.FORMATION_REVIEW]:
|
||
open_recruit_selection()
|
||
|
||
|
||
func toggle_recruit(profession: String) -> void:
|
||
var was_selected := selected_recruits.has(profession)
|
||
var result := session_service.toggle_recruit(profession)
|
||
if not result["ok"]:
|
||
if result["code"] == &"already_committed":
|
||
notice = tr_text("小队已满;请先移出一名成员。", "Company full; remove one Recruit first.")
|
||
render()
|
||
return
|
||
if was_selected:
|
||
notice = tr_text("已移出%s。" % candidate_name(profession), "%s removed." % candidate_name(profession))
|
||
else:
|
||
notice = tr_text("已选择%s。" % candidate_name(profession), "%s selected." % candidate_name(profession))
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func ordered_selected_recruits() -> Array[String]:
|
||
var ordered: Array[String] = []
|
||
for profession in AUTO_FORMATION_ORDER:
|
||
if selected_recruits.has(profession):
|
||
ordered.append(profession)
|
||
return ordered
|
||
|
||
|
||
func assign_automatic_formation() -> bool:
|
||
var ordered := ordered_selected_recruits()
|
||
if ordered.size() != 2:
|
||
return false
|
||
front_choice = ordered[0]
|
||
rear_choice = ordered[1]
|
||
return true
|
||
|
||
|
||
func review_automatic_formation() -> void:
|
||
var result := session_service.review_formation()
|
||
if not result["ok"]:
|
||
return
|
||
notice = ""
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func revise_recruit_selection() -> void:
|
||
var result := session_service.revise_formation()
|
||
if not result["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func choose_front_candidate(profession: String) -> void:
|
||
if page != AppPage.FRONT_SELECTION or not CANDIDATES.has(profession) or CANDIDATES[profession]["slot"] != "front":
|
||
return
|
||
front_choice = profession
|
||
notice = tr_text("已选择%s;确认后继续选择后排。" % candidate_name(profession), "%s selected. Continue to choose the rear." % candidate_name(profession))
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func continue_to_rear_selection() -> void:
|
||
if page != AppPage.FRONT_SELECTION or not valid_front_choice():
|
||
return
|
||
page = AppPage.REAR_SELECTION
|
||
notice = ""
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func choose_rear_candidate(profession: String) -> void:
|
||
if page != AppPage.REAR_SELECTION or not CANDIDATES.has(profession) or CANDIDATES[profession]["slot"] != "rear":
|
||
return
|
||
rear_choice = profession
|
||
notice = tr_text("已选择%s;现在检查完整阵型。" % candidate_name(profession), "%s selected. Review the complete formation." % candidate_name(profession))
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func review_formation() -> void:
|
||
if page != AppPage.REAR_SELECTION or not formation_is_valid():
|
||
return
|
||
page = AppPage.FORMATION_REVIEW
|
||
notice = ""
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func revise_front_selection() -> void:
|
||
if page != AppPage.FORMATION_REVIEW or formation_commit_count > 0:
|
||
return
|
||
page = AppPage.FRONT_SELECTION
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func revise_rear_selection() -> void:
|
||
if page != AppPage.FORMATION_REVIEW or formation_commit_count > 0:
|
||
return
|
||
page = AppPage.REAR_SELECTION
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func valid_front_choice() -> bool:
|
||
return CANDIDATES.has(front_choice) and CANDIDATES[front_choice]["slot"] == "front"
|
||
|
||
|
||
func valid_rear_choice() -> bool:
|
||
return CANDIDATES.has(rear_choice) and CANDIDATES[rear_choice]["slot"] == "rear"
|
||
|
||
|
||
func formation_is_valid() -> bool:
|
||
return selected_recruits.size() == 2 and front_choice in selected_recruits and rear_choice in selected_recruits and front_choice != rear_choice
|
||
|
||
|
||
func commit_formation() -> void:
|
||
var result := session_service.commit_formation()
|
||
if not result["ok"]:
|
||
return
|
||
_start_first_battle_presentation()
|
||
|
||
|
||
func _start_first_battle_presentation() -> void:
|
||
battle_clock = 0.0
|
||
guard_events = 0
|
||
rear_events = 0
|
||
victory_events = 0
|
||
reset_battle_feedback()
|
||
victory_clock = 0.0
|
||
departure_transition_active = true
|
||
departure_transition_clock = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func formation_contribution(profession: String) -> String:
|
||
match profession:
|
||
"Hearthguard":
|
||
return tr_text("稳稳格挡第一记重击,\n用防守保留更多生命。", "Blocks the first heavy strike\nand preserves more health.")
|
||
"Wayrunner":
|
||
return tr_text("闪避一部分伤害,\n随后用反击回应敌人。", "Evades part of the damage,\nthen answers with a counter.")
|
||
"Farstring":
|
||
return tr_text("在后排提前射击,\n直接削减敌人生命。", "Fires early from the rear\nto reduce enemy health.")
|
||
"Kindhand":
|
||
return tr_text("敌人出手后治疗前排,\n恢复已经损失的生命。", "Heals the front after impact\nand restores lost health.")
|
||
return ""
|
||
|
||
|
||
func recruit_behavior_short(profession: String) -> String:
|
||
match profession:
|
||
"Hearthguard":
|
||
return tr_text("格挡 · 承压", "Block · endure")
|
||
"Wayrunner":
|
||
return tr_text("闪避 · 反击", "Evade · counter")
|
||
"Farstring":
|
||
return tr_text("远射 · 输出", "Ranged damage")
|
||
"Kindhand":
|
||
return tr_text("治疗 · 恢复", "Heal · recover")
|
||
return ""
|
||
|
||
|
||
func formation_position_contribution(profession: String, slot: String) -> String:
|
||
if slot == "front":
|
||
match profession:
|
||
"Hearthguard":
|
||
return tr_text("系统让他先行:\n格挡敌人的第一击。", "System puts them first:\nblocks the opening strike.")
|
||
"Wayrunner":
|
||
return tr_text("系统让他先行:\n闪避后准备反击。", "System puts them first:\nevades, then counters.")
|
||
"Farstring":
|
||
return tr_text("系统让她先行:\n会暴露在敌人第一击下。", "System puts her first:\nexposed to the opening strike.")
|
||
return tr_text("第二位会在先行者受击后行动。", "The second member acts after the lead is struck.") if profession == "Wayrunner" else formation_contribution(profession)
|
||
|
||
|
||
func choose_candidate(profession: String) -> void:
|
||
if not CANDIDATES.has(profession):
|
||
return
|
||
var slot: String = CANDIDATES[profession]["slot"]
|
||
if slot == "front":
|
||
front_choice = profession
|
||
notice = tr_text("%s进入前排。现在选择一名后排。" % candidate_name(profession), "%s joined the front. Now choose a rear role." % candidate_name(profession))
|
||
else:
|
||
rear_choice = profession
|
||
notice = tr_text("%s进入后排。队伍已准备好。" % candidate_name(profession), "%s joined the rear. The squad is ready." % candidate_name(profession))
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func depart() -> void:
|
||
if front_choice.is_empty() or rear_choice.is_empty():
|
||
return
|
||
begin_first_battle()
|
||
|
||
|
||
func begin_first_battle() -> void:
|
||
if not session_service.begin_compat_first_battle()["ok"]:
|
||
return
|
||
battle_clock = 0.0
|
||
reset_battle_feedback()
|
||
victory_clock = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func advance_battle_phase() -> void:
|
||
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE] or not battle_playing:
|
||
return
|
||
if departure_transition_active:
|
||
departure_transition_active = false
|
||
departure_transition_clock = 0.0
|
||
commit_phase_impact(battle_phase, false)
|
||
var encounter_id := session_state.encounter_id()
|
||
var previous_page := page
|
||
var result := session_service.complete_phase(encounter_id, battle_phase)
|
||
if not result["ok"]:
|
||
return
|
||
battle_clock = 0.0
|
||
if not battle_playing:
|
||
match previous_page:
|
||
AppPage.SECOND_BATTLE: second_result_time = second_battle_elapsed
|
||
AppPage.THIRD_BATTLE: third_result_time = third_battle_elapsed
|
||
AppPage.FOURTH_BATTLE: fourth_result_time = fourth_battle_elapsed
|
||
AppPage.FIFTH_BATTLE: fifth_result_time = fifth_battle_elapsed
|
||
render()
|
||
|
||
|
||
func phase_impact_time(phase: int) -> float:
|
||
return battle_fixture.impact_time(session_state.encounter_id(), phase, _battle_decisions())
|
||
|
||
|
||
func phase_duration(phase: int) -> float:
|
||
return battle_fixture.duration_for_phase(session_state.encounter_id(), phase, _battle_decisions())
|
||
|
||
|
||
func _battle_decisions() -> Dictionary:
|
||
return {
|
||
"adaptation": adaptation_choice,
|
||
"retained_item": retained_item,
|
||
"added_id": added_rear,
|
||
"route": route_choice,
|
||
"profile": selected_build_profile(),
|
||
"trained_profession": trained_profession(),
|
||
}
|
||
|
||
|
||
func commit_phase_impact(phase: int, refresh_runtime := true) -> void:
|
||
var result := session_service.resolve_compat_impact(session_state.encounter_id(), phase, {
|
||
"profile": selected_build_profile(),
|
||
"trained_slot": trained_member,
|
||
"trained_profession": trained_profession(),
|
||
})
|
||
if not result["ok"] or result["events"].is_empty():
|
||
return
|
||
var event: Dictionary = result["events"][0]
|
||
play_cue(String(event["cue"]))
|
||
var feedback_target := legacy_feedback_target(String(event["target_member_id"]))
|
||
emit_battle_feedback(
|
||
phase,
|
||
feedback_target,
|
||
int(event["delta"]),
|
||
String(event["qualifier_zh"]),
|
||
String(event["qualifier_en"]),
|
||
String(event["tier"]),
|
||
{
|
||
"encounter_id": String(event["encounter_id"]),
|
||
"source_member_id": String(event["source_member_id"]),
|
||
"target_member_id": String(event["target_member_id"]),
|
||
"cue": String(event["cue"]),
|
||
"vfx": battle_feedback_vfx_kind(event),
|
||
}
|
||
)
|
||
if refresh_runtime and page in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE]:
|
||
render()
|
||
|
||
|
||
func legacy_feedback_target(member_id: String) -> String:
|
||
if member_id in ["enemy", "front", "rear", "added"]:
|
||
return member_id
|
||
var slot := visible_member_slot(member_id)
|
||
return slot if not slot.is_empty() else member_id
|
||
|
||
|
||
func battle_feedback_vfx_kind(event: Dictionary) -> String:
|
||
if int(event.get("delta", 0)) > 0:
|
||
return "heal"
|
||
var target_id := String(event.get("target_member_id", ""))
|
||
if target_id == visible_member_id(0) and profession_defense_state(target_id) != "idle":
|
||
return "guard"
|
||
return "hit"
|
||
|
||
|
||
func commit_second_phase_impact(phase: int, refresh_runtime := true) -> void:
|
||
commit_phase_impact(phase, refresh_runtime)
|
||
|
||
|
||
func commit_third_phase_impact(phase: int, refresh_runtime := true) -> void:
|
||
commit_phase_impact(phase, refresh_runtime)
|
||
|
||
|
||
func commit_fourth_phase_impact(phase: int, refresh_runtime := true) -> void:
|
||
commit_phase_impact(phase, refresh_runtime)
|
||
|
||
|
||
func commit_fifth_phase_impact(phase: int, refresh_runtime := true) -> void:
|
||
commit_phase_impact(phase, refresh_runtime)
|
||
|
||
|
||
|
||
func emit_battle_feedback(phase: int, target: String, delta: int, qualifier_zh: String, qualifier_en: String, tier: String, binding: Dictionary = {}) -> void:
|
||
var payload := {
|
||
"phase": phase,
|
||
"target": target,
|
||
"delta": delta,
|
||
"qualifier_zh": qualifier_zh,
|
||
"qualifier_en": qualifier_en,
|
||
"tier": tier,
|
||
}
|
||
for key in binding:
|
||
payload[key] = binding[key]
|
||
feedback_presenter.present(payload)
|
||
|
||
|
||
func reset_battle_feedback() -> void:
|
||
feedback_presenter.clear()
|
||
battle_feedback_label = null
|
||
battle_feedback_vfx = null
|
||
|
||
|
||
func expire_battle_feedback() -> void:
|
||
feedback_presenter.expire()
|
||
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 departure_transition_active:
|
||
if page != AppPage.BATTLE or not battle_playing:
|
||
departure_transition_active = false
|
||
departure_transition_clock = 0.0
|
||
departure_sprites.clear()
|
||
return
|
||
departure_transition_clock += delta
|
||
var departure_duration := REDUCED_DEPARTURE_DURATION if reduced_motion else DEPARTURE_TRANSITION_DURATION
|
||
var departure_progress := 1.0 if reduced_motion else smoothstep(0.0, 1.0, clampf(departure_transition_clock / departure_duration, 0.0, 1.0))
|
||
for member_id in departure_sprites:
|
||
var departure_actor: TextureRect = departure_sprites[member_id]
|
||
if departure_actor != null and is_instance_valid(departure_actor):
|
||
departure_actor.position = Vector2(departure_start_positions[member_id]).lerp(Vector2(departure_target_positions[member_id]), departure_progress)
|
||
departure_actor.texture = animation_texture(member_id, "idle" if reduced_motion else "advance", departure_transition_clock)
|
||
if departure_threat_sprite != null and is_instance_valid(departure_threat_sprite):
|
||
departure_threat_sprite.texture = animation_texture("Riftblade", "idle", departure_transition_clock)
|
||
if departure_transition_clock >= departure_duration:
|
||
departure_transition_active = false
|
||
departure_sprites.clear()
|
||
departure_transition_clock = 0.0
|
||
render()
|
||
return
|
||
if page in [AppPage.MAIN_MENU, AppPage.FIRST_MISSION, AppPage.RECRUIT_SELECTION, AppPage.FORMATION_REVIEW] and not portrait_layout():
|
||
yard_preview_clock += delta
|
||
for profession in yard_preview_sprites:
|
||
var actor: TextureRect = yard_preview_sprites[profession]
|
||
if actor != null and is_instance_valid(actor):
|
||
actor.texture = animation_texture(profession, "idle", yard_preview_clock)
|
||
if yard_threat_sprite != null and is_instance_valid(yard_threat_sprite):
|
||
yard_threat_sprite.texture = animation_texture("Riftblade", "idle", yard_preview_clock)
|
||
return
|
||
if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT, AppPage.GUILD_RETURN, AppPage.SESSION_RESULT, AppPage.ROUTE_BOARD, AppPage.ROUTE_REVIEW, AppPage.SECOND_CYCLE_RESULT, AppPage.TRAINING, AppPage.TRAINING_REVIEW, AppPage.THIRD_CYCLE_RESULT]:
|
||
if front_choice.is_empty() or rear_choice.is_empty():
|
||
return
|
||
victory_clock += delta
|
||
if victory_front_sprite != null and is_instance_valid(victory_front_sprite):
|
||
victory_front_sprite.texture = animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock)
|
||
victory_rear_sprite.texture = animation_texture(rear_choice, "idle", victory_clock)
|
||
return
|
||
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE] or not battle_playing or restart_confirmation:
|
||
return
|
||
var feedback_was_active := not active_battle_feedback.is_empty()
|
||
var impact_at := phase_impact_time(battle_phase)
|
||
var duration := phase_duration(battle_phase)
|
||
var clock_result := battle_clock_owner.advance(delta, render_snapshot()["encounter"], impact_at, duration, restart_confirmation)
|
||
if page == AppPage.SECOND_BATTLE:
|
||
second_battle_elapsed += delta
|
||
elif page == AppPage.THIRD_BATTLE:
|
||
third_battle_elapsed += delta
|
||
elif page == AppPage.FOURTH_BATTLE:
|
||
fourth_battle_elapsed += delta
|
||
elif page == AppPage.FIFTH_BATTLE:
|
||
fifth_battle_elapsed += delta
|
||
for request in clock_result["impact_requests"]:
|
||
commit_phase_impact(int(request["phase"]))
|
||
if clock_result["phase_complete"]:
|
||
advance_battle_phase()
|
||
return
|
||
if front_sprite == null or not is_instance_valid(front_sprite):
|
||
return
|
||
var progress: float = float(clock_result["phase_progress"])
|
||
if battle_phase == 0 and not reduced_motion:
|
||
front_sprite.position.x = lerp(size.x * 0.13, size.x * 0.38, progress)
|
||
rear_sprite.position.x = lerp(size.x * 0.03, size.x * 0.20, progress)
|
||
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
|
||
added_rear_sprite.position.x = lerp(-added_rear_sprite.size.x, size.x * 0.10, progress)
|
||
enemy_sprite.position.x = lerp(size.x * 0.82, size.x * 0.57, progress)
|
||
var later_cycle := page in [AppPage.THIRD_BATTLE, AppPage.FOURTH_BATTLE, AppPage.FIFTH_BATTLE]
|
||
var visible_front := visible_member_id(0)
|
||
var visible_rear := visible_member_id(1)
|
||
var visible_third := visible_member_id(2)
|
||
front_sprite.texture = animation_texture(visible_front, later_battle_animation_state(visible_front) if later_cycle else front_animation_state(), battle_clock)
|
||
rear_sprite.texture = animation_texture(visible_rear, later_battle_animation_state(visible_rear) if later_cycle else rear_animation_state(), battle_clock)
|
||
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
|
||
added_rear_sprite.texture = animation_texture(visible_third, later_battle_animation_state(visible_third), battle_clock)
|
||
enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock)
|
||
var projectile_actor_is_active := false
|
||
for member_id in visible_member_ids():
|
||
if (later_battle_animation_state(member_id) if later_cycle else (rear_animation_state() if member_id == visible_rear else front_animation_state())) == "draw_release":
|
||
projectile_actor_is_active = true
|
||
break
|
||
if projectile != null and is_instance_valid(projectile) and projectile_actor_is_active and not reduced_motion:
|
||
var projectile_start := 0.08
|
||
var projectile_end := phase_impact_time(battle_phase) + 0.10
|
||
var projectile_progress := clampf((battle_clock - projectile_start) / (projectile_end - projectile_start), 0.0, 1.0)
|
||
projectile.visible = battle_clock >= projectile_start and battle_clock <= projectile_end
|
||
projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, projectile_progress)
|
||
if feedback_was_active:
|
||
battle_feedback_clock += delta
|
||
update_battle_feedback_visual()
|
||
|
||
|
||
func commit_first_victory_reward() -> void:
|
||
session_service.commit_reward(&"battle_1", FIRST_VICTORY_COIN, &"battle_1")
|
||
|
||
|
||
func open_adaptation() -> void:
|
||
if not session_service.open_adaptation()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func choose_adaptation(id: String) -> void:
|
||
if not session_service.choose_adaptation(id)["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func begin_second_battle() -> void:
|
||
if not session_service.commit_adaptation()["ok"]:
|
||
return
|
||
battle_clock = 0.0
|
||
guard_events = 0
|
||
rear_events = 0
|
||
reset_battle_feedback()
|
||
second_battle_elapsed = 0.0
|
||
second_result_time = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func open_guild_return() -> void:
|
||
if not session_service.open_guild_response()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func choose_guild_option(id: String) -> void:
|
||
if not session_service.choose_guild_response(domain_guild_response(id))["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func domain_guild_response(id: String) -> String:
|
||
match id:
|
||
"retain_item": return "retain_loan"
|
||
"recruit_rear": return "add_recruit"
|
||
return id
|
||
|
||
|
||
func legacy_guild_choice(id: String) -> String:
|
||
match id:
|
||
"retain_loan": return "retain_item"
|
||
"add_recruit": return "recruit_rear"
|
||
return id
|
||
|
||
|
||
func set_legacy_guild_choice(id: String) -> void:
|
||
var response := domain_guild_response(id)
|
||
if response.is_empty() or response in P91ContractsScript.GUILD_RESPONSE_IDS:
|
||
session_state.guild_response = response
|
||
|
||
|
||
func begin_third_battle() -> void:
|
||
if not session_service.commit_guild_response()["ok"]:
|
||
return
|
||
battle_clock = 0.0
|
||
guard_events = 0
|
||
rear_events = 0
|
||
reset_battle_feedback()
|
||
third_battle_elapsed = 0.0
|
||
third_result_time = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func open_route_board() -> void:
|
||
if not session_service.open_route_board()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func choose_route(id: String) -> void:
|
||
if not session_service.choose_route(id)["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func open_route_review() -> void:
|
||
if not session_service.review_route()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func cancel_route_review() -> void:
|
||
if not session_service.cancel_route_review()["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func commit_route() -> void:
|
||
if not session_service.commit_route()["ok"]:
|
||
return
|
||
battle_clock = 0.0
|
||
guard_events = 0
|
||
rear_events = 0
|
||
reset_battle_feedback()
|
||
fourth_battle_elapsed = 0.0
|
||
fourth_result_time = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func commit_route_reward() -> void:
|
||
session_service.commit_reward(&"route", route_reward(route_choice), &"battle_4")
|
||
|
||
|
||
func open_training() -> void:
|
||
if not session_service.open_growth()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func choose_training(slot: String) -> void:
|
||
var member_id := member_profession(slot)
|
||
if not session_service.choose_growth(member_id)["ok"]:
|
||
return
|
||
training_choice = slot
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func open_training_review() -> void:
|
||
if not session_service.review_growth()["ok"]:
|
||
return
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func cancel_training_review() -> void:
|
||
if not session_service.cancel_growth_review()["ok"]:
|
||
return
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func commit_training() -> void:
|
||
if not session_service.commit_growth()["ok"]:
|
||
return
|
||
trained_member = training_choice
|
||
battle_clock = 0.0
|
||
guard_events = 0
|
||
rear_events = 0
|
||
reset_battle_feedback()
|
||
fifth_battle_elapsed = 0.0
|
||
fifth_result_time = 0.0
|
||
play_cue("depart")
|
||
render()
|
||
|
||
|
||
func commit_fifth_reward() -> void:
|
||
session_service.commit_reward(&"battle_5", FIFTH_BATTLE_REWARD, &"battle_5")
|
||
|
||
|
||
func request_restart() -> void:
|
||
restart_confirmation = true
|
||
render()
|
||
|
||
|
||
func cancel_restart() -> void:
|
||
restart_confirmation = false
|
||
render()
|
||
|
||
|
||
func confirm_restart() -> void:
|
||
restart_confirmation = false
|
||
clear_experience()
|
||
page = AppPage.MAIN_MENU
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func go_home() -> void:
|
||
restart_confirmation = false
|
||
clear_experience()
|
||
page = AppPage.MAIN_MENU
|
||
play_cue("confirm")
|
||
render()
|
||
|
||
|
||
func clear_experience() -> void:
|
||
session_service.reset_transient(&"compat_clear")
|
||
battle_clock_owner.reset()
|
||
second_battle_elapsed = 0.0
|
||
third_battle_elapsed = 0.0
|
||
fourth_battle_elapsed = 0.0
|
||
fifth_battle_elapsed = 0.0
|
||
reset_battle_feedback()
|
||
victory_clock = 0.0
|
||
departure_transition_active = false
|
||
departure_transition_clock = 0.0
|
||
departure_sprites.clear()
|
||
departure_start_positions.clear()
|
||
departure_target_positions.clear()
|
||
departure_threat_sprite = null
|
||
|
||
|
||
func toggle_locale() -> void:
|
||
locale = "en" if locale == "zh_CN" else "zh_CN"
|
||
render()
|
||
|
||
|
||
func toggle_text_scale() -> void:
|
||
text_scale = 1.0 if text_scale > 1.0 else 1.3
|
||
render()
|
||
|
||
|
||
func toggle_motion() -> void:
|
||
reduced_motion = not reduced_motion
|
||
render()
|
||
|
||
|
||
func toggle_mute() -> void:
|
||
muted = not muted
|
||
AudioServer.set_bus_mute(AudioServer.get_bus_index(&"SFX"), muted)
|
||
if not muted:
|
||
play_cue("select")
|
||
render()
|
||
|
||
|
||
func battle_event_text() -> String:
|
||
if page == AppPage.FIFTH_BATTLE:
|
||
return fifth_battle_event_text()
|
||
if page == AppPage.FOURTH_BATTLE:
|
||
return fourth_battle_event_text()
|
||
if page == AppPage.THIRD_BATTLE:
|
||
return third_battle_event_text()
|
||
if page == AppPage.SECOND_BATTLE:
|
||
return second_battle_event_text()
|
||
match battle_phase:
|
||
0:
|
||
return tr_text("小队正在接近敌人…", "The squad advances toward the enemy…")
|
||
1:
|
||
return tr_text("危险!裂影刃手准备攻击前排", "Danger! The Riftblade targets the front")
|
||
2:
|
||
if front_choice == "Hearthguard":
|
||
return tr_text("盾卫挡住重击,伤害降低", "The Guardian braces and reduces the blow")
|
||
if front_choice == "Wayrunner":
|
||
return tr_text("游侠侧身闪避并准备反击", "The Runner evades and prepares a counter")
|
||
return tr_text("弓手被系统排在先行位,承受了高额伤害", "The Archer was assigned as lead and takes heavy damage")
|
||
3:
|
||
if rear_choice == "Farstring":
|
||
return tr_text("弓手从第二位射中敌人", "The Archer strikes from second position")
|
||
if rear_choice == "Kindhand":
|
||
return tr_text("治疗举起灵灯,为先行者恢复生命", "The Healer restores the lead")
|
||
return tr_text("游侠从第二位切入,发动侧翼反击", "The Runner enters from second position with a flank counter")
|
||
4:
|
||
return tr_text("前后排配合,敌人即将倒下", "Front and rear combine; the enemy is nearly defeated")
|
||
5:
|
||
return tr_text("裂影刃手被击败!", "Riftblade defeated!")
|
||
return ""
|
||
|
||
|
||
func victory_explanation() -> String:
|
||
var lead_line := tr_text("系统让%s先行,她承受了更高风险。" % candidate_name(front_choice), "The system put %s first, exposing her to greater risk." % candidate_name(front_choice)) if front_choice == "Farstring" else tr_text("系统让%s先行并回应了敌人的第一击。" % candidate_name(front_choice), "The system put %s first to answer the opening strike." % candidate_name(front_choice))
|
||
var second_line := tr_text("%s从第二位发动侧翼反击。" % candidate_name(rear_choice), "%s countered from second position." % candidate_name(rear_choice)) if rear_choice == "Wayrunner" else (tr_text("%s从第二位造成远程伤害。" % candidate_name(rear_choice), "%s dealt ranged damage from second position." % candidate_name(rear_choice)) if rear_choice == "Farstring" else tr_text("%s从第二位恢复先行者生命。" % candidate_name(rear_choice), "%s restored the lead from second position." % candidate_name(rear_choice)))
|
||
return lead_line + "\n" + second_line
|
||
|
||
|
||
func second_battle_event_text() -> String:
|
||
match battle_phase:
|
||
0:
|
||
return tr_text("小队带着借用装备迎战老兵…", "The squad advances with its loaned item…")
|
||
1:
|
||
return tr_text("晨钟先手:%s抢在敌人之前行动" % candidate_name(front_choice), "Early Bell: %s acts before the enemy" % candidate_name(front_choice)) if adaptation_choice == "EQ-061" else tr_text("危险!老兵准备推开并重创%s" % candidate_name(front_choice), "Danger! The veteran prepares to strike %s" % candidate_name(front_choice))
|
||
2:
|
||
return tr_text("钉阵胫甲稳住%s,重击降为 -10" % candidate_name(front_choice), "Frontpin anchors %s; the strike falls to -10" % candidate_name(front_choice)) if adaptation_choice == "EQ-002" else tr_text("%s承受 -30 重击;速度换来风险" % candidate_name(front_choice), "%s takes the -30 strike; speed accepted the risk" % candidate_name(front_choice))
|
||
3:
|
||
if rear_choice == "Wayrunner":
|
||
return tr_text("%s从第二位切入,发动侧翼反击" % candidate_name(rear_choice), "%s enters from second position with a flank counter" % candidate_name(rear_choice))
|
||
if rear_choice == "Farstring":
|
||
return tr_text("%s从第二位补上远程伤害" % candidate_name(rear_choice), "%s follows with a ranged hit" % candidate_name(rear_choice))
|
||
return tr_text("%s从第二位恢复%s生命" % [candidate_name(rear_choice), candidate_name(front_choice)], "%s restores %s from second position" % [candidate_name(rear_choice), candidate_name(front_choice)])
|
||
4:
|
||
return tr_text("更慢但更稳的连携开始收尾", "The slower, safer coordination begins the finish") if adaptation_choice == "EQ-002" else tr_text("先手优势让敌人更快进入终结线", "Initiative brings the enemy to the finish sooner")
|
||
5:
|
||
return tr_text("老兵裂影刃手被击败!", "Veteran Riftblade defeated!")
|
||
return ""
|
||
|
||
|
||
func third_battle_event_text() -> String:
|
||
var lead_id := visible_member_id(0)
|
||
var second_id := visible_member_id(1)
|
||
var third_id := visible_member_id(2)
|
||
match battle_phase:
|
||
0:
|
||
return tr_text("小队向裂隙队长推进…", "The squad advances on the Rift Captain…")
|
||
1:
|
||
return tr_text("晨钟先手:%s抢在队长之前行动" % candidate_name(lead_id), "Early Bell: %s acts before the captain" % candidate_name(lead_id)) if retained_item == "EQ-061" else tr_text("队长蓄力,准备攻击先行者%s" % candidate_name(lead_id), "The captain gathers force against lead %s" % candidate_name(lead_id))
|
||
2:
|
||
return tr_text("钉阵胫甲让%s守住阵线,重击只造成 -10" % candidate_name(lead_id), "Frontpin lets %s hold; the strike deals only -10" % candidate_name(lead_id)) if retained_item == "EQ-002" else tr_text("队长重击%s;现在需要队友支援" % candidate_name(lead_id), "The captain hits %s; support is needed" % candidate_name(lead_id))
|
||
3:
|
||
return support_action_text(second_id, lead_id, "第二位", "second position")
|
||
4:
|
||
if not third_id.is_empty():
|
||
return support_action_text(third_id, lead_id, "第三位", "third position")
|
||
return tr_text("保留装备继续放大两人连携", "The retained item amplifies the two-person follow-up")
|
||
5:
|
||
return tr_text("三人阵线合力压制队长", "The three-person line overwhelms the captain") if not added_rear.is_empty() else tr_text("两人阵线守住装备优势", "The two-person line keeps its item advantage")
|
||
6:
|
||
return tr_text("裂隙队长被击败!", "Rift Captain defeated!")
|
||
return ""
|
||
|
||
|
||
func fourth_battle_event_text() -> String:
|
||
var lead_id := visible_member_id(0)
|
||
var marked_id := session_state.marked_member_id()
|
||
var second_id := visible_member_id(1)
|
||
var third_id := visible_member_id(2)
|
||
match battle_phase:
|
||
0:
|
||
return tr_text("小队进入%s…" % route_name(route_choice), "The Company enters %s…" % route_name(route_choice))
|
||
1:
|
||
if route_choice == "R6-01":
|
||
return tr_text("路线守卫蓄力:正面粉碎即将命中%s" % candidate_name(lead_id), "The Warden charges a front crush against %s" % candidate_name(lead_id))
|
||
if route_choice == "R6-02":
|
||
return tr_text("晨钟抢到先手" if selected_build_profile() == "tempo" else "敌方速度爆发抢先行动", "Early Bell seizes initiative" if selected_build_profile() == "tempo" else "Enemy tempo bursts first")
|
||
return tr_text("双辉标记锁定了%s" % candidate_name(marked_id), "Twinflare marks %s" % candidate_name(marked_id))
|
||
2:
|
||
if route_choice == "R6-03":
|
||
return tr_text("分裂打击越过%s,命中%s -35" % [candidate_name(lead_id), candidate_name(marked_id)], "The split strike bypasses %s and hits %s for -35" % [candidate_name(lead_id), candidate_name(marked_id)])
|
||
return tr_text("钉阵胫甲把%s受到的粉碎降为 -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "路线压力命中%s" % candidate_name(lead_id), "Frontpin reduces %s's crush to -10" % candidate_name(lead_id) if route_choice == "R6-01" and selected_build_profile() == "guarded" else "Route pressure hits %s" % candidate_name(lead_id))
|
||
3:
|
||
return support_action_text(second_id, marked_id if route_choice == "R6-03" else lead_id, "第二位", "second position")
|
||
4:
|
||
return support_action_text(third_id, marked_id if route_choice == "R6-03" else lead_id, "第三位", "third position") if not third_id.is_empty() else tr_text("保留构筑发动第二次反击", "The retained build follows up")
|
||
5:
|
||
return tr_text("小队突破路线守卫的防线", "The Company breaks the route defense")
|
||
6:
|
||
return tr_text("路线守卫被击败,奖励已结算", "Route Warden defeated; reward settled")
|
||
return ""
|
||
|
||
|
||
func fifth_battle_event_text() -> String:
|
||
var lead_id := visible_member_id(0)
|
||
var marked_id := session_state.marked_member_id()
|
||
var supports := support_member_ids()
|
||
var first_support := supports[0] if not supports.is_empty() else ""
|
||
var second_support := supports[1] if supports.size() > 1 else ""
|
||
match battle_phase:
|
||
0:
|
||
return tr_text("Lv.2 成员带队进入双压关隘…", "The Lv.2 member leads into Twin-Pressure Pass…")
|
||
1:
|
||
return tr_text("历练弓手%s提前射击,但本场仍只行动一次" % candidate_name(trained_profession()), "Trained Archer %s fires early, but still acts only once" % candidate_name(trained_profession())) if trained_profession() == "Farstring" else tr_text("双压守卫同时锁定%s与%s" % [candidate_name(lead_id), candidate_name(marked_id)], "The Twin Warden marks %s and %s" % [candidate_name(lead_id), candidate_name(marked_id)])
|
||
2:
|
||
return tr_text("%s的历练额外减少 20 伤害" % candidate_name(lead_id), "%s's training removes 20 more damage" % candidate_name(lead_id)) if trained_profession() == lead_id else tr_text("%s承受第一道正面重压" % candidate_name(lead_id), "%s absorbs the first pressure" % candidate_name(lead_id))
|
||
3:
|
||
return tr_text("分裂压力越过阵线,%s受到 -40" % candidate_name(marked_id), "Split pressure bypasses the line; %s takes -40" % candidate_name(marked_id))
|
||
4:
|
||
if trained_profession() == first_support and first_support == "Farstring":
|
||
return tr_text("历练弓手已在第一步行动,本步不重复攻击", "The trained Archer already acted; no duplicate attack")
|
||
return support_action_text(first_support, marked_id, "第二位", "second position", trained_profession() == first_support)
|
||
5:
|
||
if not second_support.is_empty():
|
||
if trained_profession() == second_support and second_support == "Farstring":
|
||
return tr_text("历练弓手已提前行动,不会重复射击", "The trained Archer acted early and does not fire twice")
|
||
return support_action_text(second_support, marked_id, "第三位", "third position", trained_profession() == second_support)
|
||
return tr_text("两人小队发动保留构筑追击", "The two-person line triggers its retained-build follow-up")
|
||
6:
|
||
return tr_text("双压守卫被击败;成长结果已验证", "Twin Warden defeated; growth result verified")
|
||
return ""
|
||
|
||
|
||
func support_action_text(member_id: String, target_id: String, position_zh: String, position_en: String, trained := false) -> String:
|
||
match member_id:
|
||
"Wayrunner":
|
||
return tr_text("%s%s发动侧翼反击" % [position_zh, candidate_name(member_id)], "%s in %s launches a flank counter" % [candidate_name(member_id), position_en])
|
||
"Farstring":
|
||
return tr_text("%s%s完成远程射击" % [position_zh, candidate_name(member_id)], "%s in %s fires a ranged shot" % [candidate_name(member_id), position_en])
|
||
"Kindhand":
|
||
var amount := 25 if trained else 12
|
||
return tr_text("%s%s为%s恢复 +%d" % [position_zh, candidate_name(member_id), candidate_name(target_id), amount], "%s in %s restores +%d to %s" % [candidate_name(member_id), position_en, amount, candidate_name(target_id)])
|
||
return tr_text("%s%s完成职责行动" % [position_zh, candidate_name(member_id)], "%s in %s completes the role action" % [candidate_name(member_id), position_en])
|
||
|
||
|
||
func selected_build_profile() -> String:
|
||
if not added_rear.is_empty():
|
||
return "full_line"
|
||
return "guarded" if retained_item == "EQ-002" else "tempo"
|
||
|
||
|
||
func member_slots() -> Array[String]:
|
||
var slots: Array[String] = []
|
||
for index in range(visible_member_ids().size()):
|
||
slots.append(["front", "rear", "added"][index])
|
||
return slots
|
||
|
||
|
||
func member_profession(slot: String) -> String:
|
||
match slot:
|
||
"front":
|
||
return visible_member_id(0)
|
||
"rear":
|
||
return visible_member_id(1)
|
||
"added":
|
||
return visible_member_id(2)
|
||
return ""
|
||
|
||
|
||
func member_slot_label(slot: String) -> String:
|
||
var member_id := member_profession(slot)
|
||
var origin := tr_text("公会加入", "Guild recruit") if member_id == added_rear and not added_rear.is_empty() else tr_text("初始成员", "Starting member")
|
||
match slot:
|
||
"front":
|
||
return tr_text("先行位 · %s" % origin, "Lead · %s" % origin)
|
||
"rear":
|
||
return tr_text("第二位 · %s" % origin, "Second · %s" % origin)
|
||
"added":
|
||
return tr_text("第三位 · %s" % origin, "Third · %s" % origin)
|
||
return ""
|
||
|
||
|
||
func member_level(slot: String) -> int:
|
||
if slot not in member_slots():
|
||
return 0
|
||
var member_id := member_profession(slot)
|
||
return int(session_state.member_levels.get(member_id, 2 if training_committed and slot == trained_member else 1))
|
||
|
||
|
||
func trained_profession() -> String:
|
||
if not training_committed:
|
||
return ""
|
||
return session_state.trained_member_id if not session_state.trained_member_id.is_empty() else member_profession(trained_member)
|
||
|
||
|
||
func training_color(slot: String) -> Color:
|
||
match member_profession(slot):
|
||
"Hearthguard", "Wayrunner":
|
||
return COLOR_TEAL
|
||
"Farstring":
|
||
return COLOR_GOLD
|
||
"Kindhand":
|
||
return Color("#77b6a5")
|
||
return COLOR_GOLD
|
||
|
||
|
||
func training_effect_short(slot: String) -> String:
|
||
match member_profession(slot):
|
||
"Hearthguard", "Wayrunner":
|
||
return tr_text("前排重压再减 20", "Front pressure -20")
|
||
"Farstring":
|
||
return tr_text("唯一射击提前 1 秒", "One shot moves 1s earlier")
|
||
"Kindhand":
|
||
return tr_text("后排治疗 +12 → +25", "Rear healing +12 → +25")
|
||
return ""
|
||
|
||
|
||
func training_test_short(slot: String) -> String:
|
||
match member_profession(slot):
|
||
"Hearthguard", "Wayrunner":
|
||
return tr_text("承受正面重压后的剩余生命", "HP after the front pressure")
|
||
"Farstring":
|
||
return tr_text("射击提前且攻击次数不变", "earlier shot with no extra attack")
|
||
"Kindhand":
|
||
return tr_text("被标记成员获得 +25 恢复", "the marked member receives +25 healing")
|
||
return ""
|
||
|
||
|
||
func training_effect_detail(slot: String) -> String:
|
||
match member_profession(slot):
|
||
"Hearthguard", "Wayrunner":
|
||
return tr_text("下一战正面重压在装备结算后再减少 20。
|
||
有钉阵胫甲:前排 70 → 90;无胫甲:55 → 75。", "The next front pressure loses 20 damage after item mitigation.\nWith Frontpin: 70 → 90 HP; without: 55 → 75 HP.")
|
||
"Farstring":
|
||
return tr_text("唯一一次普通射击移到敌人施压之前。
|
||
不增加攻击次数;战斗时间 7.40 → 6.40 秒。", "The one normal shot moves before enemy pressure.\nNo extra attack; battle time falls from 7.40 to 6.40 seconds.")
|
||
"Kindhand":
|
||
return tr_text("下一战治疗目标是受伤的原后排。
|
||
恢复量从 +12 提高到 +25:后排 72 → 85。", "The next heal targets the damaged original rear.\nHealing rises from +12 to +25: rear HP 72 → 85.")
|
||
return ""
|
||
|
||
|
||
func training_guidance() -> String:
|
||
if training_choice.is_empty():
|
||
return tr_text("没有预选。比较得到、代价与放弃。还不能确认:先选择一名成员。", "Nothing is preselected. Compare gain, cost, and opportunity cost. Cannot confirm yet: choose one member.")
|
||
return tr_text("已选择“%s”。确认前仍可更换;金币、路线与等级尚未改变。" % candidate_name(member_profession(training_choice)), "“%s” selected. You can still change it; Coin, route, and levels are unchanged." % candidate_name(member_profession(training_choice)))
|
||
|
||
|
||
func training_result_explanation() -> String:
|
||
match trained_profession():
|
||
"Hearthguard", "Wayrunner":
|
||
return tr_text("前排 Lv.2 在已有装备结算后再减少 20 伤害。
|
||
这次前排保留 %d 生命;其他成员仍是 Lv.1。" % front_hp, "Front Lv.2 removed 20 damage after item mitigation.\nThe front kept %d HP; all other members remain Lv.1." % front_hp)
|
||
"Farstring":
|
||
return tr_text("弓手 Lv.2 把唯一射击提前到敌人之前。
|
||
没有增加攻击次数;战斗缩短为 %.2f 秒。" % fifth_result_time, "Archer Lv.2 moved its one shot before the enemy.\nNo extra attack was added; battle time fell to %.2f seconds." % fifth_result_time)
|
||
"Kindhand":
|
||
return tr_text("治疗 Lv.2 把受伤原后排从 60 恢复到 %d。
|
||
恢复量是 +25;其他成员仍是 Lv.1。" % rear_hp, "Healer Lv.2 restored the damaged original rear from 60 to %d.\nThe heal was +25; all other members remain Lv.1." % rear_hp)
|
||
return ""
|
||
|
||
|
||
func member_levels_summary() -> String:
|
||
var parts: Array[String] = []
|
||
for slot in member_slots():
|
||
parts.append("%s %s Lv.%d" % [member_slot_label(slot), candidate_name(member_profession(slot)), member_level(slot)])
|
||
return " | ".join(parts) if locale == "zh_CN" else " | ".join(parts)
|
||
|
||
|
||
func route_name(id: String) -> String:
|
||
if not ROUTES.has(id):
|
||
return ""
|
||
return ROUTES[id]["name_zh"] if locale == "zh_CN" else ROUTES[id]["name_en"]
|
||
|
||
|
||
func route_pressure(id: String) -> String:
|
||
if not ROUTES.has(id):
|
||
return ""
|
||
return ROUTES[id]["pressure_zh"] if locale == "zh_CN" else ROUTES[id]["pressure_en"]
|
||
|
||
|
||
func route_exposure(id: String) -> String:
|
||
if not ROUTES.has(id):
|
||
return ""
|
||
return ROUTES[id]["exposure_zh"] if locale == "zh_CN" else ROUTES[id]["exposure_en"]
|
||
|
||
|
||
func route_reward(id: String) -> int:
|
||
return int(ROUTES[id]["reward"]) if ROUTES.has(id) else 0
|
||
|
||
|
||
func route_coverage_state(id: String) -> String:
|
||
var profile := selected_build_profile()
|
||
if id == "R6-01":
|
||
return "covered" if profile == "guarded" else ("partial" if profile == "full_line" else "gap")
|
||
if id == "R6-02":
|
||
return "covered" if profile == "tempo" else ("partial" if profile == "full_line" else "gap")
|
||
if id == "R6-03":
|
||
return "covered" if profile == "full_line" else "gap"
|
||
return "gap"
|
||
|
||
|
||
func route_coverage_label(id: String) -> String:
|
||
match route_coverage_state(id):
|
||
"covered":
|
||
return tr_text("应对充分", "COVERED")
|
||
"partial":
|
||
return tr_text("部分应对", "PARTIAL")
|
||
return tr_text("存在缺口", "GAP")
|
||
|
||
|
||
func route_coverage_reason(id: String) -> String:
|
||
var profile := selected_build_profile()
|
||
if id == "R6-01":
|
||
if profile == "guarded":
|
||
return tr_text("应对充分:钉阵胫甲直接降低正面粉碎。", "Covered: Frontpin directly reduces the front crush.")
|
||
if profile == "full_line":
|
||
return tr_text("部分应对:三人阵线能治疗,但不能减少首次重击。", "Partial: the full line heals after impact but cannot reduce it.")
|
||
return tr_text("存在缺口:晨钟加快行动,但不会减少正面伤害。", "Gap: Early Bell adds speed but does not reduce front damage.")
|
||
if id == "R6-02":
|
||
if profile == "tempo":
|
||
return tr_text("应对充分:晨钟让前排抢在速度爆发之前行动。", "Covered: Early Bell acts before the tempo burst.")
|
||
if profile == "full_line":
|
||
return tr_text("部分应对:完整后排能恢复,但不能打断爆发。", "Partial: the full rear line recovers but cannot interrupt the burst.")
|
||
return tr_text("存在缺口:钉阵胫甲抗重击,但不改变敌方先手。", "Gap: Frontpin resists force but does not answer enemy initiative.")
|
||
if profile == "full_line":
|
||
return tr_text("应对充分:弓手与治疗分担后排打击和恢复。", "Covered: Archer and Healer divide rear damage and recovery.")
|
||
return tr_text("存在缺口:两件前排装备都无法保护被标记的后排。", "Gap: neither front item protects the marked rear.")
|
||
|
||
|
||
func route_reason_short(id: String) -> String:
|
||
return tr_text("当前理由:%s" % route_coverage_label(id), "Why now: %s" % route_coverage_label(id))
|
||
|
||
|
||
func coverage_color(id: String) -> Color:
|
||
match route_coverage_state(id):
|
||
"covered":
|
||
return COLOR_TEAL
|
||
"partial":
|
||
return COLOR_GOLD
|
||
return COLOR_CRIMSON
|
||
|
||
|
||
func build_summary_short() -> String:
|
||
match selected_build_profile():
|
||
"guarded":
|
||
return tr_text("钉阵两人小队", "Frontpin two-person line")
|
||
"tempo":
|
||
return tr_text("晨钟两人小队", "Early Bell two-person line")
|
||
return tr_text("弓手 + 治疗三人小队", "Archer + Healer three-person line")
|
||
|
||
|
||
func build_summary_text() -> String:
|
||
var company_size := 3 if selected_build_profile() == "full_line" else 2
|
||
return tr_text("金币 %d | 小队 %d 人\n%s" % [coin, company_size, build_summary_short()], "Coin %d | Company %d\n%s" % [coin, company_size, build_summary_short()])
|
||
|
||
|
||
func route_board_guidance() -> String:
|
||
if route_choice.is_empty():
|
||
return tr_text("没有预选或推荐。比较理由、得到、代价与放弃。还不能确认:先选择一条路线。", "Nothing is preselected or recommended. Compare reason, gain, cost, and opportunity cost. Cannot confirm yet: choose a route.")
|
||
return tr_text("已选择“%s”。确认前仍可更换;金币与路线尚未改变。" % route_name(route_choice), "“%s” selected. You can still change it; Coin and route are unchanged." % route_name(route_choice))
|
||
|
||
|
||
func route_result_headline() -> String:
|
||
return tr_text("%s在%s的作用" % [build_summary_short(), route_name(route_choice)], "%s on %s" % [build_summary_short(), route_name(route_choice)])
|
||
|
||
|
||
func route_result_explanation() -> String:
|
||
return tr_text("压力:%s\n%s\n结果来自上一轮构筑与本轮压力;奖励只结算一次。" % [route_pressure(route_choice), route_coverage_reason(route_choice)], "Pressure: %s\n%s\nThe inherited build and route pressure caused this result; reward settled once." % [route_pressure(route_choice), route_coverage_reason(route_choice)])
|
||
|
||
|
||
func adaptation_name(id: String) -> String:
|
||
return tr_text("钉阵胫甲", "Frontpin Greaves") if id == "EQ-002" else tr_text("晨钟", "Early Bell")
|
||
|
||
|
||
func adaptation_option_title(id: String) -> String:
|
||
return tr_text("钉阵胫甲 · 稳住第一击", "Frontpin Greaves · Steady the First Hit") if id == "EQ-002" else tr_text("晨钟 · 抢到先手", "Early Bell · Seize Initiative")
|
||
|
||
|
||
func adaptation_reason(id: String) -> String:
|
||
return tr_text("当前理由:下一场会重击先行者", "Why now: next battle strikes the lead") if id == "EQ-002" else tr_text("当前理由:先在重击前造成 -28", "Why now: deal -28 before the heavy strike")
|
||
|
||
|
||
func adaptation_gain(id: String) -> String:
|
||
return tr_text("得到:先行者第一次受伤降为 -10", "Gain: the lead's first damage becomes -10") if id == "EQ-002" else tr_text("得到:先行者抢在敌人之前行动", "Gain: the lead acts before the enemy")
|
||
|
||
|
||
func adaptation_cost(id: String) -> String:
|
||
return tr_text("代价:小队下一次攻击更晚", "Cost: the Company's next strike is later") if id == "EQ-002" else tr_text("代价:先行者承受完整 -30 重击", "Cost: the lead takes the full -30 strike")
|
||
|
||
|
||
func adaptation_foregone(id: String) -> String:
|
||
return tr_text("放弃:先行者抢在敌人之前行动", "Give up: the lead acts before the enemy") if id == "EQ-002" else tr_text("放弃:第一次受伤降为 -10", "Give up: first damage becomes -10")
|
||
|
||
|
||
func adaptation_effect(id: String) -> String:
|
||
return tr_text("保护先行者 · 第一次伤害降为 -10", "Protect the lead · first damage becomes -10") if id == "EQ-002" else tr_text("让先行者抢在敌人之前攻击", "Move the lead's first attack before the enemy")
|
||
|
||
|
||
func adaptation_tradeoff(id: String) -> String:
|
||
return tr_text("代价:小队下一次攻击更晚", "Tradeoff: the next Company strike is later") if id == "EQ-002" else tr_text("代价:先行者承受完整 -30 重击", "Tradeoff: the lead takes the full -30 strike")
|
||
|
||
|
||
func adaptation_guidance() -> String:
|
||
if adaptation_choice.is_empty():
|
||
return tr_text("没有预选。比较当前理由、得到、代价与放弃。还不能确认:先选择一件装备。", "Nothing is preselected. Compare reason, gain, cost, and opportunity cost. Cannot confirm yet: choose one item.")
|
||
return tr_text("已选择“%s”。确认前仍可更换;金币与小队尚未改变。" % adaptation_name(adaptation_choice), "“%s” selected. You can still change it; Coin and Company are unchanged." % adaptation_name(adaptation_choice))
|
||
|
||
|
||
func second_result_explanation() -> String:
|
||
var member_line := ""
|
||
match rear_choice:
|
||
"Wayrunner":
|
||
member_line = tr_text("%s从第二位发动侧翼反击。" % candidate_name(rear_choice), "%s countered from second position." % candidate_name(rear_choice))
|
||
"Farstring":
|
||
member_line = tr_text("%s从第二位补上远程伤害。" % candidate_name(rear_choice), "%s added ranged damage from second position." % candidate_name(rear_choice))
|
||
"Kindhand":
|
||
member_line = tr_text("%s为%s恢复了 12 生命。" % [candidate_name(rear_choice), candidate_name(front_choice)], "%s restored 12 HP to %s." % [candidate_name(rear_choice), candidate_name(front_choice)])
|
||
if adaptation_choice == "EQ-002":
|
||
return tr_text("钉阵胫甲让%s受到的第一击从 -30 降到 -10。" % candidate_name(front_choice), "Frontpin reduced %s's first hit from -30 to -10." % candidate_name(front_choice)) + "\n" + member_line
|
||
return tr_text("晨钟让%s先造成 -28,但她承受了完整 -30 重击。" % candidate_name(front_choice), "Early Bell let %s deal -28 first, but she took the full -30 strike." % candidate_name(front_choice)) + "\n" + member_line
|
||
|
||
|
||
func later_preview_supports_pair() -> bool:
|
||
return formation_commit_count > 0 or (front_choice in ["Hearthguard", "Wayrunner"] and rear_choice in ["Farstring", "Kindhand"])
|
||
|
||
|
||
func guild_preview_order() -> Array[String]:
|
||
if guild_choice != "recruit_rear":
|
||
return visible_member_ids()
|
||
return session_service.ordered_with_addition(session_service.mapped_guild_recruit())
|
||
|
||
|
||
func guild_recruit_position(recruit_id: String) -> String:
|
||
var index := session_service.ordered_with_addition(recruit_id).find(recruit_id)
|
||
var labels_zh := ["先行位", "第二位", "第三位"]
|
||
var labels_en := ["lead", "second", "third"]
|
||
return (labels_zh if locale == "zh_CN" else labels_en)[index] if index >= 0 and index < 3 else ""
|
||
|
||
|
||
func guild_choice_guidance() -> String:
|
||
if guild_choice.is_empty():
|
||
return tr_text("没有预选。比较当前理由、得到、代价与放弃。还不能确认:先选择一种公会应对。", "Nothing is preselected. Compare reason, gain, cost, and opportunity cost. Cannot confirm yet: choose one Guild response.")
|
||
if guild_choice == "retain_item":
|
||
return tr_text("已选择“保留已验证装备”。确认前仍可更换;金币与小队尚未改变。", "“Keep the Proven Item” selected. You can still change it; Coin and Company are unchanged.")
|
||
var recruit_id := session_service.mapped_guild_recruit()
|
||
var order_names: Array[String] = []
|
||
for member_id in guild_preview_order():
|
||
order_names.append(candidate_name(member_id))
|
||
return tr_text("已选择“招募第三名成员”。%s成为%s;新顺序:%s;原两人相对顺序不变。确认前金币与小队尚未改变。" % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " → ".join(order_names)], "“Recruit a Third Member” selected. %s becomes %s; new order: %s; the original pair keeps its relative order. Coin and Company are unchanged before commit." % [candidate_name(recruit_id), guild_recruit_position(recruit_id), " -> ".join(order_names)])
|
||
|
||
|
||
func session_result_explanation() -> String:
|
||
if guild_choice == "retain_item":
|
||
if retained_item == "EQ-002":
|
||
return tr_text("你把金币用于保留钉阵胫甲。\n队伍仍是两人,但队长重击从 -30 降为 -10。", "You spent Coin to retain Frontpin.\nThe line stayed at two, but the captain strike fell from -30 to -10.")
|
||
return tr_text("你把金币用于保留晨钟。\n队伍仍是两人,但先手让战斗更快结束。", "You spent Coin to retain Early Bell.\nThe line stayed at two, but initiative ended the battle faster.")
|
||
return tr_text("你归还借用装备并招募了%s。\n弓手与治疗都行动:阵线更完整,但花费更多。" % candidate_name(added_rear), "You returned the loan and recruited %s.\nArcher and Healer both acted: a fuller line at greater cost." % candidate_name(added_rear))
|
||
|
||
|
||
func candidate_name(profession: String) -> String:
|
||
if profession.is_empty() or not CANDIDATES.has(profession):
|
||
return ""
|
||
return CANDIDATES[profession]["name_zh"] if locale == "zh_CN" else CANDIDATES[profession]["name_en"]
|
||
|
||
|
||
func candidate_role(profession: String) -> String:
|
||
return CANDIDATES[profession]["role_zh"] if locale == "zh_CN" else CANDIDATES[profession]["role_en"]
|
||
|
||
|
||
func candidate_role_short(profession: String) -> String:
|
||
match profession:
|
||
"Hearthguard":
|
||
return tr_text("盾卫", "Guardian")
|
||
"Wayrunner":
|
||
return tr_text("游侠", "Runner")
|
||
"Farstring":
|
||
return tr_text("弓手", "Archer")
|
||
"Kindhand":
|
||
return tr_text("治疗", "Healer")
|
||
return ""
|
||
|
||
|
||
func tr_text(zh: String, en: String) -> String:
|
||
return zh if locale == "zh_CN" else en
|
||
|
||
|
||
func portrait_layout() -> bool:
|
||
return size.y > size.x
|
||
|
||
|
||
func scaled(value: int) -> int:
|
||
return int(round(float(value) * text_scale))
|
||
|
||
|
||
func add_full_wash(color: Color) -> ColorRect:
|
||
var wash := ColorRect.new()
|
||
wash.color = color
|
||
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(wash)
|
||
return wash
|
||
|
||
|
||
func actor_at(texture: Texture2D, actor_position: Vector2, actor_size: Vector2, node_name: String) -> TextureRect:
|
||
var actor := TextureRect.new()
|
||
actor.name = node_name
|
||
actor.texture = texture
|
||
actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
actor.stretch_mode = TextureRect.STRETCH_SCALE
|
||
actor.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(actor)
|
||
actor.position = actor_position
|
||
actor.size = actor_size
|
||
return actor
|
||
|
||
|
||
func render_visible_party(prefix: String, start_position: Vector2, actor_height: float, spacing := -1.0) -> Array[TextureRect]:
|
||
var actors: Array[TextureRect] = []
|
||
var resolved_spacing := spacing if spacing >= 0.0 else actor_height * 0.88
|
||
var node_suffixes := ["Front", "Rear", "AddedRear"]
|
||
for index in range(visible_member_ids().size()):
|
||
var member_id := visible_member_id(index)
|
||
var scale: float = [1.0, 0.90, 0.84][index]
|
||
var actor_size: float = actor_height * scale
|
||
var actor := actor_at(
|
||
animation_texture(member_id, "victory" if index == 0 and member_id == "Hearthguard" else "idle", victory_clock),
|
||
start_position + Vector2(resolved_spacing * index, actor_height - actor_size),
|
||
Vector2(actor_size, actor_size),
|
||
prefix + node_suffixes[index]
|
||
)
|
||
actor.set_meta("member_id", member_id)
|
||
actors.append(actor)
|
||
return actors
|
||
|
||
|
||
func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> TextureRect:
|
||
var image := TextureRect.new()
|
||
image.name = "BattleVFX"
|
||
image.texture = texture
|
||
image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
image.stretch_mode = TextureRect.STRETCH_SCALE
|
||
image.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
display_root.add_child(image)
|
||
image.position = vfx_position
|
||
image.size = Vector2(diameter, diameter)
|
||
return image
|
||
|
||
|
||
func render_battle_feedback(actor_width: float, actor_height: float) -> void:
|
||
if active_battle_feedback.is_empty():
|
||
return
|
||
var target: String = active_battle_feedback["target"]
|
||
var delta: int = int(active_battle_feedback["delta"])
|
||
var target_position := front_base_position if target == "front" else (rear_base_position if target == "rear" else (added_rear_base_position if target == "added" else enemy_base_position))
|
||
var target_scale := 1.0 if target == "front" else (0.90 if target == "rear" else (0.86 if target == "added" 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_kind := String(active_battle_feedback.get("vfx", "heal" if delta > 0 else ("guard" if int(active_battle_feedback["phase"]) == 2 else "hit")))
|
||
var vfx_texture: Texture2D = VFX.get(vfx_kind, 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"
|
||
for key in ["encounter_id", "source_member_id", "target_member_id", "cue", "vfx"]:
|
||
if active_battle_feedback.has(key):
|
||
battle_feedback_label.set_meta(key, active_battle_feedback[key])
|
||
battle_feedback_vfx.set_meta(key, active_battle_feedback[key])
|
||
|
||
|
||
func update_battle_feedback_visual() -> void:
|
||
if active_battle_feedback.is_empty():
|
||
return
|
||
if battle_feedback_clock >= BATTLE_FEEDBACK_DURATION:
|
||
expire_battle_feedback()
|
||
return
|
||
var normalized := clampf(battle_feedback_clock / BATTLE_FEEDBACK_DURATION, 0.0, 1.0)
|
||
var fade := 1.0 if normalized < 0.58 else 1.0 - ((normalized - 0.58) / 0.42)
|
||
if battle_feedback_label != null and is_instance_valid(battle_feedback_label):
|
||
var rise := 0.0 if reduced_motion else 34.0 * (1.0 - pow(1.0 - normalized, 2.0))
|
||
battle_feedback_label.position = battle_feedback_origin - Vector2(0.0, rise)
|
||
var pop_scale := 1.0
|
||
if not reduced_motion:
|
||
pop_scale = lerpf(0.78, 1.12, battle_feedback_clock / 0.10) if battle_feedback_clock < 0.10 else lerpf(1.12, 1.0, clampf((battle_feedback_clock - 0.10) / 0.16, 0.0, 1.0))
|
||
battle_feedback_label.scale = Vector2.ONE * pop_scale
|
||
battle_feedback_label.modulate.a = clampf(fade, 0.0, 1.0)
|
||
if battle_feedback_vfx != null and is_instance_valid(battle_feedback_vfx):
|
||
battle_feedback_vfx.visible = battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW
|
||
battle_feedback_vfx.modulate.a = clampf(1.0 - battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW, 0.0, 1.0)
|
||
if not reduced_motion and battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW:
|
||
var recoil := sin(battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW * PI) * 8.0
|
||
if active_battle_feedback["target"] == "front" and front_sprite != null and is_instance_valid(front_sprite):
|
||
front_sprite.position.x = front_base_position.x - recoil
|
||
elif active_battle_feedback["target"] == "rear" and rear_sprite != null and is_instance_valid(rear_sprite):
|
||
rear_sprite.position.x = rear_base_position.x - recoil
|
||
elif active_battle_feedback["target"] == "added" and added_rear_sprite != null and is_instance_valid(added_rear_sprite):
|
||
added_rear_sprite.position.x = added_rear_base_position.x - recoil
|
||
elif active_battle_feedback["target"] == "enemy" and enemy_sprite != null and is_instance_valid(enemy_sprite):
|
||
enemy_sprite.position.x = enemy_base_position.x + recoil
|
||
|
||
|
||
func add_signal_label(value: String, label_position: Vector2, color: Color) -> Label:
|
||
var label := centered_label(value, 34, color)
|
||
label.position = label_position
|
||
label.size = Vector2(90, 60)
|
||
label.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.04, 0.08, 0.72), color, 2))
|
||
display_root.add_child(label)
|
||
return label
|
||
|
||
|
||
func add_health_bar(node_name: String, bar_position: Vector2, bar_size: Vector2, value: int, maximum: int, actor_name: String) -> void:
|
||
var shell := VBoxContainer.new()
|
||
shell.name = node_name
|
||
shell.position = bar_position
|
||
shell.size = bar_size + Vector2(0, 24)
|
||
display_root.add_child(shell)
|
||
var caption := game_label("%s %d/%d" % [actor_name, value, maximum], 11, COLOR_WHITE)
|
||
shell.add_child(caption)
|
||
var bar := ProgressBar.new()
|
||
bar.max_value = maximum
|
||
bar.value = value
|
||
bar.show_percentage = false
|
||
bar.custom_minimum_size.y = bar_size.y
|
||
bar.add_theme_stylebox_override("background", lacquer_style(Color("#14192a"), Color("#667085"), 1))
|
||
bar.add_theme_stylebox_override("fill", lacquer_style(COLOR_TEAL if value > 35 else COLOR_CRIMSON, COLOR_WHITE, 1))
|
||
shell.add_child(bar)
|
||
|
||
|
||
func game_label(value: String, font_size: int, color: Color) -> Label:
|
||
var label := WidgetsScript.label(value, scaled(font_size), color)
|
||
label.add_theme_font_size_override("font_size", scaled(font_size))
|
||
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 := WidgetsScript.button(node_name, label, callback)
|
||
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"))
|
||
return button
|
||
|
||
|
||
func ornate_button_style(primary: bool, state: String) -> StyleBoxFlat:
|
||
var fill := COLOR_GOLD if primary else Color("#1b3154")
|
||
var border := COLOR_GOLD_LIGHT if primary else Color("#7e9bb7")
|
||
if state == "hover" or state == "focus":
|
||
fill = COLOR_GOLD_LIGHT if primary else COLOR_INDIGO_HOVER
|
||
border = COLOR_WHITE
|
||
elif state == "pressed":
|
||
fill = COLOR_PARCHMENT_DARK if primary else Color("#10213c")
|
||
border = COLOR_GOLD
|
||
elif state == "disabled":
|
||
fill = Color("#3d4657")
|
||
border = Color("#697180")
|
||
var style := lacquer_style(fill, border, 2)
|
||
style.shadow_color = Color(0.01, 0.015, 0.03, 0.7)
|
||
style.shadow_size = 5 if state != "pressed" else 2
|
||
style.shadow_offset = Vector2(0, 3 if state != "pressed" else 1)
|
||
return style
|
||
|
||
|
||
func candidate_style(selected: bool, hover: bool, slot_color: Color) -> StyleBoxFlat:
|
||
var fill := Color(0.035, 0.065, 0.13, 0.88 if not hover else 0.96)
|
||
var border := COLOR_GOLD if selected else slot_color
|
||
var style := lacquer_style(fill, COLOR_WHITE if hover else border, 3 if selected else 2)
|
||
style.shadow_color = Color(0.01, 0.015, 0.03, 0.75)
|
||
style.shadow_size = 4
|
||
style.shadow_offset = Vector2(0, 2)
|
||
return style
|
||
|
||
|
||
func route_node_style(selected: bool, hover: bool, route_color: Color) -> StyleBoxFlat:
|
||
var fill := Color(0.03, 0.07, 0.12, 0.94 if not hover else 1.0)
|
||
var border := COLOR_GOLD_LIGHT if selected else route_color
|
||
var style := lacquer_style(fill, COLOR_WHITE if hover else border, 4 if selected else 2)
|
||
style.corner_radius_top_left = 42
|
||
style.corner_radius_top_right = 42
|
||
style.corner_radius_bottom_left = 42
|
||
style.corner_radius_bottom_right = 42
|
||
style.shadow_color = Color(0.01, 0.015, 0.03, 0.78)
|
||
style.shadow_size = 8 if selected else 5
|
||
style.shadow_offset = Vector2(0, 3)
|
||
return style
|
||
|
||
|
||
func dashed_slot_style(border: Color) -> StyleBoxFlat:
|
||
var style := lacquer_style(Color(0.035, 0.06, 0.12, 0.58), border, 2)
|
||
style.corner_radius_top_left = 28
|
||
style.corner_radius_top_right = 28
|
||
style.corner_radius_bottom_left = 28
|
||
style.corner_radius_bottom_right = 28
|
||
style.shadow_color = Color(0.01, 0.015, 0.03, 0.58)
|
||
style.shadow_size = 6
|
||
style.shadow_offset = Vector2(0, 3)
|
||
return style
|
||
|
||
|
||
func lacquer_style(fill: Color, border: Color, width: int) -> StyleBoxFlat:
|
||
return WidgetsScript.style(fill, border, width)
|
||
|
||
|
||
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:
|
||
audio_adapter.play(StringName(kind), muted)
|