865 lines
34 KiB
GDScript
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
|