Files
aetherbound-guild/runtime/p10_1/runtime_controller.gd
T

1288 lines
57 KiB
GDScript

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