Files
aetherbound-guild/runtime/p9_3/core_loop_controller.gd
T

1371 lines
64 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name P93CoreLoopController
extends Control
signal windglass_market_requested(snapshot: Dictionary)
const Service = preload("res://p9_3/core_loop_service.gd")
const State = preload("res://p9_3/core_loop_state.gd")
const Rules = preload("res://domain/p9_2/battle_rules.gd")
const AnimationAdapter = preload("res://presentation/animation_adapter.gd")
const SaveService = preload("res://p9_6/save_service.gd")
const HALL_BACKGROUND = preload("res://assets/backgrounds/p9_3_guild_hall.png")
const FIELD_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png")
const UI_FONT = preload("res://assets/fonts/NotoSansSC.ttf")
const HEAL_VFX = preload("res://assets/vfx/protect_gain.png")
const HIT_VFX = preload("res://assets/vfx/damage_heavy.png")
const FRONTPIN_TEXTURE = preload("res://assets/equipment/eq_002_frontpin_greaves.png")
const OPEN_LINE_TEXTURE = preload("res://assets/equipment/eq_012_open_line_cord.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 GOLD := Color("#f4c45c")
const GOLD_LIGHT := Color("#ffe8a3")
const INK := Color("#102033")
const WHITE := Color("#f8fbff")
const CYAN := Color("#73e3e6")
const CORAL := Color("#ff7478")
const JADE := Color("#75e0a4")
var service := Service.new()
var save_service := SaveService.new()
var animation := AnimationAdapter.new()
var display_root: Control
var ambient_actors: Dictionary = {}
var battle_actors: Dictionary = {}
var battle_hp_bars: Dictionary = {}
var battle_visual_x: Dictionary = {}
var battle_actions: Dictionary = {}
var battle_tick_accumulator := 0.0
var battle_visual_active := false
var battle_result_delay := 0.0
var battle_result_shown := false
var world_clock := 0.0
var shake_clock := 0.0
var locale := "zh_CN"
var text_scale := 1.0
var reduced_motion := false
var muted := true
var low_power := false
var speed_multiplier := 1
var paused := false
var returned_from_battle := false
var render_queued := false
var continue_snapshot: Dictionary = {}
@export var persistence_enabled := true
func _ready() -> void:
set_meta("visible_world_ratio", 0.88)
var ui_theme := Theme.new()
ui_theme.default_font = UI_FONT
theme = ui_theme
animation.load_specs(ANIMATION_SPECS)
if persistence_enabled:
var loaded: Dictionary = save_service.load_snapshot()
if loaded.ok:
continue_snapshot = Dictionary(loaded.snapshot).duplicate(true)
display_root = Control.new()
display_root.name = "P93DisplayRoot"
display_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(display_root)
resized.connect(_queue_render_current)
_render_current()
func _process(delta: float) -> void:
if not low_power:
world_clock += maxf(0.0, delta)
_update_ambient_actors()
if battle_visual_active:
_process_battle(delta)
if shake_clock > 0.0:
shake_clock = maxf(0.0, shake_clock - delta)
var strength := shake_clock / 0.16
display_root.position = Vector2(sin(world_clock * 90.0) * 4.0 * strength, cos(world_clock * 76.0) * 2.0 * strength)
elif display_root.position != Vector2.ZERO:
display_root.position = Vector2.ZERO
func enter_hall() -> void:
if persistence_enabled:
save_service.clear()
continue_snapshot.clear()
if service.enter_hall().ok:
_save_stable()
_queue_render_current()
func continue_saved_loop() -> void:
if continue_snapshot.is_empty() or not service.state.restore_snapshot(continue_snapshot):
continue_snapshot.clear()
_queue_render_current()
return
returned_from_battle = service.state.reward_receipts.size() > 0
_queue_render_current()
func tap_offer(member_id: String) -> void:
if service.state.phase != State.Phase.HALL:
return
if service.state.focused_offer == member_id:
var result: Dictionary = service.hire(member_id)
if result.ok:
_save_stable()
_queue_render_current()
return
if service.focus_offer(member_id).ok:
_queue_render_current()
func tap_equipment(item_id: String) -> void:
if service.focus_equipment(item_id).ok:
_queue_render_current()
func tap_equipment_wearer(member_id: String) -> void:
if service.equip_focused(member_id).ok:
_save_stable()
_queue_render_current()
func tap_rift_tide_plan(plan_id: String) -> void:
if service.choose_rift_tide_plan(plan_id).ok:
_save_stable()
_queue_render_current()
func recover_injured_company() -> void:
if service.recover_injured_company().ok:
_save_stable()
_queue_render_current()
func enter_windglass_market() -> void:
if service.state.phase != State.Phase.HALL or service.state.recovery_required or service.state.reward_receipts.size() < 4:
return
_save_stable()
windglass_market_requested.emit(service.state.snapshot())
func tap_encounter(encounter_id: String) -> void:
if service.state.phase != State.Phase.ENCOUNTER:
return
if service.state.selected_encounter == encounter_id:
start_selected_battle()
return
if service.select_encounter(encounter_id).ok:
_save_stable()
_queue_render_current()
func start_selected_battle() -> void:
_save_stable()
var result: Dictionary = service.start_battle()
if not result.ok:
return
battle_tick_accumulator = 0.0
battle_result_delay = 0.0
battle_result_shown = false
battle_visual_active = true
paused = false
speed_multiplier = 1
_queue_render_current()
func toggle_pause() -> void:
if service.state.phase != State.Phase.BATTLE:
return
paused = not paused
service.battle.set_paused(paused)
_update_battle_control_labels()
func toggle_speed() -> void:
if service.state.phase != State.Phase.BATTLE:
return
speed_multiplier = 2 if speed_multiplier == 1 else 1
_update_battle_control_labels()
func claim_and_return() -> void:
var result: Dictionary = service.claim_and_return()
if not result.ok:
return
battle_visual_active = false
returned_from_battle = true
_save_stable()
_queue_render_current()
func reset_loop() -> void:
if persistence_enabled:
save_service.clear()
continue_snapshot.clear()
service.state.reset()
service.battle.state.reset()
battle_visual_active = false
returned_from_battle = false
_queue_render_current()
func _notification(what: int) -> void:
if what in [NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_WM_CLOSE_REQUEST]:
_save_stable()
func _save_stable() -> bool:
if not persistence_enabled or service.state.phase not in [State.Phase.HALL, State.Phase.ENCOUNTER]:
return false
var result: Dictionary = save_service.save_snapshot(service.state.snapshot())
return bool(result.ok)
func _queue_render_current() -> void:
if render_queued:
return
render_queued = true
call_deferred("_deferred_render_current")
func _deferred_render_current() -> void:
render_queued = false
_render_current()
func _render_current() -> void:
if display_root == null:
return
_clear_display()
if size.y > size.x:
_render_rotate_notice()
return
match service.state.phase:
State.Phase.TITLE:
_render_title()
State.Phase.HALL:
_render_hall()
State.Phase.ENCOUNTER:
_render_encounters()
State.Phase.BATTLE, State.Phase.RESULT:
_render_battle()
func _clear_display() -> void:
ambient_actors.clear()
battle_actors.clear()
battle_hp_bars.clear()
battle_visual_x.clear()
for child in display_root.get_children():
child.free()
func _render_title() -> void:
_add_background(HALL_BACKGROUND)
var wash := ColorRect.new()
wash.name = "P93TitleWash"
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.color = Color(0.01, 0.04, 0.09, 0.34)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(wash)
_add_actor("Hearthguard", Vector2(size.x * 0.63, size.y * 0.49), size.y * 0.48, "P93TitleHearthguard", true)
_add_actor("Farstring", Vector2(size.x * 0.73, size.y * 0.53), size.y * 0.43, "P93TitleFarstring", true)
var title := _label(_tr("裂脉远征团", "AETHERBOUND GUILD"), 38, WHITE)
title.name = "P93Title"
title.position = Vector2(size.x * 0.055, size.y * 0.24)
title.size = Vector2(size.x * 0.52, 58)
display_root.add_child(title)
var subtitle := _label(_tr("招募队员 · 排列队伍 · 观看他们战斗", "RECRUIT · ORDER · WATCH THEM FIGHT"), 14, CYAN)
subtitle.position = Vector2(size.x * 0.058, size.y * 0.39)
subtitle.size = Vector2(size.x * 0.48, 34)
display_root.add_child(subtitle)
var has_continue := not continue_snapshot.is_empty()
var start := _action_button("P93EnterHallButton", _tr("新远征", "NEW EXPEDITION") if has_continue else _tr("进入公会", "ENTER THE GUILD"), enter_hall)
start.position = Vector2(size.x * 0.058, size.y * (0.55 if has_continue else 0.62))
start.size = Vector2(minf(270.0, size.x * 0.34), 56)
display_root.add_child(start)
if has_continue:
var continue_button := _action_button("P96ContinueButton", _tr("继续远征", "CONTINUE EXPEDITION"), continue_saved_loop)
continue_button.position = Vector2(size.x * 0.058, size.y * 0.73)
continue_button.size = Vector2(minf(270.0, size.x * 0.34), 52)
display_root.add_child(continue_button)
func _render_hall() -> void:
if service.equipment_choice_available():
_render_equipment_hall()
return
if service.state.recovery_required:
_render_injury_recovery_hall()
return
if service.state.reward_receipts.size() >= 4:
_render_rift_tide_return_hall()
return
if service.state.reward_receipts.size() == 3 and service.state.equipment_receipts.size() == 1:
_render_growth_return_hall()
return
_add_background(HALL_BACKGROUND)
_add_compact_hud(_tr("公会大厅", "GUILD HALL"), service.state.coin, service.state.company.size())
var company_caption := _label(_tr("你的队伍", "YOUR COMPANY"), 13, GOLD_LIGHT)
company_caption.position = Vector2(28, size.y - 62)
company_caption.size = Vector2(size.x * 0.30, 30)
display_root.add_child(company_caption)
var actor_height := clampf(size.y * 0.36, 118.0, 146.0)
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
_add_actor(member_id, Vector2(52 + index * actor_height * 0.62, size.y - actor_height - 18), actor_height, "P93Company_%s" % member_id, true)
if returned_from_battle and service.state.focused_offer.is_empty():
var returned := _outlined_label(_tr("远征归来 +%d 金币" % service.state.last_reward, "RETURNED +%d COIN" % service.state.last_reward), 17, GOLD_LIGHT)
returned.name = "P93ReturnReceipt"
returned.position = Vector2(size.x * 0.34, 72)
returned.size = Vector2(size.x * 0.34, 42)
display_root.add_child(returned)
var offers: Array[String] = service.state.available_offers
if offers.is_empty():
return
var offer_start := size.x * 0.48
var offer_spacing := size.x * 0.16
for index in range(offers.size()):
var member_id: String = offers[index]
var focused := service.state.focused_offer == member_id
var actor_pos := Vector2(offer_start + index * offer_spacing, size.y - actor_height - (32 if focused else 18))
_add_ground_ring(actor_pos + Vector2(actor_height * 0.13, actor_height * 0.84), actor_height * 0.76, GOLD if focused else Color(0.2, 0.75, 0.82, 0.62), "P93OfferRing_%s" % member_id)
_add_actor(member_id, actor_pos, actor_height, "P93OfferActor_%s" % member_id, true)
var offer: Dictionary = service.offer(member_id)
var caption := _outlined_label("%s ◆%d" % [_offer_name(member_id), int(offer.cost)], 11, GOLD_LIGHT if focused else WHITE)
caption.name = "P93OfferCaption_%s" % member_id
caption.position = Vector2(actor_pos.x - 8, actor_pos.y - 28)
caption.size = Vector2(actor_height + 16, 28)
caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(caption)
var target := _actor_target("P93OfferTarget_%s" % member_id, actor_pos, Vector2(actor_height, actor_height), tap_offer.bind(member_id))
target.tooltip_text = _tr("点击查看;再次点击招募", "Tap to inspect; tap again to recruit")
display_root.add_child(target)
var incoming_crosswind := _incoming_crosswind_visible()
if incoming_crosswind:
_render_incoming_crosswind()
if service.state.focused_offer.is_empty():
if not incoming_crosswind:
var hint := _outlined_label(_tr("点击一名候选人", "TAP A RECRUIT"), 17, WHITE)
hint.name = "P93HallHint"
hint.position = Vector2(size.x * 0.43, 72)
hint.size = Vector2(size.x * 0.50, 40)
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(hint)
else:
var focused_id := service.state.focused_offer
var data: Dictionary = service.offer(focused_id)
var detail := _outlined_label(
"%s ◆ %d\n%s · %s\n%s" % [
_offer_name(focused_id), int(data.cost),
_tr(String(data.strength_zh), String(data.strength_en)),
_tr(String(data.limit_zh), String(data.limit_en)),
_tr("再次点击本人即可招募", "TAP THIS PERSON AGAIN TO HIRE"),
],
12,
GOLD_LIGHT
)
detail.name = "P93OfferDetail"
detail.position = Vector2(size.x * (0.28 if incoming_crosswind else 0.36), 62)
detail.size = Vector2(size.x * (0.46 if incoming_crosswind else 0.61), 74)
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(detail)
func _incoming_crosswind_visible() -> bool:
return service.state.phase == State.Phase.HALL and service.state.reward_receipts.size() == 1 and service.state.company.size() == 2
func _render_incoming_crosswind() -> void:
var threat_label := _outlined_label(_tr("侧风隘口逼近\n近战 + 远程 ◆ +12", "CROSSWIND APPROACHES\nCONTACT + RANGED ◆ +12"), 11, CORAL)
threat_label.name = "P94IncomingThreat"
threat_label.position = Vector2(size.x - 210, 72)
threat_label.size = Vector2(190, 56)
threat_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(threat_label)
var ground_center := Vector2(size.x - 100, size.y - 48)
_add_ground_ring(ground_center, 112, CORAL, "P94IncomingThreatMark")
var lead := _add_actor("Riftblade", Vector2(size.x - 168, size.y - 126), 72, "P94IncomingEnemy_0", true)
lead.modulate = Color(1.0, 0.82, 0.88, 1.0)
var rear := _add_actor("Riftblade", Vector2(size.x - 108, size.y - 112), 60, "P94IncomingEnemy_1", true)
rear.modulate = Color(0.78, 0.86, 1.0, 1.0)
func _render_equipment_hall() -> void:
_add_background(HALL_BACKGROUND)
_add_compact_hud(_tr("钟门战前整备", "BEFORE BELL BREACH"), service.state.coin, service.state.company.size())
var actor_height := clampf(size.y * 0.27, 100.0, 112.0)
var actor_positions: Dictionary = {}
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
var actor_position := Vector2(16 + index * 88, size.y - actor_height - 18)
actor_positions[member_id] = actor_position
_add_actor(member_id, actor_position, actor_height, "P93Company_%s" % member_id, true)
_add_member_status(member_id, actor_position + Vector2(-4, -42), Vector2(actor_height + 8, 42), "P95MemberStatus_%s" % member_id)
var threat := _outlined_label(_tr("钟门裂口逼近\n第一击会推开前排", "BELL BREACH APPROACHES\nFIRST CONTACT PUSHES THE LEAD"), 11, CORAL)
threat.name = "P95BellThreat"
threat.position = Vector2(size.x - 228, 66)
threat.size = Vector2(210, 58)
threat.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(threat)
_add_ground_ring(Vector2(size.x - 104, size.y - 48), 118, CORAL, "P95BellThreatMark")
var bell_lead := _add_actor("Riftblade", Vector2(size.x - 176, size.y - 128), 76, "P95BellEnemy_0", true)
bell_lead.modulate = Color(1.0, 0.75, 0.82, 1.0)
var bell_rear := _add_actor("Riftblade", Vector2(size.x - 112, size.y - 114), 62, "P95BellEnemy_1", true)
bell_rear.modulate = Color(0.78, 0.86, 1.0, 1.0)
var item_ids := ["EQ-002", "EQ-012"]
for index in range(item_ids.size()):
var item_id: String = item_ids[index]
var focused := service.state.focused_equipment == item_id
var center := Vector2(size.x * 0.42 + index * 126, size.y * 0.66)
_add_ground_ring(center + Vector2(0, 24), 88 if focused else 72, GOLD if focused else CYAN, "P95EquipmentRing_%s" % item_id.replace("-", "_"))
var icon := TextureRect.new()
icon.name = "P95EquipmentIcon_%s" % item_id.replace("-", "_")
icon.custom_minimum_size = Vector2.ZERO
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.texture = _equipment_texture(item_id)
icon.position = center - Vector2(48, 70 if focused else 62)
icon.size = Vector2(96, 96)
icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(icon)
var data: Dictionary = service.equipment(item_id)
var caption := _outlined_label("%s\n%s" % [_tr(String(data.name_zh), String(data.name_en)), _tr("稳住队列 · 较慢", "HOLD LINE · SLOWER") if item_id == "EQ-002" else _tr("借推抢射 · 弓手暴露", "PUSH SHOT · EXPOSE ARCHER")], 10, GOLD_LIGHT if focused else WHITE)
caption.name = "P95EquipmentCaption_%s" % item_id.replace("-", "_")
caption.position = center + Vector2(-65, 20)
caption.size = Vector2(130, 48)
caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(caption)
var target := _actor_target("P95EquipmentTarget_%s" % item_id.replace("-", "_"), center + Vector2(-54, -72), Vector2(108, 142), tap_equipment.bind(item_id))
target.tooltip_text = _tr("点击查看这件装备", "Tap to inspect this item")
display_root.add_child(target)
if service.state.focused_equipment.is_empty():
var hint := _outlined_label(_tr("点击一件战利品", "TAP ONE SALVAGED ITEM"), 15, GOLD_LIGHT)
hint.name = "P95EquipmentHint"
hint.position = Vector2(size.x * 0.36, 70)
hint.size = Vector2(size.x * 0.30, 40)
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(hint)
else:
var item_id := service.state.focused_equipment
var data: Dictionary = service.equipment(item_id)
var wearer := String(data.wearer)
var wearer_position: Vector2 = actor_positions[wearer]
_add_ground_ring(wearer_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.82, GOLD, "P95CompatibleWearerMark")
var wearer_target := _actor_target("P95WearerTarget_%s" % wearer, wearer_position, Vector2(actor_height, actor_height), tap_equipment_wearer.bind(wearer))
wearer_target.tooltip_text = _tr("点击发光的队员穿戴", "Tap the marked Recruit to equip")
display_root.add_child(wearer_target)
var detail := _outlined_label("%s\n%s · %s\n%s" % [_tr(String(data.name_zh), String(data.name_en)), _tr(String(data.benefit_zh), String(data.benefit_en)), _tr(String(data.cost_zh), String(data.cost_en)), _tr("点击发光的队员穿戴", "TAP THE MARKED RECRUIT")], 10, GOLD_LIGHT)
detail.name = "P95EquipmentDetail"
detail.position = Vector2(size.x * 0.35, 58)
detail.size = Vector2(size.x * 0.35, 68)
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(detail)
func _render_growth_return_hall() -> void:
_add_background(HALL_BACKGROUND)
_add_compact_hud(_tr("裂潮逼近", "RIFT TIDE APPROACHES"), service.state.coin, service.state.company.size())
var headline := _outlined_label(_tr("钟门凯旋 · 伤势带入裂潮", "BELL CLEARED · WOUNDS CARRY FORWARD"), 16, GOLD_LIGHT)
headline.name = "P95GrowthHeadline"
headline.position = Vector2(size.x * 0.06, 60)
headline.size = Vector2(size.x * 0.54, 44)
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(headline)
var actor_height := clampf(size.y * 0.31, 112.0, 190.0)
var spacing := size.x * 0.18
var start_x := size.x * 0.055
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.80, GOLD if not String(service.state.equipped_items[member_id]).is_empty() else CYAN, "P95GrowthRing_%s" % member_id)
_add_actor(member_id, actor_position, actor_height, "P93Company_%s" % member_id, true, "victory" if member_id == "Hearthguard" else "idle")
_add_member_status(member_id, actor_position + Vector2(-8, -48), Vector2(actor_height + 16, 38), "P95GrowthStatus_%s" % member_id, true)
_add_member_condition(member_id, actor_position + Vector2(-8, -16), Vector2(actor_height + 16, 30), "P96Condition_%s" % member_id)
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.38))
var plan_caption := _outlined_label(_tr("裂潮计划 · 现在选择", "RIFT TIDE PLAN · CHOOSE NOW"), 13, WHITE)
plan_caption.name = "P96PlanHeadline"
plan_caption.position = Vector2(size.x * 0.64, 62)
plan_caption.size = Vector2(size.x * 0.34, 36)
plan_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(plan_caption)
_render_plan_target(
"press_on",
Vector2(size.x * 0.70, size.y * 0.67),
_tr("直接追击", "PRESS ON"),
_tr("0 金币 · 保留伤势", "0 COIN · KEEP HP"),
CORAL
)
_render_plan_target(
"recover",
Vector2(size.x * 0.88, size.y * 0.67),
_tr("整队恢复", "RECOVER FIRST"),
_tr("6 金币 · 满 HP", "6 COIN · FULL HP"),
JADE
)
func _render_plan_target(plan_id: String, center: Vector2, title_text: String, consequence: String, color: Color) -> void:
var visual_scale := clampf(size.y / 390.0, 0.92, 1.55)
_add_ground_ring(center + Vector2(0, 32 * visual_scale), 112 * visual_scale, color, "P96PlanRing_%s" % plan_id)
if plan_id == "press_on":
var lead := _add_actor("Riftblade", center + Vector2(-49, -56) * visual_scale, 60 * visual_scale, "P96PlanEnemy_0", true, "advance")
lead.modulate = Color(1.0, 0.76, 0.82, 1.0)
var rear := _add_actor("Riftblade", center + Vector2(4, -44) * visual_scale, 48 * visual_scale, "P96PlanEnemy_1", true, "advance")
rear.modulate = Color(0.78, 0.86, 1.0, 1.0)
else:
var recovery_icon := TextureRect.new()
recovery_icon.name = "P96PlanRecoveryIcon"
recovery_icon.texture = HEAL_VFX
recovery_icon.custom_minimum_size = Vector2.ZERO
recovery_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
recovery_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
recovery_icon.position = center + Vector2(-42, -58) * visual_scale
recovery_icon.size = Vector2(84, 84) * visual_scale
recovery_icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
recovery_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(recovery_icon)
var caption := _outlined_label("%s\n%s" % [title_text, consequence], 10, color)
caption.name = "P96PlanCaption_%s" % plan_id
caption.position = center + Vector2(-68, 36 * visual_scale)
caption.size = Vector2(136, 48)
caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(caption)
var target := _actor_target("P96PlanTarget_%s" % plan_id, center + Vector2(-62, -68) * visual_scale, Vector2(124, 156) * visual_scale, tap_rift_tide_plan.bind(plan_id))
target.tooltip_text = "%s · %s" % [title_text, consequence]
display_root.add_child(target)
func _render_injury_recovery_hall() -> void:
_add_background(HALL_BACKGROUND)
var defeated := service.state.last_outcome == "defeat"
var cost := service.injury_recovery_cost()
_add_compact_hud(_tr("裂潮败退", "RIFT TIDE DEFEAT") if defeated else _tr("裂潮凯旋", "RIFT TIDE CLEARED"), service.state.coin, service.state.company.size())
var headline_text := _tr("两波击溃了队伍 · 无奖励、无人丢失", "TWO WAVES BROKE THE LINE · NO REWARD, NO ONE LOST") if defeated else _tr("胜利 +20 金币 · 倒下队员等待恢复", "VICTORY +20 COIN · FALLEN RECRUITS NEED RECOVERY")
var headline := _outlined_label(headline_text, 16, CORAL if defeated else GOLD_LIGHT)
headline.name = "P96RecoveryHeadline"
headline.position = Vector2(size.x * 0.17, 62)
headline.size = Vector2(size.x * 0.66, 44)
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(headline)
var actor_height := clampf(size.y * 0.33, 118.0, 134.0)
var spacing := size.x * 0.19
var start_x := size.x * 0.10
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
var injured := member_id in service.state.injured_members
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.78, CORAL if injured else JADE, "P96RecoveryRing_%s" % member_id)
var actor := _add_actor(member_id, actor_position, actor_height, "P96RecoveryActor_%s" % member_id, true)
actor.modulate = Color(0.72, 0.72, 0.78, 1.0) if injured else Color.WHITE
_add_member_condition(member_id, actor_position + Vector2(-8, -18), Vector2(actor_height + 16, 34), "P96RecoveryCondition_%s" % member_id)
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.40))
var recovery_label := _outlined_label(
_tr("支付 %d 金币,全员恢复;失败重试不会重复奖励" % cost, "PAY %d COIN TO RESTORE ALL; RETRY CANNOT DUPLICATE REWARDS" % cost),
12,
JADE
)
recovery_label.name = "P96RecoveryDetail"
recovery_label.position = Vector2(size.x * 0.58, size.y * 0.43)
recovery_label.size = Vector2(size.x * 0.38, 52)
recovery_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(recovery_label)
var recovery_button := _action_button("P96RecoveryButton", _tr("恢复队伍 ◆ %d" % cost, "RECOVER COMPANY ◆ %d" % cost), recover_injured_company)
recovery_button.position = Vector2(size.x * 0.66, size.y * 0.68)
recovery_button.size = Vector2(size.x * 0.25, 54)
recovery_button.disabled = service.state.coin < cost
display_root.add_child(recovery_button)
func _render_rift_tide_return_hall() -> void:
_add_background(HALL_BACKGROUND)
_add_compact_hud(_tr("裂潮归来", "RIFT TIDE RETURN"), service.state.coin, service.state.company.size())
var receipt: Dictionary = service.state.rift_tide_result_receipts[-1] if not service.state.rift_tide_result_receipts.is_empty() else {}
var final_hp: Dictionary = Dictionary(receipt.get("member_hp", service.state.member_condition)).duplicate(true)
var xp_members: Array = []
if not service.state.xp_receipts.is_empty():
xp_members = Array(service.state.xp_receipts[-1].get("members", []))
var xp_names: Array[String] = []
for row in xp_members:
xp_names.append(_member_role_name(String(row.get("member_id", ""))))
var headline := _outlined_label(_tr("裂潮已平息 · +20 金币", "RIFT TIDE BROKEN · +20 COIN"), 19, GOLD_LIGHT)
headline.name = "P96FinalHeadline"
headline.position = Vector2(size.x * 0.18, 60)
headline.size = Vector2(size.x * 0.64, 44)
headline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(headline)
var xp_text := ", ".join(xp_names)
var detail := _outlined_label(
_tr("存活者 +18 XP:%s\n所有队员与装备均已保留" % xp_text, "SURVIVORS +18 XP: %s\nALL RECRUITS AND EQUIPMENT PRESERVED" % xp_text),
11,
JADE
)
detail.name = "P96FinalDetail"
detail.position = Vector2(size.x * 0.24, 99)
detail.size = Vector2(size.x * 0.52, 50)
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(detail)
var actor_height := clampf(size.y * 0.34, 120.0, 138.0)
var spacing := size.x * 0.24
var start_x := size.x * 0.14
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
var actor_position := Vector2(start_x + index * spacing, size.y - actor_height - 18)
_add_ground_ring(actor_position + Vector2(actor_height * 0.5, actor_height * 0.90), actor_height * 0.80, GOLD if not String(service.state.equipped_items[member_id]).is_empty() else JADE, "P96FinalRing_%s" % member_id)
_add_actor(member_id, actor_position, actor_height, "P96FinalActor_%s" % member_id, true, "victory" if member_id == "Hearthguard" else "idle")
var maximum := int(Rules.member_definition(member_id).get("max_hp", 0))
var battle_hp := int(final_hp.get(member_id, 0))
var condition := _outlined_label(_tr("战斗末 %d/%d HP" % [battle_hp, maximum], "BATTLE END %d/%d HP" % [battle_hp, maximum]), 9, CORAL if battle_hp <= 0 else JADE)
condition.name = "P96FinalCondition_%s" % member_id
condition.position = actor_position + Vector2(-12, -18)
condition.size = Vector2(actor_height + 24, 32)
condition.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(condition)
_add_member_status(member_id, actor_position + Vector2(-12, actor_height - 30), Vector2(actor_height + 24, 42), "P96FinalStatus_%s" % member_id, true)
_add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.38))
var continue_button := _action_button("P97EnterMarketButton", _tr("前往风晶集市", "ENTER WINDGLASS MARKET"), enter_windglass_market)
continue_button.position = Vector2(size.x - 232, 156)
continue_button.size = Vector2(216, 52)
display_root.add_child(continue_button)
func _render_encounters() -> void:
_add_background(HALL_BACKGROUND)
var p95_bell_only := service.state.reward_receipts.size() == 2 and service.state.equipment_receipts.size() == 1
var p96_rift_only := service.state.reward_receipts.size() == 3 and not service.state.rift_tide_plan.is_empty()
var encounter_context := _tr("裂潮追击 · 连续两波", "RIFT TIDE · TWO WAVES") if p96_rift_only else (_tr("检验新装备", "TEST THE NEW EQUIPMENT") if p95_bell_only else _tr("选择下一场战斗", "CHOOSE THE NEXT FIGHT"))
_add_compact_hud(encounter_context, service.state.coin, service.state.company.size())
var actor_height := 100.0 if p95_bell_only or p96_rift_only else clampf(size.y * 0.30, 100.0, 126.0)
for index in range(service.state.company.size()):
var member_id: String = service.state.company[index]
var actor_position := Vector2(16 + index * 100, size.y - actor_height - 16) if p95_bell_only or p96_rift_only else Vector2(36 + index * actor_height * 0.57, size.y - actor_height - 16)
_add_actor(member_id, actor_position, actor_height, "P93Ready_%s" % member_id, true, "advance")
if p95_bell_only:
_add_member_status(member_id, actor_position + Vector2(0, -40), Vector2(96, 40), "P95ReadyStatus_%s" % member_id, true)
elif p96_rift_only:
_add_member_condition(member_id, actor_position + Vector2(0, -34), Vector2(96, 34), "P96ReadyCondition_%s" % member_id)
var encounter_ids := ["rift_tide"] if p96_rift_only else (["bell_breach"] if p95_bell_only else ["open_road", "crosswind", "bell_breach"])
var start_x := size.x * (0.73 if p95_bell_only or p96_rift_only else 0.43)
var spacing := size.x * 0.18
for index in range(encounter_ids.size()):
var encounter_id: String = encounter_ids[index]
var selected := service.state.selected_encounter == encounter_id
var data: Dictionary = service.encounter(encounter_id)
var center := Vector2(start_x + index * spacing, size.y * 0.67)
_add_ground_ring(center, 92.0 if selected else 78.0, CORAL if encounter_id == "bell_breach" else (GOLD if selected else Color(0.35, 0.82, 0.92, 0.68)), "P93EncounterRing_%s" % encounter_id)
var enemy_count := 1 if encounter_id == "open_road" else 2
for enemy_index in range(enemy_count):
var enemy_size := actor_height * (0.58 if enemy_index == 0 else 0.48)
var enemy_pos := Vector2(center.x - enemy_size * 0.58 + enemy_index * 34, size.y - enemy_size - 58 - enemy_index * 10)
var enemy := _add_actor("Riftblade", enemy_pos, enemy_size, "P93EncounterEnemy_%s_%d" % [encounter_id, enemy_index], true)
enemy.modulate = Color(1.0, 0.75, 0.82, 1.0) if encounter_id in ["bell_breach", "rift_tide"] else Color.WHITE
var caption := _outlined_label("%s\n%s ◆ +%d" % [_encounter_name(encounter_id), _tr(String(data.pressure_zh), String(data.pressure_en)), int(data.reward)], 10, GOLD_LIGHT if selected else WHITE)
caption.name = "P93EncounterCaption_%s" % encounter_id
caption.position = Vector2(center.x - 70, 66)
caption.size = Vector2(140, 58)
caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(caption)
var target := _actor_target("P93EncounterTarget_%s" % encounter_id, Vector2(center.x - 64, 58), Vector2(128, size.y - 82), tap_encounter.bind(encounter_id))
target.tooltip_text = _tr("点击预览;再次点击出战", "Tap to preview; tap again to fight")
display_root.add_child(target)
var instruction_text := _tr("点击一个敌群", "TAP AN ENEMY GROUP") if service.state.selected_encounter.is_empty() else _tr("再次点击同一敌群,立即出战", "TAP THE SAME GROUP AGAIN TO FIGHT")
var instruction := _outlined_label(instruction_text, 15, GOLD_LIGHT)
instruction.name = "P93EncounterHint"
instruction.position = Vector2(size.x * 0.36, size.y - 47)
instruction.size = Vector2(size.x * 0.60, 34)
instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(instruction)
if p95_bell_only:
var item_id := String(service.state.equipment_receipts[0].item_id)
var item: Dictionary = service.equipment(item_id)
var equipment_read := _outlined_label("%s → %s\n%s" % [_tr(String(item.name_zh), String(item.name_en)), _member_name(String(item.wearer)), _tr(String(item.benefit_zh), String(item.benefit_en))], 11, GOLD_LIGHT)
equipment_read.name = "P95CommittedEquipment"
equipment_read.position = Vector2(size.x * 0.40, 70)
equipment_read.size = Vector2(size.x * 0.28, 62)
equipment_read.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(equipment_read)
elif p96_rift_only:
var plan_read := _outlined_label(
_tr("已恢复后重试\n敌人仅剩裂潮" if service.state.rift_tide_plan == "recovered_after_defeat" else ("已选择:%s\n敌人仅剩裂潮" % ("整队恢复" if service.state.rift_tide_plan == "recover" else "直接追击")), "RECOVERED RETRY\nONLY RIFT TIDE REMAINS" if service.state.rift_tide_plan == "recovered_after_defeat" else ("PLAN: %s\nONLY RIFT TIDE REMAINS" % ("RECOVER FIRST" if service.state.rift_tide_plan == "recover" else "PRESS ON"))),
11,
JADE if service.state.rift_tide_plan in ["recover", "recovered_after_defeat"] else CORAL
)
plan_read.name = "P96CommittedPlan"
plan_read.position = Vector2(size.x * 0.42, 72)
plan_read.size = Vector2(size.x * 0.26, 58)
plan_read.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(plan_read)
func _render_battle() -> void:
_add_background(FIELD_BACKGROUND)
_add_battle_controls()
battle_visual_active = true
var actor_height := clampf(size.y * 0.34, 112.0, 158.0)
for member_id in service.battle.state.member_ids:
var actor := _add_actor(member_id, Vector2.ZERO, actor_height, "P93BattleMember_%s" % member_id, false)
battle_actors[member_id] = actor
battle_visual_x[member_id] = _battle_x(int(service.battle.state.member_positions.get(member_id, 0)))
battle_hp_bars[member_id] = _add_actor_health(member_id, JADE)
for enemy_id in service.battle.state.enemy_ids:
if int(service.battle.state.enemy_hp.get(enemy_id, 0)) <= 0:
continue
var actor := _add_actor("Riftblade", Vector2.ZERO, actor_height * (0.90 if enemy_id == "RiftBulwark" else 0.76), "P93BattleEnemy_%s" % enemy_id, false)
actor.modulate = Color.WHITE if enemy_id == "RiftBulwark" else Color(0.78, 0.86, 1.0, 1.0)
battle_actors[enemy_id] = actor
battle_visual_x[enemy_id] = _battle_x(int(service.battle.state.enemy_positions.get(enemy_id, 100)))
battle_hp_bars[enemy_id] = _add_actor_health(enemy_id, CORAL)
_update_battle_visuals(0.0)
if service.state.phase == State.Phase.RESULT:
_show_battle_result()
func _process_battle(delta: float) -> void:
_update_battle_visuals(delta)
if service.state.phase == State.Phase.RESULT:
if not battle_result_shown:
battle_result_delay += delta
if battle_result_delay >= (0.18 if reduced_motion else 0.72):
_show_battle_result()
return
if paused:
return
battle_tick_accumulator += maxf(0.0, delta) * float(speed_multiplier)
while battle_tick_accumulator >= Rules.TICK_SECONDS and service.state.phase == State.Phase.BATTLE:
battle_tick_accumulator -= Rules.TICK_SECONDS
var result: Dictionary = service.advance_battle_ticks(1)
for event in result.get("events", []):
_handle_battle_event(event)
func _handle_battle_event(event: Dictionary) -> void:
match String(event.get("kind", "")):
"wave_started":
_clear_battle_feedback()
battle_actions.clear()
var context := find_child("P93BattleContext", true, false) as Label
if context != null:
context.text = _battle_context_text()
_spawn_word(_tr("第二波来袭!", "WAVE 2 INCOMING!"), Vector2(size.x * 0.42, size.y * 0.26), CORAL, "P96WaveCue")
"contact":
_spawn_word(_tr("接敌!", "CONTACT!"), Vector2(size.x * 0.49, size.y * 0.46), GOLD_LIGHT)
"equipment_trigger":
if String(event.get("qualifier_en", "")) == "push resisted":
_set_action("Hearthguard", "guard", 0.64)
_spawn_word(_tr("稳住队列!", "LINE HELD!"), Vector2(size.x * 0.31, size.y * 0.43), GOLD_LIGHT, "P95BellCue_Frontpin")
_spawn_vfx("Hearthguard", HEAL_VFX)
"line_push":
_set_action("Hearthguard", "guard", 0.42)
_set_action("Farstring", "advance", 0.48)
_spawn_word(_tr("前排被推开!", "LEAD PUSHED!"), Vector2(size.x * 0.39, size.y * 0.43), CORAL, "P95BellCue_Push")
"member_action":
var source := String(event.get("source_member_id", ""))
_set_action(source, _member_action_state(source), 0.52)
if source == "Wayrunner":
_spawn_role_word(source, _tr("反击!", "COUNTER!"), GOLD_LIGHT)
if event.has("target_enemy_id"):
var target_enemy := String(event.target_enemy_id)
_set_action(target_enemy, "hit", 0.30)
_spawn_number(int(event.delta), target_enemy, CORAL)
if source == "Farstring":
_spawn_projectile(source, target_enemy)
if String(event.get("qualifier_en", "")) == "extended shot":
_spawn_word(_tr("开线抢射!", "OPEN-LINE SHOT!"), Vector2(size.x * 0.48, size.y * 0.34), GOLD_LIGHT, "P95BellCue_OpenLine")
else:
_spawn_vfx(target_enemy, HIT_VFX)
else:
var target_member := String(event.get("target_member_id", source))
if source == "Kindhand":
_spawn_role_word(source, _tr("治疗!", "HEAL!"), JADE)
_spawn_number(int(event.delta), target_member, JADE)
_spawn_vfx(target_member, HEAL_VFX)
"enemy_action":
var source_enemy := String(event.get("source_enemy_id", ""))
var target_member := String(event.get("target_member_id", ""))
_set_action(source_enemy, "attack", 0.48)
_set_action(target_member, "guard" if target_member == "Hearthguard" else "idle", 0.34)
_spawn_number(int(event.delta), target_member, CORAL)
_spawn_vfx(target_member, HIT_VFX)
if not reduced_motion:
shake_clock = 0.16
"outcome_locked":
battle_result_delay = 0.0
func _update_battle_visuals(delta: float) -> void:
if battle_actors.is_empty():
return
for actor_key in battle_actors:
var actor_id: String = String(actor_key)
var actor: TextureRect = battle_actors[actor_id]
var is_enemy: bool = actor_id in service.battle.state.enemy_ids
var position_value: int = int(service.battle.state.enemy_positions.get(actor_id, 100)) if is_enemy else int(service.battle.state.member_positions.get(actor_id, 0))
var desired_x: float = _battle_x(position_value)
var current_x: float = float(battle_visual_x.get(actor_id, desired_x))
current_x = desired_x if reduced_motion else lerpf(current_x, desired_x, minf(1.0, delta * 8.0))
battle_visual_x[actor_id] = current_x
var actor_height: float = actor.size.y
actor.position = Vector2(current_x - actor.size.x * 0.5, size.y - actor_height - 18)
var animation_id: String = "Riftblade" if is_enemy else actor_id
var state_name: String = _battle_actor_state(actor_id, is_enemy)
var action_clock: float = world_clock
if battle_actions.has(actor_id):
var action: Dictionary = battle_actions[actor_id]
action.clock = float(action.get("clock", 0.0)) + delta
action.remaining = maxf(0.0, float(action.get("remaining", 0.0)) - delta)
action_clock = float(action.clock)
if float(action.remaining) <= 0.0:
battle_actions.erase(actor_id)
actor.texture = animation.texture(animation_id, state_name, action_clock)
var hp_bar: ProgressBar = battle_hp_bars.get(actor_id)
if hp_bar != null:
var hp: int = int(service.battle.state.enemy_hp.get(actor_id, 0)) if is_enemy else int(service.battle.state.member_hp.get(actor_id, 0))
hp_bar.value = hp
hp_bar.position = Vector2(actor.position.x + actor.size.x * 0.5 - hp_bar.size.x * 0.5, actor.position.y - 9)
hp_bar.visible = hp > 0 and service.state.phase == State.Phase.BATTLE
func _battle_actor_state(actor_id: String, is_enemy: bool) -> String:
var hp := int(service.battle.state.enemy_hp.get(actor_id, 0)) if is_enemy else int(service.battle.state.member_hp.get(actor_id, 0))
if hp <= 0:
return "death" if is_enemy else "idle"
if battle_actions.has(actor_id):
return String(battle_actions[actor_id].state)
if service.state.phase == State.Phase.RESULT and service.state.last_outcome == "victory" and not is_enemy:
return "victory" if actor_id == "Hearthguard" else "idle"
if not _contact_reached():
return "advance"
return "idle"
func _member_action_state(member_id: String) -> String:
return {
"Hearthguard": "basic_attack",
"Wayrunner": "evade_counter",
"Farstring": "draw_release",
"Kindhand": "lantern_heal",
}.get(member_id, "idle")
func _set_action(actor_id: String, state_name: String, duration: float) -> void:
battle_actions[actor_id] = {"state": state_name, "remaining": duration, "clock": 0.0}
func _contact_reached() -> bool:
var members: Array[String] = service.battle.state.living_member_ids()
var enemies: Array[String] = service.battle.state.living_enemy_ids()
if members.is_empty() or enemies.is_empty():
return true
return int(service.battle.state.enemy_positions.get(enemies[0], 100)) - int(service.battle.state.member_positions.get(members[0], 0)) <= Rules.CONTACT_RANGE
func _show_battle_result() -> void:
if battle_result_shown:
return
battle_result_shown = true
_clear_battle_feedback()
var victory := service.state.last_outcome == "victory"
var reward := int(service.encounter(service.state.selected_encounter).get("reward", 0)) if victory else 0
var result := _outlined_label(_tr("胜利 ◆ +%d 金币" % reward, "VICTORY ◆ +%d COIN" % reward) if victory else _tr("队伍战败", "COMPANY DEFEATED"), 28, GOLD_LIGHT if victory else CORAL)
result.name = "P93BattleResult"
result.position = Vector2(size.x * 0.25, size.y * 0.24)
result.size = Vector2(size.x * 0.50, 70)
result.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(result)
if service.state.selected_encounter == "bell_breach" and service.state.equipment_receipts.size() == 1:
var item_id := String(service.state.equipment_receipts[0].item_id)
var farstring_hp := int(service.battle.state.member_hp.get("Farstring", 0))
var result_detail := _outlined_label(
_tr("钉阵胫甲:队列未乱 · 弓手 %d HP · %d tick" % [farstring_hp, service.battle.state.tick], "FRONTPIN: LINE HELD · ARCHER %d HP · %d TICKS" % [farstring_hp, service.battle.state.tick]) if item_id == "EQ-002" else _tr("开线绳结:弓手抢射后排 · 剩余 %d HP · %d tick" % [farstring_hp, service.battle.state.tick], "OPEN-LINE: REAR SHOT · ARCHER %d HP · %d TICKS" % [farstring_hp, service.battle.state.tick]),
13,
GOLD_LIGHT if item_id == "EQ-002" else CORAL
)
result_detail.name = "P95BellResultDetail"
result_detail.position = Vector2(size.x * 0.18, size.y * 0.41)
result_detail.size = Vector2(size.x * 0.64, 52)
result_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(result_detail)
elif service.state.selected_encounter == "rift_tide":
var receipts: Array[Dictionary] = service.state.rift_tide_wave_receipts
var wave_one_tick := int(receipts[-2].tick) if receipts.size() >= 2 else 0
var wave_two_tick := int(receipts[-1].tick) if receipts.size() >= 1 else 0
var fallen: Array[String] = []
for member_id in service.state.company:
if int(service.battle.state.member_hp.get(member_id, 0)) <= 0:
fallen.append(_member_name(member_id))
var detail_text := ""
if victory:
detail_text = _tr("第一波 %d tick · 第二波 %d tick\n存活者 +18 XP · 倒下 %d 人,返回后恢复 %d 金币" % [wave_one_tick, wave_two_tick, fallen.size(), fallen.size() * 2], "WAVE 1 %d TICKS · WAVE 2 %d TICKS\nSURVIVORS +18 XP · %d FALLEN, RECOVERY %d COIN" % [wave_one_tick, wave_two_tick, fallen.size(), fallen.size() * 2])
else:
detail_text = _tr("第一波 %d tick · 第二波 %d tick 后全队倒下\n无奖励、无人丢失 · 返回后恢复 8 金币" % [wave_one_tick, wave_two_tick], "WAVE 1 %d TICKS · COMPANY FELL IN WAVE 2 AT %d\nNO REWARD, NO ONE LOST · RECOVERY 8 COIN" % [wave_one_tick, wave_two_tick])
var result_detail := _outlined_label(detail_text, 12, GOLD_LIGHT if victory else CORAL)
result_detail.name = "P96ResultDetail"
result_detail.position = Vector2(size.x * 0.17, size.y * 0.39)
result_detail.size = Vector2(size.x * 0.66, 66)
result_detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(result_detail)
for hp_bar in battle_hp_bars.values():
if is_instance_valid(hp_bar):
hp_bar.hide()
var return_text := _tr("领取 +20 并返回", "CLAIM +20 AND RETURN") if service.state.selected_encounter == "rift_tide" and victory else (_tr("返回大厅恢复", "RETURN TO RECOVER") if service.state.selected_encounter == "rift_tide" else _tr("带着结果返回大厅", "RETURN TO THE HALL"))
var return_button := _action_button("P93ReturnHallButton", return_text, claim_and_return)
return_button.position = Vector2(size.x * 0.35, size.y - 68)
return_button.size = Vector2(size.x * 0.30, 52)
display_root.add_child(return_button)
func _add_compact_hud(context: String, coin: int, company_size: int) -> void:
var shade := ColorRect.new()
shade.name = "P93TopShade"
shade.position = Vector2.ZERO
shade.size = Vector2(size.x, 54)
shade.color = Color(0.02, 0.06, 0.12, 0.72)
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(shade)
var context_label := _label(context, 15, WHITE)
context_label.position = Vector2(18, 8)
context_label.size = Vector2(size.x * 0.44, 38)
context_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(context_label)
var resources := _outlined_label("◆ %d ⚔ %d/10" % [coin, company_size], 15, GOLD_LIGHT)
resources.name = "P93Resources"
resources.position = Vector2(size.x * 0.54, 8)
resources.size = Vector2(size.x * 0.28, 38)
resources.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
resources.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(resources)
var pause_button := _icon_button("P93MenuButton", "↻", reset_loop)
pause_button.position = Vector2(size.x - 54, 5)
pause_button.size = Vector2(46, 44)
pause_button.tooltip_text = _tr("重新开始本次体验", "Restart this preview")
display_root.add_child(pause_button)
func _add_battle_controls() -> void:
var context := _outlined_label(_battle_context_text(), 14, WHITE)
context.name = "P93BattleContext"
context.position = Vector2(14, 8)
context.size = Vector2(size.x * 0.44, 42)
context.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
display_root.add_child(context)
var pause_button := _icon_button("P93PauseButton", "▶" if paused else "Ⅱ", toggle_pause)
pause_button.position = Vector2(size.x - 106, 8)
pause_button.size = Vector2(44, 44)
display_root.add_child(pause_button)
var speed_button := _icon_button("P93SpeedButton", "%dx" % speed_multiplier, toggle_speed)
speed_button.position = Vector2(size.x - 56, 8)
speed_button.size = Vector2(48, 44)
display_root.add_child(speed_button)
func _battle_context_text() -> String:
if service.state.selected_encounter == "rift_tide":
return _tr("裂潮追击 · 第 %d/2 波" % service.state.rift_tide_wave, "RIFT TIDE · WAVE %d/2" % service.state.rift_tide_wave)
return _encounter_name(service.state.selected_encounter)
func _update_battle_control_labels() -> void:
var pause_button := find_child("P93PauseButton", true, false) as Button
var speed_button := find_child("P93SpeedButton", true, false) as Button
if pause_button != null:
pause_button.text = "▶" if paused else "Ⅱ"
if speed_button != null:
speed_button.text = "%dx" % speed_multiplier
func _add_actor_health(actor_id: String, color: Color) -> ProgressBar:
var is_enemy: bool = actor_id in service.battle.state.enemy_ids
var maximum: int = int(Rules.enemy_definition(actor_id).get("max_hp", 1)) if is_enemy else int(Rules.member_definition(actor_id).get("max_hp", 1))
var bar := ProgressBar.new()
bar.name = "P93Health_%s" % actor_id
bar.custom_minimum_size = Vector2.ZERO
bar.size = Vector2(48, 5)
bar.max_value = maximum
bar.value = maximum
bar.show_percentage = false
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_theme_stylebox_override("background", _flat_style(Color(0.02, 0.04, 0.07, 0.90), Color(0.85, 0.91, 0.96, 0.42), 1, 3))
bar.add_theme_stylebox_override("fill", _flat_style(color, color.lightened(0.18), 0, 3))
display_root.add_child(bar)
return bar
func _spawn_number(delta_value: int, actor_id: String, color: Color) -> void:
var actor: TextureRect = battle_actors.get(actor_id)
if actor == null:
return
var number := _outlined_label(("+" if delta_value > 0 else "") + str(delta_value), 22, color)
number.name = "P93Float_%s_%d" % [actor_id, Time.get_ticks_msec()]
number.position = actor.position + Vector2(actor.size.x * 0.36, -6)
number.size = Vector2(92, 38)
number.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
number.add_to_group("p93_transient_feedback")
display_root.add_child(number)
var tween := number.create_tween()
tween.set_parallel(true)
tween.tween_property(number, "position:y", number.position.y - (18 if reduced_motion else 42), 0.64).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(number, "modulate:a", 0.0, 0.64).set_delay(0.16)
tween.chain().tween_callback(number.queue_free)
func _spawn_role_word(actor_id: String, value: String, color: Color) -> void:
var actor: TextureRect = battle_actors.get(actor_id)
if actor == null:
return
_spawn_word(value, actor.position + Vector2(actor.size.x * 0.5 - 75, -20), color, "P94RoleCue_%s" % actor_id)
func _spawn_word(value: String, at: Vector2, color: Color, node_name := "") -> void:
var word := _outlined_label(value, 17, color)
if not node_name.is_empty():
word.name = node_name
word.position = at
word.size = Vector2(150, 36)
word.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
word.add_to_group("p93_transient_feedback")
display_root.add_child(word)
var tween := word.create_tween()
tween.tween_property(word, "modulate:a", 0.0, 0.72).set_delay(0.28)
tween.tween_callback(word.queue_free)
func _spawn_projectile(source_id: String, target_id: String) -> void:
var source: TextureRect = battle_actors.get(source_id)
var target: TextureRect = battle_actors.get(target_id)
if source == null or target == null:
return
var projectile := _outlined_label("➤", 28, GOLD_LIGHT)
projectile.name = "P93Projectile"
projectile.position = source.position + Vector2(source.size.x * 0.72, source.size.y * 0.38)
projectile.size = Vector2(52, 36)
projectile.z_index = 20
projectile.add_to_group("p93_transient_feedback")
display_root.add_child(projectile)
var target_position := target.position + Vector2(target.size.x * 0.28, target.size.y * 0.40)
var tween := projectile.create_tween()
tween.tween_property(projectile, "position", target_position, 0.46 if not reduced_motion else 0.16).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tween.tween_callback(projectile.queue_free)
func _spawn_vfx(actor_id: String, texture: Texture2D) -> void:
var actor: TextureRect = battle_actors.get(actor_id)
if actor == null:
return
var existing := find_child("P93VFX_%s" % actor_id, true, false)
if existing != null:
existing.queue_free()
var vfx := TextureRect.new()
vfx.name = "P93VFX_%s" % actor_id
vfx.custom_minimum_size = Vector2.ZERO
vfx.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
vfx.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
vfx.texture = texture
vfx.position = actor.position + Vector2(actor.size.x * 0.36, actor.size.y * 0.34)
vfx.size = actor.size * 0.28
vfx.z_index = 18
vfx.mouse_filter = Control.MOUSE_FILTER_IGNORE
vfx.add_to_group("p93_transient_feedback")
display_root.add_child(vfx)
var tween := vfx.create_tween()
tween.set_parallel(true)
tween.tween_property(vfx, "modulate:a", 0.0, 0.32)
tween.tween_property(vfx, "scale", Vector2(1.18, 1.18), 0.32).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.chain().tween_callback(vfx.queue_free)
func _clear_battle_feedback() -> void:
for transient in get_tree().get_nodes_in_group("p93_transient_feedback"):
if not is_instance_valid(transient) or not display_root.is_ancestor_of(transient):
continue
transient.hide()
transient.queue_free()
func _add_background(texture: Texture2D) -> void:
var background := TextureRect.new()
background.name = "P93WorldBackground"
background.texture = texture
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(background)
func _add_actor(character_id: String, at: Vector2, actor_height: float, node_name: String, ambient: bool, forced_state := "idle") -> TextureRect:
var actor := TextureRect.new()
actor.name = node_name
actor.custom_minimum_size = Vector2.ZERO
actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
actor.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
actor.texture = animation.texture(character_id, forced_state, world_clock)
actor.position = at
actor.size = Vector2(actor_height, actor_height)
actor.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
actor.mouse_filter = Control.MOUSE_FILTER_IGNORE
actor.set_meta("character_id", character_id)
actor.set_meta("animation_state", forced_state)
display_root.add_child(actor)
if ambient:
ambient_actors[node_name] = actor
return actor
func _update_ambient_actors() -> void:
for actor_name in ambient_actors:
var actor: TextureRect = ambient_actors[actor_name]
if not is_instance_valid(actor):
continue
var character_id := String(actor.get_meta("character_id", "Hearthguard"))
var state_name := String(actor.get_meta("animation_state", "idle"))
actor.texture = animation.texture(character_id, state_name, world_clock)
func _add_ground_ring(center: Vector2, diameter: float, color: Color, node_name: String) -> void:
var marker := Control.new()
marker.name = node_name
marker.position = center - Vector2(diameter * 0.5, 8)
marker.size = Vector2(diameter, 24)
marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(marker)
var line := ColorRect.new()
line.position = Vector2(0, 11)
line.size = Vector2(diameter, 3)
line.color = color
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
marker.add_child(line)
var diamond := _outlined_label("◆", 15, color)
diamond.position = Vector2(diameter * 0.5 - 18, -3)
diamond.size = Vector2(36, 28)
diamond.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
marker.add_child(diamond)
func _actor_target(node_name: String, at: Vector2, target_size: Vector2, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.position = at
button.size = target_size
button.flat = true
button.focus_mode = Control.FOCUS_ALL
button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
button.add_theme_stylebox_override("hover", _flat_style(Color(1, 1, 1, 0.05), Color(1, 1, 1, 0.35), 2, 8))
button.add_theme_stylebox_override("pressed", _flat_style(Color(GOLD, 0.08), GOLD, 2, 8))
button.add_theme_stylebox_override("focus", _flat_style(Color(1, 1, 1, 0.02), GOLD_LIGHT, 3, 8))
button.pressed.connect(callback)
return button
func _action_button(node_name: String, value: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = value
button.focus_mode = Control.FOCUS_ALL
button.add_theme_font_size_override("font_size", _scaled(15))
button.add_theme_color_override("font_color", INK)
button.add_theme_color_override("font_hover_color", INK)
button.add_theme_color_override("font_pressed_color", INK)
button.add_theme_stylebox_override("normal", _flat_style(GOLD, Color("#fff0b0"), 2, 4))
button.add_theme_stylebox_override("hover", _flat_style(Color("#ffe08a"), WHITE, 2, 4))
button.add_theme_stylebox_override("pressed", _flat_style(Color("#d9a947"), GOLD_LIGHT, 2, 4))
button.add_theme_stylebox_override("focus", _flat_style(GOLD, WHITE, 3, 4))
button.pressed.connect(callback)
return button
func _icon_button(node_name: String, value: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = value
button.focus_mode = Control.FOCUS_ALL
button.add_theme_font_size_override("font_size", _scaled(15))
button.add_theme_color_override("font_color", WHITE)
button.add_theme_stylebox_override("normal", _flat_style(Color(0.03, 0.08, 0.15, 0.80), Color(0.58, 0.78, 0.92, 0.80), 2, 4))
button.add_theme_stylebox_override("hover", _flat_style(Color(0.05, 0.14, 0.23, 0.92), CYAN, 2, 4))
button.add_theme_stylebox_override("pressed", _flat_style(Color(0.02, 0.06, 0.12, 0.94), GOLD, 2, 4))
button.pressed.connect(callback)
return button
func _label(value: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = value
label.add_theme_font_size_override("font_size", _scaled(font_size))
label.add_theme_color_override("font_color", color)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
return label
func _outlined_label(value: String, font_size: int, color: Color) -> Label:
var label := _label(value, font_size, color)
label.add_theme_color_override("font_outline_color", Color(0.015, 0.035, 0.075, 0.96))
label.add_theme_constant_override("outline_size", 5)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return label
func _flat_style(fill: Color, border: Color, border_width: int, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 5
style.content_margin_bottom = 5
return style
func _render_rotate_notice() -> void:
var background := ColorRect.new()
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background.color = INK
display_root.add_child(background)
var message := _outlined_label(_tr("请将手机旋转为横屏\n队伍会在同一条战线上行动", "ROTATE TO LANDSCAPE\nThe Company fights on one continuous line"), 20, WHITE)
message.name = "P93RotateNotice"
message.position = Vector2(24, size.y * 0.36)
message.size = Vector2(size.x - 48, 130)
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(message)
func _battle_x(position_value: int) -> float:
return clampf(100.0 + float(position_value + 12) * ((size.x - 200.0) / 124.0), 72.0, size.x - 72.0)
func _add_member_status(member_id: String, at: Vector2, status_size: Vector2, node_name: String, compact := false) -> void:
var level := int(service.state.member_levels.get(member_id, 1))
var xp := int(service.state.member_xp.get(member_id, 0))
var threshold := 20 + 10 * level if level < 10 else 0
var item_id := String(service.state.equipped_items.get(member_id, ""))
var item_name := ""
if not item_id.is_empty():
var item: Dictionary = service.equipment(item_id)
item_name = " ◆ %s" % _tr(String(item.name_zh), String(item.name_en))
var xp_text := _tr("最高" if level >= 10 else "%d/%d XP" % [xp, threshold], "MAX" if level >= 10 else "%d/%d XP" % [xp, threshold])
var value := "Lv.%d\n%s" % [level, xp_text] if compact else "%s\nLv.%d · %s%s" % [_member_name(member_id), level, xp_text, item_name]
var status := _outlined_label(value, 9, GOLD_LIGHT)
status.name = node_name
status.position = at
status.size = status_size
status.clip_text = true
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(status)
func _add_member_condition(member_id: String, at: Vector2, status_size: Vector2, node_name: String) -> void:
var current := int(service.state.member_condition.get(member_id, 0))
var maximum := int(Rules.member_definition(member_id).get("max_hp", 0))
var status := _outlined_label(_tr("%d/%d HP" % [current, maximum], "%d/%d HP" % [current, maximum]), 10, CORAL if current * 2 < maximum else JADE)
status.name = node_name
status.position = at
status.size = status_size
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(status)
func _add_equipped_icon(member_id: String, at: Vector2) -> void:
var item_id := String(service.state.equipped_items.get(member_id, ""))
if item_id.is_empty():
return
var icon := TextureRect.new()
icon.name = "P95EquippedIcon_%s" % member_id
icon.custom_minimum_size = Vector2.ZERO
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.texture = _equipment_texture(item_id)
icon.position = at
icon.size = Vector2(54, 54)
icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(icon)
func _equipment_texture(item_id: String) -> Texture2D:
return FRONTPIN_TEXTURE if item_id == "EQ-002" else OPEN_LINE_TEXTURE
func _member_name(member_id: String) -> String:
return {
"Hearthguard": _tr("艾拉 · 守卫", "Ayla · Hearthguard"),
"Wayrunner": _tr("托马斯 · 游侠", "Tomas · Wayrunner"),
"Farstring": _tr("妮娅 · 弓手", "Nia · Farstring"),
"Kindhand": _tr("赛布尔 · 治疗", "Sable · Kindhand"),
}.get(member_id, member_id)
func _member_role_name(member_id: String) -> String:
return {
"Hearthguard": _tr("守卫", "HEARTHGUARD"),
"Wayrunner": _tr("游侠", "WAYRUNNER"),
"Farstring": _tr("弓手", "FARSTRING"),
"Kindhand": _tr("治疗", "KINDHAND"),
}.get(member_id, member_id)
func _offer_name(member_id: String) -> String:
var data: Dictionary = service.offer(member_id)
return _tr(String(data.get("name_zh", _member_name(member_id))), String(data.get("name_en", _member_name(member_id))))
func _encounter_name(encounter_id: String) -> String:
var data: Dictionary = service.encounter(encounter_id)
return _tr(String(data.get("name_zh", encounter_id)), String(data.get("name_en", encounter_id)))
func _tr(zh: String, en: String) -> String:
return en if locale == "en" else zh
func _scaled(value: int) -> int:
return maxi(10, int(round(float(value) * text_scale)))