Files
aetherbound-guild/runtime/campaign/fg2_graybox_controller.gd
T

865 lines
34 KiB
GDScript

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