class_name P101RuntimeController extends "res://p10_0/runtime_controller.gd" const BaseContent = preload("res://p10_0/content_registry.gd") const RegionContent = preload("res://p10_1/content_registry.gd") const RegionState = preload("res://p10_1/region_run_state.gd") const RegionMarketService = preload("res://p10_1/region_market_service.gd") const RegionBattleService = preload("res://p10_1/first_crossing_battle_service.gd") const RegionResolutionService = preload("res://p10_1/first_crossing_resolution_service.gd") const RegionSaveService = preload("res://p10_1/save_v7_service.gd") const MotionPresenter = preload("res://p10_1/motion_presenter.gd") const DAMAGE_VFX = preload("res://assets/vfx/damage_heavy.png") const PROTECT_GAIN_VFX = preload("res://assets/vfx/protect_gain.png") const PROTECT_ABSORB_VFX = preload("res://assets/vfx/protect_absorb.png") const REGION_SAVE_PATH := "user://aetherbound_guild_p10_1.json" const REGION_TICK_SECONDS := 0.10 const REGION_SCREEN_MARKET := "siltwake_market" const REGION_SCREEN_PLAN := "siltwake_plan" const REGION_SCREEN_BATTLE := "siltwake_battle" const REGION_SCREEN_RECOVERY := "siltwake_recovery" const REGION_SCREEN_MARKET_2 := "siltwake_market_2" var region_mode := false var region_market var region_save = RegionSaveService.new() var region_catalog: Dictionary = RegionContent.snapshot() var region_equipment_rows: Dictionary = RegionContent.rows_by_id(region_catalog, "equipment") var region_enemy_rows: Dictionary = RegionContent.rows_by_id(region_catalog, "enemies") var region_route_rows: Dictionary = RegionContent.rows_by_id(region_catalog, "routes") var region_focused_recruit := "" var region_focused_equipment := "" var region_focused_route := "" var region_focused_company := "" var region_selected_instance := "" var region_plan_id := "" var region_battle_snapshot: Dictionary = {} var region_battle_tick := 0 var region_battle_terminal_tick := 0 var region_battle_accumulator := 0.0 var region_battle_complete := false var region_battle_paused := false var region_battle_speed := 1 var region_event_cursor := 0 var region_recent_events: Array[Dictionary] = [] var region_event_counts: Dictionary = {} var region_actor_order: Array = [] var region_display_hp: Dictionary = {} var region_safe_index := 0 var region_save_sequence := 0 var region_save_commit_hash := "" var region_motion = MotionPresenter.new() var region_motion_clock := 0.0 func _bootstrap_market() -> void: region_mode = false if _try_restore_region_entry(): _prepare_base_content() _queue_render() return super._bootstrap_market() func _try_restore_region_entry() -> bool: var snapshot: Dictionary = {} if String(entry_snapshot.get("state_kind", "")) == RegionState.STATE_KIND: snapshot = entry_snapshot.duplicate(true) elif persistence_enabled: var loaded: Dictionary = region_save.load_slot(ProjectSettings.globalize_path(REGION_SAVE_PATH)) if loaded.ok: snapshot = Dictionary(loaded.snapshot).duplicate(true) region_save_sequence = int(loaded.sequence) region_save_commit_hash = String(loaded.commit_hash) if snapshot.is_empty(): return false region_market = RegionMarketService.new(snapshot) if not region_market.valid: region_market = null return false region_mode = true last_code = "p101_resumed" _restore_region_screen() _reset_region_focus() return true func _prepare_base_content() -> void: content = BaseContent.snapshot() profession_rows = BaseContent.rows_by_id(content, "professions") trait_rows = BaseContent.rows_by_id(content, "traits") equipment_rows = BaseContent.rows_by_id(content, "equipment") enemy_rows = BaseContent.rows_by_id(content, "enemies") boss_rows = BaseContent.rows_by_id(content, "bosses") contract_rows = BaseContent.rows_by_id(content, "contracts") battle_active = false battle_result_visible = false pending_line.clear() if region_market != null and not region_market.state.current_run.is_empty(): pending_line.assign(region_market.state.current_run.deployed_recruit_ids) func _restore_region_screen() -> void: if region_market == null or region_market.state.current_run.is_empty(): screen = REGION_SCREEN_MARKET return match String(region_market.state.current_run.phase): "prepared": screen = REGION_SCREEN_PLAN "recovery": screen = REGION_SCREEN_RECOVERY "market_2": screen = REGION_SCREEN_MARKET_2 _: screen = REGION_SCREEN_MARKET func _process(delta: float) -> void: if not region_mode: super._process(delta) return if screen == REGION_SCREEN_BATTLE and not region_battle_paused: region_motion_clock += maxf(0.0, delta) if not low_power: world_clock += maxf(0.0, delta) _update_ambient_actors() _animate_region_battle_actors() if screen != REGION_SCREEN_BATTLE or region_battle_complete or region_battle_paused: return if region_motion.playback_held(region_motion_clock, reduced_motion): return region_battle_accumulator += maxf(0.0, delta) * float(region_battle_speed) var ticks := int(floor(region_battle_accumulator / REGION_TICK_SECONDS)) if ticks <= 0: return region_battle_accumulator -= float(ticks) * REGION_TICK_SECONDS advance_region_battle_ticks(ticks) func _render_current() -> void: if not region_mode: super._render_current() return if display_root == null: return _clear_display() line_touch_rects.clear() set_meta("visible_world_ratio", 0.86) if size.y > size.x: _render_rotate_notice() else: match screen: REGION_SCREEN_PLAN: _render_region_plan() REGION_SCREEN_BATTLE: _render_region_battle() REGION_SCREEN_RECOVERY: _render_region_recovery() REGION_SCREEN_MARKET_2: _render_region_market_2() _: _render_region_market() _add_settings_button() if settings_open: _render_settings_band() func _render_guild_result() -> void: super._render_guild_result() if not _region_gate_ready(): return var water := ColorRect.new() water.name = "P101SiltwakeGateWater" water.position = Vector2(size.x * 0.52, size.y * 0.52) water.size = Vector2(size.x * 0.43, 5) water.color = Color("#63c8c4") water.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(water) var enter := _action_button("P101EnterRegionButton", _tr("踏入淤潮地", "ENTER SILTWAKE"), enter_siltwake) enter.position = Vector2(size.x - 238, size.y - 66) enter.size = Vector2(218, 52) display_root.add_child(enter) func _region_gate_ready() -> bool: return ( market != null and not market.state.guild_profile.is_empty() and "RG-02" in market.state.guild_profile.unlocked_region_ids and market.state.current_run.is_empty() and market.state.run_creation_draft.is_empty() ) func enter_siltwake() -> void: if region_mode or not _region_gate_ready(): return var migrated := RegionState.new() if not migrated.initialize_from_p100(market.state.snapshot()): last_code = "p101_migration_rejected" _queue_render() return var service = RegionMarketService.new(migrated.snapshot()) var result: Dictionary = service.begin_region_run("p101-ui-enter-rg02", service.state.revision, selected_difficulty) last_code = String(result.code) if not result.ok: _queue_render() return region_market = service region_mode = true screen = REGION_SCREEN_MARKET _prepare_base_content() _reset_region_focus() _save_region() _queue_render() func tap_region_recruit(offer_id: String) -> void: if not _region_market_ready(): return if region_focused_recruit != offer_id: region_focused_recruit = offer_id region_focused_equipment = "" region_focused_route = "" last_code = "recruit_inspected" _queue_render() return var offer := _region_offer("recruit", offer_id) if offer.is_empty(): return var result: Dictionary = region_market.hire( _region_operation("hire", offer_id), region_market.state.revision, offer_id, int(offer.list_price) ) _accept_region_result(result) if result.ok: region_focused_recruit = "" pending_line.assign(region_market.state.current_run.deployed_recruit_ids) func tap_region_equipment(offer_id: String) -> void: if not _region_market_ready(): return if region_focused_equipment != offer_id: region_focused_equipment = offer_id region_focused_recruit = "" region_focused_route = "" last_code = "equipment_inspected" _queue_render() return var offer := _region_offer("equipment", offer_id) if offer.is_empty(): return var result: Dictionary = region_market.buy_equipment( _region_operation("buy", offer_id), region_market.state.revision, offer_id, int(offer.list_price) ) _accept_region_result(result) if result.ok: region_selected_instance = String(result.receipt.instance_id) region_focused_equipment = "" func tap_region_company(recruit_id: String) -> void: if not _region_market_ready() or recruit_id not in region_market.state.current_run.company: return if not region_selected_instance.is_empty(): var result: Dictionary = region_market.equip( _region_operation("equip", recruit_id), region_market.state.revision, recruit_id, region_selected_instance ) _accept_region_result(result) if result.ok: region_selected_instance = "" region_focused_company = recruit_id return region_focused_company = recruit_id last_code = "company_inspected" _queue_render() func tap_region_route(offer_id: String) -> void: if not _region_market_ready(): return region_focused_route = offer_id region_focused_recruit = "" region_focused_equipment = "" last_code = "route_inspected" _queue_render() func commit_region_route() -> void: if not _region_market_ready() or region_focused_route.is_empty(): return var result: Dictionary = region_market.commit_route( _region_operation("route", region_focused_route), region_market.state.revision, region_focused_route, String(region_market.state.current_run.market_board.board_hash) ) _accept_region_result(result) if result.ok: screen = REGION_SCREEN_PLAN _save_region() _queue_render() func choose_region_plan(plan_id: String) -> void: if region_market == null or String(region_market.state.current_run.get("phase", "")) != "prepared": return var result: Dictionary = RegionBattleService.new(region_market.state.snapshot()).run_to_terminal(plan_id) last_code = String(result.code) if not result.ok: _queue_render() return region_plan_id = plan_id region_battle_snapshot = Dictionary(result.snapshot).duplicate(true) region_battle_tick = 0 region_battle_terminal_tick = int(region_battle_snapshot.tick) region_battle_accumulator = 0.0 region_battle_complete = false region_battle_paused = false region_battle_speed = 1 region_event_cursor = 0 region_recent_events.clear() region_event_counts.clear() region_actor_order = Array(region_market.state.current_run.deployed_recruit_ids).duplicate() region_display_hp.clear() for recruit_id in region_actor_order: region_display_hp[recruit_id] = int(region_market.state.current_run.recruit_records[recruit_id].current_hp) region_safe_index = int(region_market.state.current_run.pending_encounter.tide_disclosure.current_safe_index) region_motion_clock = 0.0 region_motion.reset(region_actor_order, region_safe_index) screen = REGION_SCREEN_BATTLE _queue_render() func advance_region_battle_ticks(ticks: int) -> Dictionary: if screen != REGION_SCREEN_BATTLE or region_battle_complete or ticks <= 0: return {"ok": false, "code": "battle_inactive", "events": []} var previous_tick := region_battle_tick region_battle_tick = mini(region_battle_terminal_tick, region_battle_tick + ticks) var emitted: Array[Dictionary] = [] var events: Array = region_battle_snapshot.get("event_log", []) while region_event_cursor < events.size() and int(events[region_event_cursor].tick) <= region_battle_tick: var event: Dictionary = Dictionary(events[region_event_cursor]).duplicate(true) region_event_cursor += 1 emitted.append(event) _consume_region_event(event) if region_battle_tick >= region_battle_terminal_tick: region_battle_complete = true last_code = "first_crossing_playback_terminal" elif previous_tick != region_battle_tick: last_code = "first_crossing_playing" _queue_render() return {"ok": true, "code": last_code, "events": emitted, "tick": region_battle_tick} func toggle_region_battle_pause() -> void: if screen != REGION_SCREEN_BATTLE or region_battle_complete: return region_battle_paused = not region_battle_paused _queue_render() func toggle_region_battle_speed() -> void: if screen != REGION_SCREEN_BATTLE or region_battle_complete: return region_battle_speed = 2 if region_battle_speed == 1 else 1 _queue_render() func settle_region_battle() -> void: if not region_battle_complete or region_battle_snapshot.is_empty(): return var resolver = RegionResolutionService.new(region_market.state.snapshot()) var result: Dictionary = resolver.settle( _region_operation("settle", String(region_battle_snapshot.snapshot_hash).left(8)), resolver.state.revision, region_battle_snapshot ) last_code = String(result.code) if not result.ok: _queue_render() return region_market = RegionMarketService.new(result.snapshot) _save_region() screen = REGION_SCREEN_MARKET_2 if String(region_market.state.current_run.phase) == "market_2" else REGION_SCREEN_RECOVERY _queue_render() func recover_region_company() -> void: if region_market == null or String(region_market.state.current_run.get("phase", "")) != "recovery": return var report: Dictionary = region_market.state.current_run.last_battle_report var cost := int(region_market.state.current_run.recovery_cost) var resolver = RegionResolutionService.new(region_market.state.snapshot()) var result: Dictionary = resolver.recover( _region_operation("recover", String(report.get("battle_hash", "")).left(8)), resolver.state.revision, String(report.get("battle_hash", "")), cost ) last_code = String(result.code) if not result.ok: _queue_render() return region_market = RegionMarketService.new(result.snapshot) pending_line.assign(region_market.state.current_run.deployed_recruit_ids) _reset_region_focus() _save_region() screen = REGION_SCREEN_MARKET _queue_render() func _commit_line_drag(source_id: String, target_id: String) -> void: if not region_mode: super._commit_line_drag(source_id, target_id) return if not _region_market_ready() or source_id.is_empty() or target_id.is_empty() or source_id == target_id: return var order: Array = Array(region_market.state.current_run.deployed_recruit_ids).duplicate() if source_id not in order or target_id not in order: return order.erase(source_id) order.insert(order.find(target_id), source_id) var result: Dictionary = region_market.commit_order( _region_operation("order", "drag"), region_market.state.revision, order ) _accept_region_result(result) if result.ok: pending_line.assign(region_market.state.current_run.deployed_recruit_ids) func reset_progress() -> void: if not region_mode: super.reset_progress() return if not reset_confirmation: reset_confirmation = true last_code = "reset_confirmation_required" _queue_render() return for suffix in ["", ".bak", ".candidate"]: var path := ProjectSettings.globalize_path(REGION_SAVE_PATH + suffix) if FileAccess.file_exists(path): DirAccess.remove_absolute(path) region_mode = false region_market = null entry_snapshot = {} region_save_sequence = 0 region_save_commit_hash = "" super.reset_progress() func _accept_region_result(result: Dictionary) -> void: last_code = String(result.get("code", "")) if result.ok: _save_region() _queue_render() func _save_region() -> bool: if not persistence_enabled or region_market == null: return true var next_sequence := region_save_sequence + 1 var result: Dictionary = region_save.write_slot( ProjectSettings.globalize_path(REGION_SAVE_PATH), region_market.state.snapshot(), next_sequence, region_save_commit_hash ) if not result.ok: last_code = "p101_%s" % String(result.code) return false region_save_sequence = int(result.sequence) region_save_commit_hash = String(result.commit_hash) return true func _region_market_ready() -> bool: return ( region_mode and region_market != null and not region_market.state.current_run.is_empty() and String(region_market.state.current_run.phase) == "market" ) func _region_operation(kind: String, target: String) -> String: operation_index += 1 var revision := int(region_market.state.revision) if region_market != null else -1 return "p101-ui-%s-%d-%d-%s" % [kind, revision, operation_index, target] func _reset_region_focus() -> void: region_focused_recruit = "" region_focused_equipment = "" region_focused_route = "" region_focused_company = "" region_selected_instance = "" func _region_offer(kind: String, offer_id: String) -> Dictionary: if region_market == null or region_market.state.current_run.is_empty(): return {} var field := String({"recruit": "recruit_offers", "equipment": "equipment_offers", "route": "battle_offers"}.get(kind, "")) var id_field := "source_offer_id" if kind == "recruit" else "offer_id" for offer in region_market.state.current_run.market_board.get(field, []): if String(offer.get(id_field, "")) == offer_id: return Dictionary(offer).duplicate(true) return {} func _consume_region_event(event: Dictionary) -> void: var kind := String(event.kind) region_motion.consume(event, region_motion_clock) region_event_counts[kind] = int(region_event_counts.get(kind, 0)) + 1 region_recent_events.append(event.duplicate(true)) if region_recent_events.size() > 4: region_recent_events.pop_front() if kind == "pull_committed": region_actor_order = Array(event.order_after).duplicate() elif kind == "tide_followed" or kind == "tide_shifted_while_holding": region_safe_index = int(event.safe_index) elif kind == "authoritative_damage": region_display_hp[String(event.target_id)] = int(event.hp_after) elif kind == "recruit_fallen": region_display_hp[String(event.recruit_id)] = 0 func _render_region_market() -> void: _add_siltwake_world() var run: Dictionary = region_market.state.current_run var tide: Dictionary = run.market_board.tide_disclosure _add_header( _tr("淤潮地 · 集市 1", "SILTWAKE · MARKET 1"), _tr("◆%d 队伍 %d/4 安全位 %d→%d 5.0秒后换潮" % [run.coin, run.company.size(), tide.current_safe_index, tide.next_safe_index], "◆%d COMPANY %d/4 SAFE %d→%d TIDE 5.0s" % [run.coin, run.company.size(), tide.current_safe_index, tide.next_safe_index]) ) _render_tide_notches(tide, 0) _render_region_recruit_pier() _render_region_equipment_bank() _render_region_route_posts() _render_region_company_line() _render_region_context() func _render_region_recruit_pier() -> void: var run: Dictionary = region_market.state.current_run var offers: Array = run.market_board.recruit_offers var x0 := 20.0 var actor_size := clampf(size.y * 0.20, 64.0, 82.0) var y := 88.0 var caption := _outlined_label(_tr("渡口候选 · 点选查看,再点一次雇佣", "FERRY RECRUITS · TAP TO INSPECT, AGAIN TO HIRE"), 9, GOLD_LIGHT) caption.position = Vector2(x0, 56) caption.size = Vector2(size.x * 0.38, 28) display_root.add_child(caption) for index in range(offers.size()): var offer: Dictionary = offers[index] var offer_id := String(offer.source_offer_id) var available: bool = offer_id in run.available_recruit_offer_ids var x := x0 + index * (actor_size + 8.0) _add_ground_ring(Vector2(x + actor_size * 0.5, y + actor_size - 3), actor_size * 0.72, GOLD if region_focused_recruit == offer_id else CYAN, "P101RecruitRing_%s" % offer_id) var actor := _add_actor(_p100_recruit_rig(offer), Vector2(x, y), actor_size, "P101RecruitActor_%s" % offer_id, true) actor.self_modulate = _p100_profession_color(String(offer.profession_id)) actor.modulate.a = 1.0 if available else 0.28 if available: display_root.add_child(_actor_target("P101RecruitTarget_%s" % offer_id, Vector2(x - 3, y - 4), Vector2(actor_size + 6, actor_size + 38), tap_region_recruit.bind(offer_id))) var label := _outlined_label("%s\n%s ◆%d" % [_generated_name(offer).left(12), _short_profession(String(offer.profession_id)), int(offer.list_price)], 7, GOLD_LIGHT if region_focused_recruit == offer_id else WHITE) label.position = Vector2(x - 8, y + actor_size - 2) label.size = Vector2(actor_size + 16, 42) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(label) func _render_region_equipment_bank() -> void: var run: Dictionary = region_market.state.current_run var offers: Array = run.market_board.equipment_offers var x0 := size.x * 0.39 var width := size.x * 0.34 var spacing := width / 4.0 var caption := _outlined_label(_tr("防潮装备 · 购买后点队员装备", "TIDE GEAR · BUY, THEN TAP A RECRUIT"), 9, GOLD_LIGHT) caption.position = Vector2(x0, 56) caption.size = Vector2(width, 28) display_root.add_child(caption) for index in range(offers.size()): var offer: Dictionary = offers[index] var offer_id := String(offer.offer_id) var available: bool = offer_id in run.available_equipment_offer_ids var x := x0 + index * spacing + 4 var post := ColorRect.new() post.name = "P101EquipmentPost_%s" % offer_id post.position = Vector2(x + spacing * 0.45, 90) post.size = Vector2(4, 62) post.color = _equipment_color(String(offer.catalog_id)) post.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(post) var icon := _outlined_label(_equipment_symbol(String(offer.catalog_id)), 23, _equipment_color(String(offer.catalog_id))) icon.position = Vector2(x, 82) icon.size = Vector2(spacing - 4, 44) icon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(icon) if available: display_root.add_child(_actor_target("P101EquipmentTarget_%s" % offer_id, Vector2(x, 80), Vector2(spacing - 4, 92), tap_region_equipment.bind(offer_id))) var label := _outlined_label("%s\n◆%d" % [_region_name(String(offer.catalog_id)).left(9), int(offer.list_price)], 7, GOLD_LIGHT if region_focused_equipment == offer_id else WHITE) label.position = Vector2(x - 4, 128) label.size = Vector2(spacing + 4, 40) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.modulate.a = 1.0 if available else 0.35 display_root.add_child(label) func _render_region_route_posts() -> void: var run: Dictionary = region_market.state.current_run var offers: Array = run.market_board.battle_offers var x := size.x - 204.0 var caption := _outlined_label(_tr("三条水路", "THREE CROSSINGS"), 9, GOLD_LIGHT) caption.position = Vector2(x, 56) caption.size = Vector2(184, 28) display_root.add_child(caption) for index in range(offers.size()): var offer: Dictionary = offers[index] var offer_id := String(offer.offer_id) var y := 84.0 + index * 58.0 var selected := region_focused_route == offer_id var line := ColorRect.new() line.name = "P101RouteLine_%s" % offer_id line.position = Vector2(x, y + 40) line.size = Vector2(174, 4) line.color = GOLD if selected else [JADE, CYAN, CORAL][int(offer.risk)] line.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(line) var label := _outlined_label(_tr("风险%d %s" % [offer.risk, _region_name(String(offer.route_id))], "RISK %d %s" % [offer.risk, _region_name(String(offer.route_id))]), 8, GOLD_LIGHT if selected else WHITE) label.position = Vector2(x, y) label.size = Vector2(174, 40) label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS display_root.add_child(label) display_root.add_child(_actor_target("P101RouteTarget_%s" % offer_id, Vector2(x - 4, y), Vector2(182, 48), tap_region_route.bind(offer_id))) func _render_region_company_line() -> void: var run: Dictionary = region_market.state.current_run var company: Array = run.company var actor_size := clampf(size.y * 0.17, 56.0, 70.0) var y := size.y - 132.0 var x0 := size.x * 0.30 var spacing := minf(actor_size + 12, (size.x * 0.40) / maxf(1.0, float(company.size()))) var caption := _outlined_label(_tr("队伍战线 · 拖动调整前后顺序", "COMPANY LINE · DRAG TO REORDER"), 9, GOLD_LIGHT) caption.position = Vector2(x0, y - 25) caption.size = Vector2(size.x * 0.43, 24) display_root.add_child(caption) for index in range(company.size()): var recruit_id := String(run.deployed_recruit_ids[index]) var record: Dictionary = run.recruit_records[recruit_id] var x := x0 + index * spacing _add_ground_ring(Vector2(x + actor_size * 0.5, y + actor_size - 3), actor_size * 0.72, GOLD if region_focused_company == recruit_id else JADE, "P101CompanyRing_%s" % recruit_id) var actor := _add_actor(_p100_recruit_rig(record), Vector2(x, y), actor_size, "P101CompanyActor_%s" % recruit_id, true) actor.self_modulate = _p100_profession_color(String(record.profession_id)) var target_rect := Rect2(Vector2(x - 3, y - 4), Vector2(actor_size + 6, actor_size + 35)) line_touch_rects[recruit_id] = target_rect display_root.add_child(_actor_target("P101CompanyTarget_%s" % recruit_id, target_rect.position, target_rect.size, tap_region_company.bind(recruit_id))) var item_id := String(run.equipped_by_recruit.get(recruit_id, "")) var catalog_id := String(run.inventory.get(item_id, {}).get("catalog_id", "")) var label := _outlined_label("%dHP\n%s" % [record.current_hp, _region_name(catalog_id).left(8) if catalog_id.begins_with("EQ-04") else _short_profession(String(record.profession_id))], 7, WHITE) label.position = Vector2(x - 6, y + actor_size - 2) label.size = Vector2(actor_size + 12, 38) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(label) func _render_region_context() -> void: var band := ColorRect.new() band.name = "P101MarketContextBand" band.position = Vector2(0, size.y - 62) band.size = Vector2(size.x, 62) band.color = Color(0.015, 0.035, 0.075, 0.94) band.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(band) var text := _region_context_text() var context := _outlined_label(text, 9, WHITE) context.position = Vector2(14, size.y - 58) context.size = Vector2(size.x - 250, 52) context.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(context) var depart := _action_button("P101PrepareRouteButton", _tr("准备穿越", "PREPARE CROSSING"), commit_region_route) depart.position = Vector2(size.x - 224, size.y - 58) depart.size = Vector2(208, 48) depart.disabled = region_focused_route.is_empty() or region_market.state.current_run.company.size() < 2 display_root.add_child(depart) func _region_context_text() -> String: if not region_selected_instance.is_empty(): var item: Dictionary = region_market.state.current_run.inventory.get(region_selected_instance, {}) return _tr("已购买 %s:现在点一名队员替换其装备。" % _region_name(String(item.get("catalog_id", ""))), "BOUGHT %s: TAP A RECRUIT TO EQUIP IT." % _region_name(String(item.get("catalog_id", "")))) if not region_focused_recruit.is_empty(): var recruit := _region_offer("recruit", region_focused_recruit) return _tr("%s · %s · ◆%d。再点一次确认雇佣。" % [_generated_name(recruit), _short_profession(String(recruit.profession_id)), int(recruit.list_price)], "%s · %s · ◆%d. TAP AGAIN TO HIRE." % [_generated_name(recruit), _short_profession(String(recruit.profession_id)), int(recruit.list_price)]) if not region_focused_equipment.is_empty(): var equipment := _region_offer("equipment", region_focused_equipment) return _tr("%s · ◆%d。再点一次购买;随后点队员装备。" % [_region_name(String(equipment.catalog_id)), int(equipment.list_price)], "%s · ◆%d. TAP AGAIN TO BUY, THEN TAP A RECRUIT." % [_region_name(String(equipment.catalog_id)), int(equipment.list_price)]) if not region_focused_route.is_empty(): var route := _region_offer("route", region_focused_route) return _route_summary(int(route.risk)) if region_market.state.current_run.company.size() < 2: return _tr("先在左侧雇佣两名队员。潮水将在战斗第50刻移动安全位置。", "HIRE TWO RECRUITS ON THE LEFT. THE SAFE POSITION SHIFTS AT BATTLE TICK 50.") return _tr("选择装备与水路;前后顺序会改变拉拽后的受击关系。", "CHOOSE GEAR AND A CROSSING; LINE ORDER CHANGES WHO IS EXPOSED AFTER A PULL.") func _render_region_plan() -> void: _add_siltwake_world() var encounter: Dictionary = region_market.state.current_run.pending_encounter var tide: Dictionary = encounter.tide_disclosure _add_header(_tr("穿越前战术", "CROSSING PLAN"), _tr("%s · 风险%d · 安全位 %d→%d" % [_region_name(String(encounter.route_id)), encounter.risk, tide.current_safe_index, tide.next_safe_index], "%s · RISK %d · SAFE %d→%d" % [_region_name(String(encounter.route_id)), encounter.risk, tide.current_safe_index, tide.next_safe_index])) _render_tide_notches(tide, 0) _render_plan_company() var center_y := size.y * 0.55 _render_plan_target("hold_line", Vector2(size.x * 0.08, center_y), Vector2(size.x * 0.36, 92), GOLD) _render_plan_target("follow_tide", Vector2(size.x * 0.56, center_y), Vector2(size.x * 0.36, 92), CYAN) var hint := _outlined_label(_route_summary(int(encounter.risk)), 10, WHITE) hint.position = Vector2(size.x * 0.16, 88) hint.size = Vector2(size.x * 0.68, 58) hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(hint) func _render_plan_company() -> void: var run: Dictionary = region_market.state.current_run var actor_size := clampf(size.y * 0.13, 58.0, 96.0) var spacing := actor_size + 8.0 var total := maxf(actor_size, actor_size + spacing * float(run.deployed_recruit_ids.size() - 1)) var x0 := (size.x - total) * 0.5 for index in range(run.deployed_recruit_ids.size()): var recruit_id := String(run.deployed_recruit_ids[index]) var record: Dictionary = run.recruit_records[recruit_id] var actor := _add_actor(_p100_recruit_rig(record), Vector2(x0 + index * spacing, size.y * 0.31), actor_size, "P101PlanActor_%s" % recruit_id, true, "advance") actor.self_modulate = _p100_profession_color(String(record.profession_id)) func _render_plan_target(plan_id: String, at: Vector2, target_size: Vector2, color: Color) -> void: var selected_name := _tr("守住战线", "HOLD LINE") if plan_id == "hold_line" else _tr("顺潮移动", "FOLLOW TIDE") var consequence := _tr("保持攻击节奏,依靠站位和抗拉装备。", "KEEP TEMPO; RELY ON ORDER AND PULL RESIST.") if plan_id == "hold_line" else _tr("第50刻随安全位移动,牺牲一次行动节奏。", "MOVE AT TICK 50; GIVE UP ONE ACTION CADENCE.") var line := ColorRect.new() line.name = "P101PlanLine_%s" % plan_id line.position = Vector2(at.x, at.y + target_size.y - 5) line.size = Vector2(target_size.x, 5) line.color = color line.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(line) var label := _outlined_label("%s\n%s" % [selected_name, consequence], 11, WHITE) label.position = at label.size = target_size label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(label) display_root.add_child(_actor_target("P101PlanTarget_%s" % plan_id, at, target_size, choose_region_plan.bind(plan_id))) func _render_region_battle() -> void: _add_siltwake_world() var encounter: Dictionary = region_market.state.current_run.pending_encounter var tide: Dictionary = encounter.tide_disclosure var countdown := maxf(0.0, float(int(tide.shift_tick) - region_battle_tick) * REGION_TICK_SECONDS) _add_header( _tr("淤潮穿越 · %s" % _region_name(String(encounter.route_id)), "SILTWAKE CROSSING · %s" % _region_name(String(encounter.route_id))), _tr("第%d刻 换潮 %.1f秒 安全位 %d" % [region_battle_tick, countdown, region_safe_index], "TICK %d TIDE %.1fs SAFE %d" % [region_battle_tick, countdown, region_safe_index]) ) var battle_status := display_root.get_child(display_root.get_child_count() - 1) as Label if battle_status != null: battle_status.name = "P101BattleStatus" battle_status.size.x = maxf(100.0, size.x * 0.32) _render_tide_notches(tide, region_battle_tick) _render_region_battle_line() _render_region_persistent_feedback() _render_region_motion_effects() _render_region_event_feedback() var pause := _secondary_button("P101PauseButton", "▶" if region_battle_paused else "Ⅱ", toggle_region_battle_pause) pause.position = Vector2(size.x - 166, 1) pause.size = Vector2(48, 48) display_root.add_child(pause) var speed := _secondary_button("P101SpeedButton", "%dx" % region_battle_speed, toggle_region_battle_speed) speed.position = Vector2(size.x - 112, 1) speed.size = Vector2(48, 48) display_root.add_child(speed) if region_battle_complete: _render_region_battle_result() func _render_region_battle_line() -> void: var run: Dictionary = region_market.state.current_run var actor_size := clampf(size.y * 0.22, 70.0, 88.0) var ally_y := size.y * 0.48 var lane := ColorRect.new() lane.name = "P101BattleLane" lane.position = Vector2(0, ally_y - 18) lane.size = Vector2(size.x, actor_size + 42) lane.color = Color(0.015, 0.04, 0.055, 0.22) lane.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(lane) var ally_spacing := minf(actor_size + 8.0, size.x * 0.30 / maxf(1.0, float(region_actor_order.size() - 1))) for index in range(region_actor_order.size()): var recruit_id := String(region_actor_order[index]) var record: Dictionary = run.recruit_records.get(recruit_id, {}) if record.is_empty(): continue var base := Vector2(size.x * 0.48 - actor_size - index * ally_spacing, ally_y) var rig := _p100_recruit_rig(record) var visual: Dictionary = region_motion.actor_visual(recruit_id, index, -ally_spacing, region_motion_clock, reduced_motion, low_power) _add_ground_ring(base + Vector2(actor_size * 0.5, actor_size - 3), actor_size * 0.62, JADE, "P101BattleGround_%s" % recruit_id) var actor := _add_actor(rig, base, actor_size, "P101BattleActor_%s" % recruit_id, false, _motion_actor_animation(rig, String(visual.state))) actor.self_modulate = _p100_profession_color(String(record.profession_id)) actor.set_meta("base_position", base) actor.set_meta("region_actor_id", recruit_id) actor.set_meta("slot_index", index) actor.set_meta("slot_step", -ally_spacing) battle_actors[recruit_id] = actor _apply_region_actor_visual(actor, visual, rig) _add_region_hp_bar(recruit_id, actor, int(region_display_hp.get(recruit_id, record.current_hp)), int(record.max_hp), false) var all_enemies: Array = run.pending_encounter.enemy_ids var enemies: Array = region_motion.visible_enemy_ids.duplicate() var enemy_spacing := minf(actor_size + 8.0, size.x * 0.30 / maxf(1.0, float(all_enemies.size() - 1))) for index in range(enemies.size()): var enemy_id := String(enemies[index]) var base := Vector2(size.x * 0.52 + index * enemy_spacing, ally_y - 8) var visual: Dictionary = region_motion.enemy_visual(enemy_id, region_motion_clock, reduced_motion, low_power) _add_ground_ring(base + Vector2((actor_size + 6) * 0.5, actor_size + 3), actor_size * 0.66, CORAL, "P101BattleEnemyGround_%s" % enemy_id) var enemy := _add_actor("Riftblade", base, actor_size + 6, "P101BattleEnemy_%s" % enemy_id, false, String(visual.state)) enemy.self_modulate = _region_enemy_color(enemy_id) enemy.set_meta("base_position", base) enemy.set_meta("region_actor_id", enemy_id) enemy.set_meta("enemy_actor", true) battle_actors[enemy_id] = enemy _apply_region_actor_visual(enemy, visual, "Riftblade") var name := _outlined_label(_region_name(enemy_id).left(12), 7, CORAL) name.name = "P101BattleEnemyName_%s" % enemy_id name.position = Vector2(base.x - 12, base.y + actor_size - 2) name.size = Vector2(actor_size + 30, 28) name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name.set_meta("actor_id", enemy_id) display_root.add_child(name) func _render_region_persistent_feedback() -> void: if not region_motion.healing_denied_target.is_empty(): var tether := ColorRect.new() tether.name = "P101HealingDenialTether" tether.color = Color(0.70, 0.35, 0.88, 0.78) tether.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(tether) var seal := _motion_sprite("P101HealingDenialSeal", PROTECT_ABSORB_VFX, Vector2(32, 32), VIOLET) seal.set_meta("target_id", region_motion.healing_denied_target) display_root.add_child(seal) if region_motion.mud_index >= 0: var mud := _motion_sprite("P101IndexedMudMarker", PROTECT_ABSORB_VFX, Vector2(48, 22), Color("#7b8455")) mud.set_meta("position_index", region_motion.mud_index) display_root.add_child(mud) func _render_region_motion_effects() -> void: var effects: Array[Dictionary] = region_motion.active_effects(region_motion_clock) for index in range(effects.size()): var effect: Dictionary = effects[index] var kind := String(effect.kind) var suffix := "%02d_%s" % [index, kind] if kind == "allied_basic": var source_id := String(effect.get("actor_id", "")) var rig := _recruit_rig(source_id) var texture := PROTECT_GAIN_VFX if rig in ["Farstring", "Kindhand"] else DAMAGE_VFX var projectile := _motion_sprite("P101MotionProjectile_%s" % suffix, texture, Vector2(26, 18), CYAN if rig == "Farstring" else GOLD_LIGHT) projectile.set_meta("effect", effect) display_root.add_child(projectile) elif kind in ["enemy_basic_damage", "authoritative_damage"]: var value := _outlined_label("-%d" % int(effect.get("amount", effect.get("damage", 0))), 21, CORAL) value.name = "P101MotionDamage_%s" % suffix value.size = Vector2(80, 38) value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER value.mouse_filter = Control.MOUSE_FILTER_IGNORE value.z_index = 26 value.set_meta("effect", effect) display_root.add_child(value) var impact := _motion_sprite("P101MotionImpact_%s" % suffix, DAMAGE_VFX, Vector2(30, 30), CORAL) impact.set_meta("effect", effect) display_root.add_child(impact) elif kind in ["healing_applied", "healing_converted_to_enemy_guard"]: var healing := _motion_sprite("P101MotionHealing_%s" % suffix, PROTECT_GAIN_VFX if kind == "healing_applied" else PROTECT_ABSORB_VFX, Vector2(40, 40), JADE if kind == "healing_applied" else VIOLET) healing.set_meta("effect", effect) display_root.add_child(healing) func _motion_sprite(node_name: String, texture: Texture2D, display_size: Vector2, tint: Color) -> Sprite2D: var node := Sprite2D.new() node.name = node_name node.texture = texture var source_size := texture.get_size() node.scale = Vector2(display_size.x / maxf(1.0, source_size.x), display_size.y / maxf(1.0, source_size.y)) node.set_meta("base_scale", node.scale) node.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST node.self_modulate = tint node.z_index = 24 return node func _add_region_hp_bar(actor_id: String, actor: TextureRect, hp: int, max_hp: int, enemy: bool) -> void: var bar := ColorRect.new() bar.name = "P101Hp_%s" % actor_id bar.position = Vector2(actor.position.x + 4, actor.position.y - 8) bar.size = Vector2(actor.size.x - 8, 5) bar.color = Color(0.02, 0.03, 0.05, 0.88) bar.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(bar) var fill := ColorRect.new() fill.name = "Fill" fill.position = Vector2(1, 1) fill.size = Vector2((bar.size.x - 2) * clampf(float(hp) / maxf(1.0, float(max_hp)), 0.0, 1.0), 3) fill.color = CORAL if enemy else JADE fill.mouse_filter = Control.MOUSE_FILTER_IGNORE bar.add_child(fill) bar.set_meta("actor_id", actor_id) battle_hp_bars[actor_id] = bar func _render_region_event_feedback() -> void: var latest: Dictionary = region_recent_events[-1] if not region_recent_events.is_empty() else {} var kind := String(latest.get("kind", "battle_started")) var feedback := _outlined_label(_region_event_text(latest), 17, _region_event_color(kind)) feedback.name = "P101BattleFeedback" feedback.position = Vector2(size.x * 0.29, 92) feedback.size = Vector2(size.x * 0.42, 62) feedback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(feedback) func _render_region_battle_result() -> void: var victory := String(region_battle_snapshot.outcome) == "victory" var band := ColorRect.new() band.name = "P101BattleResultBand" band.position = Vector2(0, size.y - 70) band.size = Vector2(size.x, 70) band.color = Color(0.015, 0.035, 0.075, 0.96) band.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(band) var result := _outlined_label( _tr("穿越成功 · ◆+%d" % int(region_battle_snapshot.reward_coin), "CROSSING WON · ◆+%d" % int(region_battle_snapshot.reward_coin)) if victory else _tr("穿越失败 · 永久阵亡 %d" % region_battle_snapshot.fallen_recruit_ids.size(), "CROSSING LOST · PERMANENT FALLEN %d" % region_battle_snapshot.fallen_recruit_ids.size()), 19, GOLD_LIGHT if victory else CORAL ) result.position = Vector2(16, size.y - 66) result.size = Vector2(size.x - 250, 56) display_root.add_child(result) var settle := _action_button("P101SettleButton", _tr("确认结果", "SETTLE RESULT"), settle_region_battle) settle.position = Vector2(size.x - 222, size.y - 61) settle.size = Vector2(206, 50) display_root.add_child(settle) func _render_region_recovery() -> void: _add_siltwake_world() var run: Dictionary = region_market.state.current_run var report: Dictionary = run.last_battle_report _add_header(_tr("幸存者回到渡口", "SURVIVORS RETURN TO THE FERRY"), _tr("◆%d 救援费用 ◆%d" % [run.coin, run.recovery_cost], "◆%d RECOVERY ◆%d" % [run.coin, run.recovery_cost])) var title := _outlined_label(_tr("阵亡不会复活", "THE FALLEN DO NOT RETURN"), 26, CORAL) title.position = Vector2(size.x * 0.12, 74) title.size = Vector2(size.x * 0.76, 52) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(title) var body := _outlined_label(_tr("错误应对让队伍在换潮时失去治疗窗口。支付救援费可让幸存者恢复至40%%,回到同一集市重新整备。", "THE RESPONSE LOST THE HEALING WINDOW AT THE TIDE SHIFT. PAY TO RESTORE SURVIVORS TO 40%% AND REPREPARE THE SAME MARKET."), 12, WHITE) body.position = Vector2(size.x * 0.18, 126) body.size = Vector2(size.x * 0.64, 76) body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(body) _render_survivor_row(Vector2(size.x * 0.30, size.y * 0.54), 70) var fallen_count: int = report.get("fallen_recruit_ids", []).size() var fallen := _outlined_label(_tr("永久阵亡:%d人" % fallen_count, "PERMANENT FALLEN: %d" % fallen_count), 10, CORAL) fallen.position = Vector2(18, size.y - 60) fallen.size = Vector2(size.x - 260, 48) display_root.add_child(fallen) var recover := _action_button("P101RecoverButton", _tr("支付 ◆%d 救援" % run.recovery_cost, "PAY ◆%d TO RECOVER" % run.recovery_cost), recover_region_company) recover.position = Vector2(size.x - 232, size.y - 62) recover.size = Vector2(216, 52) recover.disabled = int(run.coin) < int(run.recovery_cost) display_root.add_child(recover) func _render_region_market_2() -> void: _add_siltwake_world() var run: Dictionary = region_market.state.current_run var handoff: Dictionary = run.market_2_handoff _add_header(_tr("淤潮地 · 集市 2", "SILTWAKE · MARKET 2"), _tr("◆%d 已保存" % run.coin, "◆%d SAVED" % run.coin)) _render_survivor_row(Vector2(size.x * 0.12, size.y * 0.43), 78) var title := _outlined_label(_tr("第一道水路已穿越", "FIRST CROSSING COMPLETE"), 27, GOLD_LIGHT) title.position = Vector2(size.x * 0.47, size.y * 0.24) title.size = Vector2(size.x * 0.46, 62) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(title) var body := _outlined_label(_tr("奖励 ◆%d 已结算一次。队伍、生命、装备与前后顺序已写入 Save v7;后续集市尚未开放。" % int(handoff.get("reward_coin", 0)), "REWARD ◆%d SETTLED ONCE. COMPANY, VITALITY, GEAR AND ORDER ARE SAVED IN V7; LATER MARKETS ARE NOT OPEN YET." % int(handoff.get("reward_coin", 0))), 12, WHITE) body.position = Vector2(size.x * 0.49, size.y * 0.40) body.size = Vector2(size.x * 0.42, 100) body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(body) var seal := _outlined_label(_tr("MARKET 2 · 存档完成", "MARKET 2 · SAVE COMPLETE"), 14, JADE) seal.position = Vector2(size.x * 0.55, size.y - 76) seal.size = Vector2(size.x * 0.34, 48) seal.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(seal) func _render_survivor_row(origin: Vector2, actor_size: float) -> void: var run: Dictionary = region_market.state.current_run for index in range(run.company.size()): var recruit_id := String(run.company[index]) var record: Dictionary = run.recruit_records[recruit_id] var actor := _add_actor(_p100_recruit_rig(record), origin + Vector2(index * (actor_size + 10), 0), actor_size, "P101Survivor_%s" % recruit_id, true, "victory" if screen == REGION_SCREEN_MARKET_2 else "idle") actor.self_modulate = _p100_profession_color(String(record.profession_id)) func _add_siltwake_world() -> void: _add_background(WINDGLASS_BACKGROUND) var wash := ColorRect.new() wash.name = "P101WaterLayer" wash.position = Vector2(0, 50) wash.size = Vector2(size.x, size.y - 50) wash.color = Color(0.03, 0.22, 0.24, 0.36) wash.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(wash) for index in range(4): var channel := ColorRect.new() channel.name = "P101Channel_%d" % (index + 1) channel.position = Vector2(size.x * (0.18 + 0.17 * index), 52) channel.size = Vector2(3, size.y - 116) channel.color = Color(0.55, 0.92, 0.88, 0.24) channel.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(channel) for index in range(7): var reed := ColorRect.new() reed.name = "P101ReedLayer_%d" % index reed.position = Vector2(8 + index * (size.x - 26) / 6.0, size.y - 106 - (index % 3) * 10) reed.size = Vector2(3, 42 + (index % 3) * 9) reed.color = Color("#99c66b") reed.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(reed) var bank := ColorRect.new() bank.name = "P101ForegroundBank" bank.position = Vector2(0, size.y - 68) bank.size = Vector2(size.x, 68) bank.color = Color(0.08, 0.13, 0.10, 0.48) bank.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(bank) func _render_tide_notches(tide: Dictionary, tick: int) -> void: var current := int(tide.current_safe_index) if tick < int(tide.shift_tick) else int(tide.next_safe_index) var next := int(tide.next_safe_index) var x0 := size.x * 0.40 var spacing := minf(54.0, size.x * 0.25 / 4.0) for index in range(1, 5): var notch := ColorRect.new() notch.name = "P101TideNotch_%d" % index notch.position = Vector2(x0 + (index - 1) * spacing, 55) notch.size = Vector2(spacing - 8, 5) notch.color = GOLD if index == current else CYAN if index == next else Color(0.7, 0.8, 0.8, 0.30) notch.mouse_filter = Control.MOUSE_FILTER_IGNORE display_root.add_child(notch) func _animate_region_battle_actors() -> void: if screen != REGION_SCREEN_BATTLE: return for actor_id in battle_actors: var actor: TextureRect = battle_actors[actor_id] if not is_instance_valid(actor) or not actor.has_meta("base_position"): continue var rig := String(actor.get_meta("rig", "Hearthguard")) var visual: Dictionary if bool(actor.get_meta("enemy_actor", false)): visual = region_motion.enemy_visual(String(actor_id), region_motion_clock, reduced_motion, low_power) else: visual = region_motion.actor_visual( String(actor_id), int(actor.get_meta("slot_index", 0)), float(actor.get_meta("slot_step", -68.0)), region_motion_clock, reduced_motion, low_power ) _apply_region_actor_visual(actor, visual, rig) if battle_hp_bars.has(actor_id): var bar: ColorRect = battle_hp_bars[actor_id] bar.position = Vector2(actor.position.x + 4, actor.position.y - 8) bar.modulate.a = float(visual.alpha) var enemy_name := display_root.find_child("P101BattleEnemyName_%s" % String(actor_id), false, false) as Label if enemy_name != null: enemy_name.position = Vector2(actor.position.x - 12, actor.position.y + actor.size.y - 2) enemy_name.modulate.a = float(visual.alpha) _animate_region_persistent_feedback() _animate_region_motion_effects() func _apply_region_actor_visual(actor: TextureRect, visual: Dictionary, rig: String) -> void: var camera: Vector2 = region_motion.camera_offset(region_motion_clock, reduced_motion, low_power) actor.position = Vector2(actor.get_meta("base_position")) + Vector2(visual.offset) + camera actor.pivot_offset = actor.size * 0.5 actor.scale = Vector2(visual.scale) actor.rotation = float(visual.rotation) actor.modulate.a = float(visual.alpha) var semantic_state := String(visual.state) var animation_state := semantic_state if rig == "Riftblade" else _motion_actor_animation(rig, semantic_state) actor.set_meta("animation_state", animation_state) actor.set_meta("motion_state", semantic_state) actor.texture = animation.texture(rig, animation_state, float(visual.animation_time)) func _motion_actor_animation(rig: String, semantic_state: String) -> String: match semantic_state: "attack": return {"Hearthguard": "basic_attack", "Wayrunner": "evade_counter", "Farstring": "draw_release", "Kindhand": "lantern_heal"}.get(rig, "idle") "heal", "healed": return "lantern_heal" if rig == "Kindhand" else "guard" if rig == "Hearthguard" else "idle" "hit", "guard": return "guard" if rig == "Hearthguard" else "evade_counter" if rig == "Wayrunner" else "idle" "victory": return {"Hearthguard": "victory", "Wayrunner": "evade_counter", "Farstring": "draw_release", "Kindhand": "lantern_heal"}.get(rig, "idle") "fallen": return "idle" _: return semantic_state func _animate_region_persistent_feedback() -> void: var tether := display_root.find_child("P101HealingDenialTether", false, false) as ColorRect if tether != null: _set_motion_line(tether, _region_actor_center(region_motion.healing_denial_source), _region_actor_center(region_motion.healing_denied_target), 3.0) var pulse := 0.72 if low_power else 0.58 + sin(region_motion_clock * 8.0) * 0.18 tether.modulate.a = pulse var seal := display_root.find_child("P101HealingDenialSeal", false, false) as Sprite2D if seal != null: seal.position = _region_actor_center(String(seal.get_meta("target_id", ""))) seal.modulate.a = 0.78 if low_power else 0.68 + sin(region_motion_clock * 7.0) * 0.22 var mud := display_root.find_child("P101IndexedMudMarker", false, false) as Sprite2D if mud != null: var index := int(mud.get_meta("position_index", 1)) mud.position = Vector2(size.x * 0.48 - float(index - 1) * 34.0, size.y * 0.67) mud.modulate.a = 0.72 func _animate_region_motion_effects() -> void: for child in display_root.get_children(): if not child.has_meta("effect"): continue var canvas := child as CanvasItem if canvas == null: continue var effect: Dictionary = child.get_meta("effect") var duration := maxf(0.01, float(effect.get("duration", 0.78))) var progress := clampf((region_motion_clock - float(effect.get("started", region_motion_clock))) / duration, 0.0, 1.0) canvas.visible = progress < 1.0 if not canvas.visible: continue if child.name.begins_with("P101MotionProjectile"): var projectile := child as Sprite2D var source := _region_actor_center(String(effect.get("actor_id", ""))) var target := _region_actor_center(region_motion.front_visible_enemy()) var flight := clampf(progress / 0.58, 0.0, 1.0) projectile.position = source.lerp(target, flight) projectile.position.y -= sin(flight * PI) * (3.0 if reduced_motion else 16.0) projectile.rotation = (target - source).angle() projectile.modulate.a = 1.0 - maxf(0.0, (flight - 0.78) / 0.22) elif child.name.begins_with("P101MotionDamage"): var damage_label := child as Label var target_id := String(effect.get("target_id", "")) var center := _region_actor_center(target_id) damage_label.position = center + Vector2(-damage_label.size.x * 0.5, -38.0 - progress * (9.0 if reduced_motion else 28.0)) damage_label.modulate.a = 1.0 - progress elif child.name.begins_with("P101MotionImpact"): var impact := child as Sprite2D var impact_center := _region_actor_center(String(effect.get("target_id", ""))) impact.position = impact_center var pulse := sin(clampf(progress / 0.48, 0.0, 1.0) * PI) impact.scale = Vector2(impact.get_meta("base_scale")) * (0.65 + pulse * (0.20 if reduced_motion else 0.70)) impact.modulate.a = 1.0 - progress elif child.name.begins_with("P101MotionHealing"): var healing := child as Sprite2D var healing_center := _region_actor_center(String(effect.get("target_id", ""))) healing.position = healing_center + Vector2(0, -progress * (5.0 if reduced_motion else 18.0)) healing.modulate.a = 1.0 - progress func _region_actor_center(actor_id: String) -> Vector2: if actor_id.is_empty() or not battle_actors.has(actor_id): return Vector2(size.x * 0.5, size.y * 0.53) var actor: TextureRect = battle_actors[actor_id] return actor.position + actor.size * 0.5 func _set_motion_line(line: ColorRect, start: Vector2, finish: Vector2, width: float) -> void: var delta := finish - start line.position = start line.size = Vector2(maxf(1.0, delta.length()), width) line.pivot_offset = Vector2(0, width * 0.5) line.rotation = delta.angle() func _region_actor_state(recruit_id: String) -> String: if int(region_display_hp.get(recruit_id, 1)) <= 0: return "idle" if region_recent_events.is_empty(): return "advance" var event: Dictionary = region_recent_events[-1] if String(event.get("actor_id", event.get("source_id", ""))) == recruit_id: return "draw_release" if _recruit_rig(recruit_id) == "Farstring" else "lantern_heal" if _recruit_rig(recruit_id) == "Kindhand" else "basic_attack" return "advance" func _region_enemy_state(enemy_id: String) -> String: if region_recent_events.is_empty(): return "advance" var event: Dictionary = region_recent_events[-1] return "attack" if String(event.get("enemy_id", "")) == enemy_id or String(event.kind) in ["enemy_basic_damage", "pull_committed", "healing_denial_attached"] else "advance" func _region_event_text(event: Dictionary) -> String: var kind := String(event.get("kind", "battle_started")) return { "battle_started": _tr("队伍踏入淤潮水路", "THE COMPANY ENTERS SILTWAKE"), "wave_entered": _tr("敌群从芦苇间出现", "A WAVE EMERGES FROM THE REEDS"), "healing_denial_attached": _tr("泥灯水蛭封锁治疗", "MUDLAMP LEECH BLOCKS HEALING"), "healing_converted_to_enemy_guard": _tr("治疗被转化为敌方护盾", "HEALING BECOMES ENEMY GUARD"), "indexed_bank_marked": _tr("堤岸位置被标记", "BANK POSITION MARKED"), "pull_committed": _tr("芦钩拉乱了战线", "REED HOOK PULLS THE LINE"), "pull_resisted": _tr("队伍抵抗了拉拽", "THE COMPANY RESISTS THE PULL"), "indexed_mud_created": _tr("跨位留下减速淤泥", "CROSSING LEAVES HINDERING MUD"), "tide_shift_warning": _tr("换潮警告:准备移动", "TIDE WARNING: PREPARE TO MOVE"), "tide_followed": _tr("队伍顺潮进入新安全位", "THE COMPANY FOLLOWS THE TIDE"), "tide_shifted_while_holding": _tr("队伍留在原地并被潮水浸湿", "THE LINE HOLDS AND BECOMES WET"), "allied_basic": _tr("队员发起攻击", "A RECRUIT ATTACKS"), "enemy_basic_damage": _tr("敌人击中前线", "THE ENEMY HITS THE FRONT"), "authoritative_damage": _tr("结算伤害 -%d" % int(event.get("amount", 0)), "DAMAGE SETTLED -%d" % int(event.get("amount", 0))), "recruit_fallen": _tr("一名队员永久阵亡", "A RECRUIT IS PERMANENTLY FALLEN"), "battle_terminal": _tr("穿越结果已确定", "THE CROSSING RESULT IS SET"), }.get(kind, kind.replace("_", " ").to_upper()) func _region_event_color(kind: String) -> Color: if kind in ["healing_denial_attached", "healing_converted_to_enemy_guard", "pull_committed", "indexed_mud_created", "enemy_basic_damage", "authoritative_damage", "recruit_fallen"]: return CORAL if kind in ["pull_resisted", "tide_followed", "battle_terminal"]: return JADE return GOLD_LIGHT func _route_summary(risk: int) -> String: return [ _tr("低风险:拉拽会改变邻位受击;稳住前后顺序更省生命。", "LOW RISK: PULL CHANGES ADJACENCY; A STABLE LINE PRESERVES MORE VITALITY."), _tr("中风险:先处理治疗封锁,并等迟滞过半后再净化。", "MEDIUM RISK: REMOVE HEALING DENIAL AND WAIT PAST THE HINDRANCE MIDPOINT."), _tr("高风险:换潮时留在原地会失去治疗窗口;顺潮移动更安全。", "HIGH RISK: HOLDING THROUGH THE TIDE LOSES THE HEALING WINDOW; FOLLOWING IT IS SAFER."), ][clampi(risk, 0, 2)] func _region_name(row_id: String) -> String: if row_id.is_empty(): return "" var regional := RegionContent.localized_name(region_catalog, row_id, locale) if not regional.is_empty(): return regional return _row_name(row_id, "equipment" if row_id.begins_with("EQ-") else "enemy") func _equipment_symbol(catalog_id: String) -> String: return {"EQ-041": "▰", "EQ-042": "◒", "EQ-043": "◎", "EQ-044": "⚑", "EQ-045": "Ⅱ", "EQ-046": "+", "EQ-047": "▱", "EQ-048": "◁"}.get(catalog_id, "◇") func _region_enemy_color(enemy_id: String) -> Color: var number := int(enemy_id.trim_prefix("EN-R2-")) return [Color("#b5e3b2"), VIOLET, Color("#c6a3de"), Color("#9dbd78"), GOLD_LIGHT, CYAN, CORAL][number % 7]