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)))