class_name ABGFG2GrayboxController extends Control const Adapter = preload("res://campaign/p100_adapter.gd") const Fixture = preload("res://campaign/fg2_fixture.gd") const GrayboxLayer = preload("res://campaign/fg2_graybox_layer.gd") const EventPresenter = preload("res://campaign/fg2_event_presenter.gd") const AudioRouter = preload("res://campaign/fg2_audio_router.gd") const SCREEN_MARKET := "market" const SCREEN_BATTLE := "battle" const SCREEN_REWARD := "reward" const SCREEN_RECOVERY := "recovery" const LAYER_KINDS := ["background", "midground", "actors", "foreground", "vfx"] const TOUCH_DRAG_SLOP := 18.0 const INK := Color("0b1118") const BAND := Color("111a24") const TEXT := Color("e8eef2") const MUTED_TEXT := Color("a9b7be") const CYAN := Color("78c8c5") const GOLD := Color("e0b95c") const DANGER := Color("c65f54") @export var locale := "zh_CN" @export var text_scale := 1.0 @export var reduced_motion := false @export var reduced_flashes := false @export var muted := true @export var low_power := false @export var battle_auto_advance := true @export var battle_tick_interval := 0.05 @export var battle_ticks_per_step := 4 var adapter = Adapter.new() var event_presenter = EventPresenter.new() var audio_router = AudioRouter.new() var pending_snapshot: Dictionary = {} var screen := SCREEN_MARKET var view: Dictionary = {} var selected_route_id := "" var last_code := "" var last_events: Array[Dictionary] = [] var presented_event_counts: Dictionary = {} var battle_result_visible := false var battle_paused := false var battle_speed := 1 var inspect_open := false var persistent_change_visible := false var input_touch_count := 0 var input_drag_count := 0 var fallback_input_count := 0 var direct_command_count := 0 var logical_size := Vector2i(422, 195) var logical_scale := 2 var logical_root: Control var logical_base_position := Vector2.ZERO var hud_root: Control var feedback_root: Control var actor_target_root: Control var feedback_labels: Dictionary = {} var layers: Array[Control] = [] var title_label: Label var status_label: Label var battle_accumulator := 0.0 var pulse := 0.0 var drag_touch_index := -1 var drag_source_id := "" var drag_seen := false var drag_input_counted := false var owned_touch_index := -1 var owned_touch_button: Button var owned_touch_mouse_filter := Control.MOUSE_FILTER_STOP var owned_touch_start := Vector2.ZERO var owned_touch_dragged := false var raw_button_activation_count := 0 func configure_entry(snapshot_value: Dictionary) -> void: pending_snapshot = snapshot_value.duplicate(true) if is_inside_tree(): _initialize_entry() func show_failure_fixture() -> void: pending_snapshot = Fixture.recovery_market_state() _initialize_entry() func _ready() -> void: set_process(true) set_process_input(true) clip_contents = true audio_router.attach(self) audio_router.configure(muted, low_power) _initialize_entry() func _exit_tree() -> void: audio_router.cleanup() func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED and is_inside_tree() and logical_root != null: _layout_world() _request_render() func _initialize_entry() -> void: var source := pending_snapshot if not pending_snapshot.is_empty() else Fixture.canonical_market_state() var result: Dictionary = adapter.configure(source) last_code = String(result.get("code", "configure_failed")) if not bool(result.get("ok", false)): return view = adapter.view_state(locale) selected_route_id = _route_id_for_risk(0) battle_result_visible = bool(view.get("battle", {}).get("terminal", false)) persistent_change_visible = int(view.get("market_index", 0)) >= 2 if bool(view.get("recovery_required", false)): screen = SCREEN_RECOVERY elif not Dictionary(view.get("reward", {})).is_empty(): screen = SCREEN_REWARD elif String(view.get("phase", "")) == "encounter": screen = SCREEN_BATTLE else: screen = SCREEN_MARKET last_events.clear() presented_event_counts.clear() event_presenter.reset(view, reduced_motion, reduced_flashes, low_power) audio_router.reset(muted, low_power, screen == SCREEN_BATTLE) _build_world() _request_render() func _build_world() -> void: if logical_root != null and is_instance_valid(logical_root): logical_root.queue_free() logical_root = Control.new() logical_root.name = "FG2LogicalWorld" logical_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(logical_root) if feedback_root != null and is_instance_valid(feedback_root): feedback_root.queue_free() feedback_root = Control.new() feedback_root.name = "FG2FeedbackRoot" feedback_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) feedback_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(feedback_root) feedback_labels.clear() layers.clear() for kind in LAYER_KINDS: var layer = GrayboxLayer.new() layer.name = "%sLayer" % kind.capitalize() layer.configure(kind) logical_root.add_child(layer) layers.append(layer) _layout_world() func _layout_world() -> void: var viewport := Vector2i(maxi(1, int(size.x)), maxi(1, int(size.y))) if viewport.x >= 1100 and viewport.y >= 650: logical_size = Vector2i(320, 180) logical_scale = 4 elif viewport.x <= 800: logical_size = Vector2i(380, 180) logical_scale = 2 else: logical_size = Vector2i(422, 195) logical_scale = 2 logical_root.size = Vector2(logical_size) logical_root.scale = Vector2(logical_scale, logical_scale) var display_size := Vector2(logical_size * logical_scale) logical_base_position = Vector2(floor((size.x - display_size.x) * 0.5), floor((size.y - display_size.y) * 0.5)) logical_root.position = logical_base_position for layer in layers: layer.position = Vector2.ZERO layer.size = Vector2(logical_size) set_meta("logical_grid", "%dx%d@%dx" % [logical_size.x, logical_size.y, logical_scale]) set_meta("visible_world_ratio", minf(1.0, display_size.x * display_size.y / maxf(1.0, size.x * size.y))) _sync_world() func _process(delta: float) -> void: pulse += delta event_presenter.advance(delta) audio_router.advance(delta) _apply_world_effects() if not low_power or int(pulse * 10.0) % 2 == 0: _sync_world() _sync_feedback_labels() if screen != SCREEN_BATTLE or battle_paused or battle_result_visible or not battle_auto_advance: return if event_presenter.blocks_auto_advance(): return battle_accumulator += delta * float(battle_speed) if battle_accumulator < battle_tick_interval: return battle_accumulator = 0.0 advance_battle_ticks(battle_ticks_per_step) func advance_battle_ticks(count: int) -> Dictionary: var result: Dictionary = adapter.advance_battle_ticks(count, locale) last_code = String(result.get("code", "battle_advance_failed")) if not bool(result.get("ok", false)): return result view = adapter.view_state(locale) event_presenter.sync_view(view) _consume_events(result.get("events", [])) battle_result_visible = bool(view.get("battle", {}).get("terminal", false)) _sync_world() if battle_result_visible: _request_render() else: _update_status() return result func _route_pressed(offer_id: String) -> void: selected_route_id = offer_id last_code = "route_selected" _request_render() func _depart_pressed() -> void: if selected_route_id.is_empty(): last_code = "route_required" _update_status() return var result: Dictionary = adapter.start_route(selected_route_id) last_code = String(result.get("code", "route_commit_failed")) if not bool(result.get("ok", false)): _update_status() return view = adapter.view_state(locale) screen = SCREEN_BATTLE battle_result_visible = false battle_paused = false battle_accumulator = 0.0 last_events.clear() presented_event_counts.clear() event_presenter.reset(view, reduced_motion, reduced_flashes, low_power) event_presenter.begin_battle(view) audio_router.set_scene(true) var receipt: Dictionary = result.get("receipt", {}) var operation_id := String(receipt.get("operation_id", "")) if not operation_id.is_empty(): _consume_events([{ "event_id": operation_id, "kind": "route_committed", "source_id": selected_route_id, "target_id": "windglass-route", "detail": {"offer_id": selected_route_id}, }]) _request_render() func _settle_pressed() -> void: var result: Dictionary = adapter.settle_terminal(locale) last_code = String(result.get("code", "settlement_failed")) if not bool(result.get("ok", false)): _update_status() return view = adapter.view_state(locale) if bool(view.get("recovery_required", false)): screen = SCREEN_RECOVERY elif not Dictionary(view.get("reward", {})).is_empty(): screen = SCREEN_REWARD else: screen = SCREEN_MARKET event_presenter.reset(view, reduced_motion, reduced_flashes, low_power) audio_router.set_scene(false) _request_render() func _reward_pressed() -> void: var result: Dictionary = adapter.claim_coin_reward(locale) last_code = String(result.get("code", "reward_failed")) if not bool(result.get("ok", false)): _update_status() return view = adapter.view_state(locale) screen = SCREEN_MARKET persistent_change_visible = int(view.get("market_index", 0)) >= 2 selected_route_id = _route_id_for_risk(0) event_presenter.reset(view, reduced_motion, reduced_flashes, low_power) audio_router.set_scene(false) _request_render() func _recovery_pressed() -> void: var result: Dictionary = adapter.hire_protected_recovery(locale) last_code = String(result.get("code", "recovery_failed")) if not bool(result.get("ok", false)): _update_status() return view = adapter.view_state(locale) screen = SCREEN_MARKET selected_route_id = _route_id_for_risk(0) event_presenter.reset(view, reduced_motion, reduced_flashes, low_power) var receipt: Dictionary = result.get("receipt", {}) var operation_id := String(receipt.get("operation_id", "")) var recruit_id := String(receipt.get("recruit_id", "")) if not operation_id.is_empty() and not recruit_id.is_empty(): _consume_events([{ "event_id": operation_id, "kind": "recovery", "source_id": "system", "target_id": recruit_id, "detail": {"credit_used": int(receipt.get("credit_used", 0))}, }]) _request_render() func _pause_pressed() -> void: battle_paused = not battle_paused last_code = "battle_paused" if battle_paused else "battle_resumed" _request_render() func _speed_pressed() -> void: battle_speed = 2 if battle_speed == 1 else 1 last_code = "battle_speed_%d" % battle_speed _request_render() func _inspect_pressed() -> void: inspect_open = not inspect_open last_code = "inspect_open" if inspect_open else "inspect_closed" _request_render() func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: var touch := event as InputEventScreenTouch if touch.pressed: input_touch_count += 1 if owned_touch_index >= 0: return owned_touch_index = touch.index owned_touch_button = _button_at_point(touch.position) if owned_touch_button != null: owned_touch_mouse_filter = owned_touch_button.mouse_filter owned_touch_button.mouse_filter = Control.MOUSE_FILTER_IGNORE owned_touch_start = touch.position owned_touch_dragged = false var actor_id := _actor_at_point(touch.position) if screen == SCREEN_MARKET and not actor_id.is_empty(): drag_touch_index = touch.index drag_source_id = actor_id drag_seen = false drag_input_counted = false if owned_touch_button != null or drag_touch_index >= 0: get_viewport().set_input_as_handled() else: _clear_touch_ownership() elif touch.index == owned_touch_index: get_viewport().set_input_as_handled() var release_button := _button_at_point(touch.position, owned_touch_button) var activate_button := ( not touch.canceled and not owned_touch_dragged and owned_touch_button != null and is_instance_valid(owned_touch_button) and not owned_touch_button.is_queued_for_deletion() and release_button == owned_touch_button ) var button_to_activate := owned_touch_button var button_instance_id := button_to_activate.get_instance_id() if is_instance_valid(button_to_activate) else 0 var mouse_filter_to_restore := owned_touch_mouse_filter if touch.index == drag_touch_index and drag_seen: var target_id := _actor_at_point(touch.position) if not target_id.is_empty() and target_id != drag_source_id: _commit_drag_order(drag_source_id, target_id) _clear_touch_ownership() call_deferred("_restore_touch_button_mouse_filter", button_instance_id, mouse_filter_to_restore) if activate_button and is_instance_valid(button_to_activate): raw_button_activation_count += 1 button_to_activate.emit_signal(&"pressed") elif event is InputEventScreenDrag and event.index == owned_touch_index: var drag := event as InputEventScreenDrag get_viewport().set_input_as_handled() if drag.position.distance_to(owned_touch_start) >= TOUCH_DRAG_SLOP: owned_touch_dragged = true if drag.index == drag_touch_index and not drag_source_id.is_empty(): if not drag_input_counted: input_drag_count += 1 drag_input_counted = true if drag.position.distance_to(owned_touch_start) >= TOUCH_DRAG_SLOP: drag_seen = true func _commit_drag_order(source_id: String, target_id: String) -> void: var order: Array = Array(adapter.market.state.current_run.deployed_recruit_ids).duplicate() var source_index := order.find(source_id) var target_index := order.find(target_id) if source_index < 0 or target_index < 0: return order.remove_at(source_index) order.insert(target_index, source_id) var result: Dictionary = adapter.commit_order(order) last_code = String(result.get("code", "formation_failed")) if bool(result.get("ok", false)): view = adapter.view_state(locale) _request_render() func _button_at_point(point: Vector2, allowed_ignored_button: Button = null) -> Button: if hud_root == null or not is_instance_valid(hud_root): return null var canvas_point := get_viewport().get_final_transform().affine_inverse() * point var best: Button var best_z := -2147483648 for node in hud_root.find_children("*", "Button", true, false): var button := node as Button if button == null or button.disabled: continue if button.mouse_filter == Control.MOUSE_FILTER_IGNORE and button != allowed_ignored_button: continue if not button.is_visible_in_tree() or button.is_queued_for_deletion(): continue if not button.get_global_rect().has_point(canvas_point): continue var effective_z := _effective_z_index(button) if best == null or effective_z >= best_z: best = button best_z = effective_z return best func _effective_z_index(item: CanvasItem) -> int: var result := item.z_index var ancestor := item.get_parent() while item.z_as_relative and ancestor is CanvasItem: var canvas_ancestor := ancestor as CanvasItem result += canvas_ancestor.z_index item = canvas_ancestor ancestor = item.get_parent() return result func _clear_touch_ownership() -> void: owned_touch_index = -1 owned_touch_button = null owned_touch_mouse_filter = Control.MOUSE_FILTER_STOP owned_touch_start = Vector2.ZERO owned_touch_dragged = false drag_touch_index = -1 drag_source_id = "" drag_seen = false drag_input_counted = false func _restore_touch_button_mouse_filter(instance_id: int, mouse_filter: int) -> void: var button := instance_from_id(instance_id) as Button if instance_id > 0 else null if button != null and not button.is_queued_for_deletion(): button.mouse_filter = mouse_filter func _consume_events(events: Array) -> void: for raw_event in events: var event: Dictionary = raw_event last_events.append(event.duplicate(true)) var kind := String(event.get("kind", "")) presented_event_counts[kind] = int(presented_event_counts.get(kind, 0)) + 1 while last_events.size() > 12: last_events.pop_front() event_presenter.consume(events) audio_router.consume(events) func set_audio_options(mute_value: bool, power_value: bool) -> void: muted = mute_value low_power = power_value audio_router.configure(muted, low_power) func _request_render() -> void: if not is_inside_tree(): return call_deferred("_render_hud") func _render_hud() -> void: if hud_root != null and is_instance_valid(hud_root): hud_root.queue_free() hud_root = Control.new() hud_root.name = "FG2HudRoot" hud_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(hud_root) var top_band := ColorRect.new() top_band.name = "FG2TopBand" top_band.color = BAND * Color(1, 1, 1, 0.92) top_band.position = Vector2.ZERO top_band.size = Vector2(size.x, 48) top_band.mouse_filter = Control.MOUSE_FILTER_IGNORE hud_root.add_child(top_band) title_label = _label("FG2Title", _title_text(), Vector2(18, 5), Vector2(size.x - 36, 25), 17, TEXT) status_label = _label("FG2Status", _status_text(), Vector2(18, 25), Vector2(size.x - 36, 20), 12, MUTED_TEXT) match screen: SCREEN_MARKET: _render_market_controls() SCREEN_BATTLE: _render_battle_controls() SCREEN_REWARD: _render_reward_controls() SCREEN_RECOVERY: _render_recovery_controls() _render_actor_targets() _sync_world() func _render_market_controls() -> void: var routes: Array = view.get("routes", []) var button_width := clampf((size.x - 42.0) / 3.0, 112.0, 190.0) var total_width := button_width * routes.size() + 8.0 * maxi(0, routes.size() - 1) var start_x := maxf(16.0, size.x - total_width - 16.0) for index in range(routes.size()): var route: Dictionary = routes[index] var risk := int(route.get("risk", 0)) var text := _tr("稳妥", "STEADY") if risk == 0 else _tr("应变", "ADAPT") if risk == 1 else _tr("险境", "PERIL") text += " %d%s" % [int(route.get("wave_count", 0)), _tr("波", " WAVES")] var button := _button("FG2Route_%s" % String(route.offer_id), text, Rect2(start_x + index * (button_width + 8), size.y - 120, button_width, 52), _route_pressed.bind(String(route.offer_id)), String(route.offer_id) == selected_route_id, risk == 2) button.tooltip_text = ", ".join(route.get("enemy_names", [])) _button("FG2ReverseLineButton", _tr("调换战线", "REVERSE LINE"), Rect2(16, size.y - 60, 158, 48), _reverse_line_pressed) _button("FG2DepartButton", _tr("开始远征", "BEGIN EXPEDITION"), Rect2(size.x - 198, size.y - 62, 182, 52), _depart_pressed, true) if persistent_change_visible: _label("FG2PersistentChange", _tr("市场 2 已保存 · 风道恢复一段", "MARKET 2 SAVED · CHANNEL RESTORED"), Vector2(size.x * 0.25, 56), Vector2(size.x * 0.5, 26), 14, GOLD, HORIZONTAL_ALIGNMENT_CENTER) func _render_battle_controls() -> void: _button("FG2PauseButton", _tr("继续", "RESUME") if battle_paused else _tr("暂停", "PAUSE"), Rect2(16, size.y - 60, 92, 48), _pause_pressed) _button("FG2SpeedButton", "%dx" % battle_speed, Rect2(116, size.y - 60, 74, 48), _speed_pressed) _button("FG2InspectButton", _tr("收起", "CLOSE") if inspect_open else _tr("战况", "INSPECT"), Rect2(198, size.y - 60, 100, 48), _inspect_pressed) if battle_result_visible: _button("FG2SettleButton", _tr("领取战果", "SETTLE RESULT"), Rect2(size.x - 190, size.y - 62, 174, 52), _settle_pressed, true) if inspect_open: var decisive: Dictionary = view.get("battle", {}).get("first_decisive_event", {}) var detail := _tr("关键因果:尚未发生", "DECISIVE CAUSE: PENDING") if not decisive.is_empty(): detail = _tr("关键因果:%s → %s" % [String(decisive.get("source_id", "")), String(decisive.get("target_id", ""))], "DECISIVE CAUSE: %s -> %s" % [String(decisive.get("source_id", "")), String(decisive.get("target_id", ""))]) _label("FG2InspectReadout", detail, Vector2(size.x * 0.23, 54), Vector2(size.x * 0.54, 26), 13, GOLD, HORIZONTAL_ALIGNMENT_CENTER) func _render_reward_controls() -> void: var reward: Dictionary = view.get("reward", {}) var amount := 0 for card in reward.get("cards", []): if String(card.get("kind", "")) == "coin": amount = int(card.get("amount", 0)) _label("FG2RewardHeadline", _tr("战线守住了", "THE LINE HELD"), Vector2(size.x * 0.25, size.y * 0.24), Vector2(size.x * 0.5, 36), 24, TEXT, HORIZONTAL_ALIGNMENT_CENTER) _label("FG2RewardCause", _tr("敌军溃退,市场通道向前推进。", "THE ENEMY BROKE; THE MARKET CHANNEL ADVANCES."), Vector2(size.x * 0.20, size.y * 0.34), Vector2(size.x * 0.6, 30), 14, MUTED_TEXT, HORIZONTAL_ALIGNMENT_CENTER) _button("FG2RewardButton", _tr("收下 %d 金币" % amount, "TAKE %d COIN" % amount), Rect2(size.x * 0.5 - 105, size.y - 90, 210, 56), _reward_pressed, true) func _render_recovery_controls() -> void: _label("FG2RecoveryHeadline", _tr("战线出现永久缺口", "THE LINE HAS A PERMANENT GAP"), Vector2(size.x * 0.20, size.y * 0.22), Vector2(size.x * 0.6, 36), 22, DANGER, HORIZONTAL_ALIGNMENT_CENTER) _label("FG2RecoveryCause", _tr("阵亡成员不会复活。保护性补员会加入现有幸存者。", "FALLEN RECRUITS DO NOT RETURN. A PROTECTED HIRE JOINS THE SURVIVORS."), Vector2(size.x * 0.14, size.y * 0.33), Vector2(size.x * 0.72, 46), 14, TEXT, HORIZONTAL_ALIGNMENT_CENTER) _button("FG2RecoveryButton", _tr("补员并重整战线", "HIRE AND REFORM"), Rect2(size.x * 0.5 - 120, size.y - 92, 240, 56), _recovery_pressed, true) func _render_actor_targets() -> void: actor_target_root = Control.new() actor_target_root.name = "FG2ActorTargets" actor_target_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) actor_target_root.mouse_filter = Control.MOUSE_FILTER_IGNORE hud_root.add_child(actor_target_root) var company: Array = view.get("company", []) if screen == SCREEN_BATTLE: var allies: Array = view.get("battle", {}).get("allies", []) var enemies: Array = view.get("battle", {}).get("enemies", []) for index in range(allies.size()): _add_actor_target(String(allies[index].actor_id), allies[index], _battle_actor_logical(index, false), false) for index in range(enemies.size()): _add_actor_target(String(enemies[index].actor_id), enemies[index], _battle_actor_logical(index, true), true) else: var deployed_index := 0 for actor in company: if not bool(actor.get("deployed", false)): continue _add_actor_target(String(actor.recruit_id), actor, Vector2(47 + deployed_index * 44, logical_size.y * 0.63), false) deployed_index += 1 func _add_actor_target(actor_id: String, actor: Dictionary, logical_position: Vector2, enemy: bool) -> void: var target := Control.new() target.name = ("FG2EnemyTarget_" if enemy else "FG2CompanyTarget_") + actor_id var center := logical_root.position + logical_position * float(logical_scale) var target_size := Vector2(maxf(48, 25 * logical_scale), maxf(56, 34 * logical_scale)) target.position = center - Vector2(target_size.x * 0.5, target_size.y * 0.72) target.size = target_size target.mouse_filter = Control.MOUSE_FILTER_IGNORE actor_target_root.add_child(target) var label := Label.new() label.name = "Identity" var label_width := maxf(86, target_size.x + 28) label.position = Vector2((target_size.x - label_width) * 0.5, target_size.y - 8) label.size = Vector2(label_width, 20) label.text = String(actor.get("profession_name", actor.get("name", actor.get("identity_id", "")))) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS var identity_font_size := maxi(9, int(10 * text_scale)) if label.text.length() > 9: identity_font_size = maxi(9, identity_font_size - 2) label.add_theme_font_size_override("font_size", identity_font_size) label.add_theme_color_override("font_color", DANGER.lightened(0.30) if enemy else TEXT) label.mouse_filter = Control.MOUSE_FILTER_IGNORE target.add_child(label) func _reverse_line_pressed() -> void: var order: Array = Array(adapter.market.state.current_run.deployed_recruit_ids).duplicate() order.reverse() var result: Dictionary = adapter.commit_order(order) last_code = String(result.get("code", "formation_failed")) if bool(result.get("ok", false)): view = adapter.view_state(locale) _request_render() func _sync_world() -> void: var presentation := event_presenter.snapshot() for layer in layers: if is_instance_valid(layer): layer.sync(screen, view, last_events, pulse, reduced_motion, presentation) func _apply_world_effects() -> void: if logical_root == null or not is_instance_valid(logical_root): return logical_root.position = logical_base_position + Vector2(event_presenter.shake_offset()) func _sync_feedback_labels() -> void: if feedback_root == null or not is_instance_valid(feedback_root): return var presentation := event_presenter.snapshot() var candidates: Array = [] for transient in presentation.get("transients", []): if not String(transient.get("text", "")).is_empty(): candidates.append(transient) if candidates.size() > 4: candidates = candidates.slice(candidates.size() - 4, candidates.size()) var active_ids: Dictionary = {} var actor_stack_counts: Dictionary = {} for transient in candidates: var event_id := String(transient.get("event_id", "")) var actor_id := String(transient.get("target_id", "")) if actor_id.is_empty(): actor_id = String(transient.get("source_id", "")) var logical_position := _presentation_actor_logical(actor_id) if logical_position == Vector2.ZERO and String(transient.get("kind", "")) == "outcome": logical_position = Vector2(logical_size.x * 0.5, logical_size.y * 0.46) if event_id.is_empty() or logical_position == Vector2.ZERO: continue var stack_index := int(actor_stack_counts.get(actor_id, 0)) actor_stack_counts[actor_id] = stack_index + 1 active_ids[event_id] = true var label: Label = feedback_labels.get(event_id) as Label if label == null: label = Label.new() label.name = "FG2Feedback_%s" % event_id label.size = Vector2(132, 28) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.add_theme_font_size_override("font_size", maxi(13, int(15 * text_scale))) label.add_theme_constant_override("outline_size", 4) label.add_theme_color_override("font_outline_color", INK) feedback_root.add_child(label) feedback_labels[event_id] = label label.text = _localized_feedback_text(transient) label.add_theme_color_override("font_color", _feedback_color(String(transient.get("kind", "")))) var progress := float(transient.get("progress", 0.0)) var center := logical_base_position + Vector2(event_presenter.shake_offset()) + logical_position * float(logical_scale) label.position = Vector2(floor(center.x - label.size.x * 0.5), floor(center.y - 76.0 - progress * 18.0 - stack_index * 22.0)) label.modulate.a = clampf(1.0 - progress * progress, 0.0, 1.0) for event_id in feedback_labels.keys(): if active_ids.has(event_id): continue var label: Label = feedback_labels[event_id] if is_instance_valid(label): label.queue_free() feedback_labels.erase(event_id) func _presentation_actor_logical(actor_id: String) -> Vector2: if screen != SCREEN_BATTLE: var deployed_index := 0 var reserve_index := 0 for actor in view.get("company", []): if String(actor.get("recruit_id", "")) == actor_id: if bool(actor.get("deployed", false)): return Vector2(47 + deployed_index * 44, logical_size.y * 0.63) return Vector2(32 + reserve_index * 25, logical_size.y * 0.66) if bool(actor.get("deployed", false)): deployed_index += 1 else: reserve_index += 1 var battle: Dictionary = view.get("battle", {}) var allies: Array = battle.get("allies", []) var enemies: Array = battle.get("enemies", []) for index in range(allies.size()): if String(allies[index].get("actor_id", "")) == actor_id: return _battle_actor_logical(index, false) for index in range(enemies.size()): if String(enemies[index].get("actor_id", "")) == actor_id: return _battle_actor_logical(index, true) var cue: Dictionary = event_presenter.snapshot().get("actor_cues", {}).get(actor_id, {}) if not cue.is_empty(): return _battle_actor_logical(int(cue.get("slot_index", 0)), String(cue.get("side", "")) == "enemy") return Vector2.ZERO func _feedback_color(kind: String) -> Color: if kind == "damage" or kind == "fallen": return DANGER.lightened(0.25) if kind == "heal": return CYAN.lightened(0.25) if kind in ["guard", "intercept", "item_block", "push_resisted", "mitigation"]: return GOLD return TEXT func _localized_feedback_text(transient: Dictionary) -> String: var kind := String(transient.get("kind", "")) var value := int(transient.get("value", 0)) match kind: "damage": return "-%d" % value "heal": return "+%d" % value "guard": return _tr("+%d 护盾" % value, "+%d GUARD" % value) "mitigation": return _tr("%d 格挡" % value, "%d BLOCKED" % value) "intercept", "item_block", "push_resisted": return _tr("格挡", "BLOCK") "hinder": return _tr("-%d 就绪" % value, "-%d READY" % value) "fallen": return _tr("阵亡", "FALLEN") "outcome": var outcome := String(transient.get("text", "")).to_lower() return _tr("胜利", "VICTORY") if outcome == "victory" else _tr("败退", "DEFEAT") "recovery": return _tr("补员完成", "RECOVERED") return String(transient.get("text", "")) func _update_status() -> void: if status_label != null and is_instance_valid(status_label): status_label.text = _status_text() func _title_text() -> String: match screen: SCREEN_BATTLE: return _tr("风晶峡道 · 自动交战", "WINDGLASS CHANNEL · AUTO BATTLE") SCREEN_REWARD: return _tr("远征结算", "EXPEDITION RESULT") SCREEN_RECOVERY: return _tr("战线恢复", "LINE RECOVERY") return _tr("风晶市场 %d · 编队与路线" % int(view.get("market_index", 1)), "WINDGLASS MARKET %d · LINE & ROUTE" % int(view.get("market_index", 1))) func _status_text() -> String: if screen == SCREEN_BATTLE: var battle_view: Dictionary = view.get("battle", {}) if bool(battle_view.get("terminal", false)): return _tr("结果:%s · 首个决定性因果已记录" % _outcome_label(String(battle_view.get("outcome", ""))), "RESULT: %s · FIRST DECISIVE CAUSE RECORDED" % _outcome_label(String(battle_view.get("outcome", "")))) return _tr("第 %d/%d 波 · 自动攻击 · 点“战况”查看因果" % [int(battle_view.get("next_wave", 0)), int(battle_view.get("wave_count", 0))], "WAVE %d/%d · AUTO ATTACK · INSPECT CAUSE" % [int(battle_view.get("next_wave", 0)), int(battle_view.get("wave_count", 0))]) if screen == SCREEN_RECOVERY: return _tr("幸存 %d 人 · 信用 %d · 保护补员待确认" % [view.get("company", []).size(), int(view.get("replacement_credit", 0))], "%d SURVIVORS · %d CREDIT · PROTECTED HIRE READY" % [view.get("company", []).size(), int(view.get("replacement_credit", 0))]) if screen == SCREEN_REWARD: return _tr("胜利奖励只结算一次", "VICTORY REWARD COMMITS ONCE") var selected := _selected_route() if selected.is_empty(): return _tr("先选路线;可拖动队员调整自动战线", "CHOOSE A ROUTE; DRAG RECRUITS TO CHANGE AUTO LINE") return _tr("已选风险 %d · %d 波 · 拖动队员可改变承伤顺序" % [int(selected.risk), int(selected.wave_count)], "RISK %d · %d WAVES · DRAG TO CHANGE HIT ORDER" % [int(selected.risk), int(selected.wave_count)]) func _button(name_value: String, text_value: String, rect: Rect2, callback: Callable, selected := false, danger := false) -> Button: var button := Button.new() button.name = name_value button.position = Vector2(floor(rect.position.x), floor(rect.position.y)) button.size = Vector2(maxf(48, floor(rect.size.x)), maxf(48, floor(rect.size.y))) button.text = text_value button.add_theme_font_size_override("font_size", maxi(12, int(13 * text_scale))) button.add_theme_color_override("font_color", TEXT) button.add_theme_color_override("font_hover_color", TEXT) button.add_theme_stylebox_override("normal", _style(DANGER.darkened(0.42) if danger else CYAN.darkened(0.62) if selected else BAND, CYAN if selected else DANGER if danger else MUTED_TEXT.darkened(0.35))) button.add_theme_stylebox_override("hover", _style(DANGER.darkened(0.22) if danger else CYAN.darkened(0.45), TEXT)) button.add_theme_stylebox_override("pressed", _style(CYAN.darkened(0.30), GOLD)) button.pressed.connect(callback) hud_root.add_child(button) return button func _label(name_value: String, text_value: String, position_value: Vector2, size_value: Vector2, font_size: int, color: Color, alignment := HORIZONTAL_ALIGNMENT_LEFT) -> Label: var label := Label.new() label.name = name_value label.position = Vector2(floor(position_value.x), floor(position_value.y)) label.size = Vector2(floor(size_value.x), floor(size_value.y)) label.text = text_value label.horizontal_alignment = alignment label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.add_theme_font_size_override("font_size", maxi(10, int(font_size * text_scale))) label.add_theme_color_override("font_color", color) label.mouse_filter = Control.MOUSE_FILTER_IGNORE hud_root.add_child(label) return label func _style(fill: Color, border: Color) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = fill style.border_color = border style.set_border_width_all(2) style.corner_radius_top_left = 2 style.corner_radius_top_right = 2 style.corner_radius_bottom_left = 2 style.corner_radius_bottom_right = 2 return style func _actor_at_point(point: Vector2) -> String: if actor_target_root == null: return "" for child in actor_target_root.get_children(): if child is Control and child.get_global_rect().has_point(point): var raw := String(child.name) if raw.begins_with("FG2CompanyTarget_"): return raw.trim_prefix("FG2CompanyTarget_") return "" func _battle_actor_logical(index: int, enemy: bool) -> Vector2: if enemy: return Vector2(logical_size.x * 0.66 + index * 34, logical_size.y * 0.69 + (index % 2) * 5) return Vector2(logical_size.x * 0.39 - index * 38, logical_size.y * 0.69 + (index % 2) * 4) func _route_id_for_risk(risk: int) -> String: for route in view.get("routes", []): if int(route.get("risk", -1)) == risk: return String(route.get("offer_id", "")) return "" func _selected_route() -> Dictionary: for route in view.get("routes", []): if String(route.get("offer_id", "")) == selected_route_id: return route return {} func _outcome_label(outcome: String) -> String: return {"victory": _tr("胜利", "VICTORY"), "defeat": _tr("败退", "DEFEAT"), "retreat": _tr("撤退", "RETREAT")}.get(outcome, outcome.to_upper()) func _tr(zh: String, en: String) -> String: return en if locale == "en" else zh