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

876 lines
39 KiB
GDScript

class_name P910RuntimeController
extends "res://p9_9/runtime_controller.gd"
const P910Content = preload("res://content/p9_10_content.gd")
const P910State = preload("res://p9_10/contract_state.gd")
const P910BossService = preload("res://p9_10/boss_service.gd")
const P910BattleService = preload("res://p9_10/battle_service.gd")
const P910BossBattleService = preload("res://p9_10/boss_battle_service.gd")
const P910SaveService = preload("res://p9_10/save_service.gd")
var boss_battle = P910BossBattleService.new()
var boss_battle_active := false
var focused_equipment_id := ""
var focused_equipment_target := ""
var focused_company_actor := ""
var focused_boss_plan := ""
var last_boss_direction := "front_to_rear"
var last_boss_rule := ""
func _bootstrap_market() -> void:
if entry_snapshot.is_empty():
last_code = "entry_snapshot_required"
_queue_render()
return
market = P910BossService.new()
var restored := false
if String(entry_snapshot.get("state_kind", "")) == "p9_10_contract":
restored = market.restore_snapshot(entry_snapshot)
elif String(entry_snapshot.get("state_kind", "")) == "p99_run":
market = P910BossService.new(entry_snapshot)
restored = market.state.invariants_hold()
if not restored:
market = null
last_code = "p910_snapshot_invalid"
_queue_render()
return
content = P910Content.snapshot(market.state.selected_charter_claim_id)
battle = P910BattleService.new(market.state.selected_charter_claim_id)
boss_battle = P910BossBattleService.new()
saves = P910SaveService.new()
pending_line_ids.assign(market.state.deployed_recruit_ids)
focused_hire_id = ""
focused_route_id = ""
focused_equipment_id = ""
focused_equipment_target = ""
focused_company_actor = ""
focused_boss_plan = ""
battle_active = false
boss_battle_active = false
battle_result_visible = false
last_code = "p910_ready"
_save_stable()
_queue_render()
func _process(delta: float) -> void:
if not low_power:
world_clock += maxf(0.0, delta)
_update_ambient_actors()
if not battle_active or battle_result_visible or battle_paused or not battle_auto_advance:
return
battle_tick_accumulator += maxf(0.0, delta) * float(battle_speed)
var ticks := int(floor(battle_tick_accumulator / 0.08))
if ticks <= 0:
return
battle_tick_accumulator -= float(ticks) * 0.08
advance_battle_ticks(ticks)
func tap_hire_actor(recruit_id: String) -> void:
if not _consume_interaction() or not _market_ready():
return
if focused_hire_id != recruit_id:
focused_hire_id = recruit_id
last_code = "hire_inspected"
_queue_render()
return
var result: Dictionary = market.hire(_operation("hire", recruit_id), recruit_id, market.state.revision)
last_code = String(result.code)
if result.ok:
focused_hire_id = ""
pending_line_ids.assign(market.state.deployed_recruit_ids)
_save_stable()
_queue_render()
func tap_equipment_offer(offer_id: String) -> void:
if not _consume_interaction() or not _market_ready():
return
if offer_id not in market.state.available_equipment_offer_ids:
return
if focused_equipment_id != offer_id:
focused_equipment_id = offer_id
focused_equipment_target = ""
last_code = "equipment_inspected"
else:
last_code = "equipment_target_required"
_queue_render()
func tap_company_actor(recruit_id: String) -> void:
if not _consume_interaction() or not _market_ready() or recruit_id not in market.state.company:
return
if not focused_equipment_id.is_empty():
if focused_equipment_target != recruit_id:
var preview: Dictionary = market.equipment_preview(focused_equipment_id, recruit_id)
last_code = String(preview.code)
if preview.ok:
focused_equipment_target = recruit_id
_queue_render()
return
var result: Dictionary = market.buy_and_equip(_operation("equip", recruit_id), focused_equipment_id, recruit_id, market.state.revision)
last_code = String(result.code)
if result.ok:
focused_equipment_id = ""
focused_equipment_target = ""
_save_stable()
_queue_render()
return
focused_company_actor = recruit_id
last_code = "company_inspected"
_queue_render()
func commit_final_reorder() -> void:
if not _consume_interaction() or not _market_ready() or market.state.current_market != 12 or market.state.final_order_confirmed:
return
var result: Dictionary
if market.state.proposed_final_order.is_empty():
var proposed: Array[String] = market.state.deployed_recruit_ids.duplicate()
proposed.reverse()
result = market.propose_final_order(_operation("order", "propose"), proposed, market.state.revision)
else:
result = market.confirm_final_order(_operation("order", "confirm"), market.state.revision)
last_code = String(result.code)
if result.ok:
pending_line_ids.assign(market.state.deployed_recruit_ids)
_save_stable()
_queue_render()
func tap_route(route_id: String) -> void:
if not _consume_interaction() or not _market_ready():
return
if focused_route_id != route_id:
focused_route_id = route_id
last_code = "route_preview"
_queue_render()
return
var result: Dictionary = market.commit_route(_operation("route", route_id), route_id, market.state.revision)
last_code = String(result.code)
if not result.ok:
_queue_render()
return
_save_stable()
battle = P910BattleService.new(market.state.selected_charter_claim_id)
start_committed_battle()
func recover_company() -> void:
if not _consume_interaction() or market == null or battle_active:
return
var result: Dictionary
if market.state.recovery_kind == "boss_defeat":
result = market.recover_boss_defeat(_operation("recover", "boss"))
else:
result = market.recover_defeat(_operation("recover", "company"))
last_code = String(result.code)
if result.ok:
_save_stable()
_queue_render()
func tap_boss_plan(plan: String) -> void:
if not _consume_interaction() or market == null or market.state.phase != P910State.Phase.BOSS_MUSTER or market.state.recovery_required:
return
if focused_boss_plan != plan:
focused_boss_plan = plan
last_code = "boss_plan_inspected"
_queue_render()
return
var result: Dictionary = market.commit_boss_plan(_operation("boss-plan", plan), plan, market.state.revision)
last_code = String(result.code)
if not result.ok:
_queue_render()
return
_save_stable()
start_committed_boss()
func return_to_boss_muster() -> void:
if not _consume_interaction() or market == null or market.state.phase != P910State.Phase.BOSS_ENCOUNTER or battle_active:
return
var result: Dictionary = market.return_to_boss_muster(_operation("boss", "back"))
last_code = String(result.code)
if result.ok:
focused_boss_plan = ""
_save_stable()
_queue_render()
func start_committed_boss() -> void:
boss_battle = P910BossBattleService.new()
var result: Dictionary = boss_battle.start_from_contract(market.state)
last_code = String(result.code)
if not result.ok:
_queue_render()
return
boss_battle_active = true
battle_active = true
battle_result_visible = false
battle_paused = false
battle_speed = 1
battle_tick_accumulator = 0.0
battle_actions.clear()
presented_event_counts.clear()
last_boss_direction = boss_battle.state.gust_direction
last_boss_rule = "boss_started"
_queue_render()
func advance_battle_ticks(ticks: int) -> Dictionary:
if not boss_battle_active:
return super.advance_battle_ticks(ticks)
if not battle_active or battle_result_visible:
return {"ok": false, "code": "battle_inactive", "events": []}
var result: Dictionary = boss_battle.advance_ticks(ticks)
last_code = String(result.code)
for event in result.events:
_handle_boss_event(event)
_update_boss_visuals()
if boss_battle.state.terminal:
battle_result_visible = true
_queue_render()
return result
func settle_and_return() -> void:
if not _consume_interaction() or not battle_active:
return
var result: Dictionary
if boss_battle_active:
if not boss_battle.state.terminal:
return
result = market.settle_boss(_operation("boss-settle", boss_battle.state.plan), boss_battle.state.snapshot())
else:
if not battle.state.terminal:
return
result = market.settle_battle(_operation("settle", battle.state.route_id), battle.state.snapshot())
last_code = String(result.code)
if not result.ok:
_queue_render()
return
battle_active = false
boss_battle_active = false
battle_result_visible = false
focused_route_id = ""
focused_boss_plan = ""
pending_line_ids.assign(market.state.deployed_recruit_ids)
_save_stable()
_queue_render()
func close_contract() -> void:
if not _consume_interaction() or market == null or market.state.phase != P910State.Phase.CONTRACT_RESULT:
return
var result: Dictionary = market.close_contract(_operation("close", "rg01-c02"))
last_code = String(result.code)
if result.ok:
_save_stable()
_queue_render()
func _market_ready() -> bool:
return market != null and not battle_active and market.state.phase == P910State.Phase.MARKET
func _save_stable() -> bool:
if not persistence_enabled or market == null:
return false
return bool(saves.save_snapshot(market.state.snapshot()).ok)
func _operation(kind: String, target: String) -> String:
operation_index += 1
return "p910-ui-%s-%d-%d-%s" % [kind, market.state.revision, operation_index, target]
func _render_current() -> void:
if display_root == null:
return
_clear_display()
if size.y > size.x:
_render_rotate_notice()
elif market == null:
_render_missing_entry()
elif battle_active and boss_battle_active:
_render_boss_battle()
elif battle_active:
_render_battle()
elif market.state.phase == P910State.Phase.BOSS_MUSTER:
_render_boss_muster()
elif market.state.phase == P910State.Phase.CONTRACT_RESULT:
_render_contract_result()
elif market.state.phase == P910State.Phase.RUN_CLOSED:
_render_persistent_result()
else:
_render_market()
func _render_market() -> void:
_add_background(WINDGLASS_BACKGROUND)
_add_market_header()
if market.state.recovery_required:
_render_recovery_ground()
_render_market_context()
return
_render_market_offers()
_render_company_line()
_render_route_gates()
_render_market_context()
func _render_battle() -> void:
super._render_battle()
_compact_battle_name_labels()
func _compact_battle_name_labels() -> void:
var compact := size.x < 900.0 or text_scale > 1.0
var width := 42.0 if compact else 104.0
var font_size := 7 if compact else 8
for node in display_root.find_children("P97BattleName_*", "Label", true, false):
var label := node as Label
if label == null:
continue
label.size.x = width
label.add_theme_font_size_override("font_size", _scaled(font_size))
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
var actor_id := String(label.name).trim_prefix("P97BattleName_")
var actor: TextureRect = battle_actors.get(actor_id)
if actor != null:
label.position = Vector2(actor.position.x + (actor.size.x - label.size.x) * 0.5, actor.position.y + actor.size.y - 6)
label.position.y += -6.0 if bool(actor.get_meta("enemy", false)) else 4.0
func _add_market_header() -> void:
var shade := ColorRect.new()
shade.name = "P910MarketHeader"
shade.position = Vector2.ZERO
shade.size = Vector2(size.x, 50)
shade.color = Color(0.015, 0.035, 0.075, 0.88)
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(shade)
var title := _outlined_label(_tr("第二契约 · 风廊 %d" % market.state.current_market, "CONTRACT 2 · GALEWAY %d" % market.state.current_market), 15, GOLD_LIGHT)
title.name = "P910MarketTitle"
title.position = Vector2(14, 3)
title.size = Vector2(size.x * 0.38, 43)
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(title)
var values := _outlined_label(_tr("◆%d 队伍 %d/%d 胜利 %d/12" % [market.state.coin, market.state.company.size(), market.state.line_capacity, market.state.cleared_rounds], "◆%d COMPANY %d/%d CLEARS %d/12" % [market.state.coin, market.state.company.size(), market.state.line_capacity, market.state.cleared_rounds]), 12, WHITE)
values.name = "P910MarketValues"
values.position = Vector2(size.x * 0.38, 3)
values.size = Vector2(size.x * 0.60 - 12, 43)
values.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
values.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(values)
func _render_market_offers() -> void:
var right_edge := size.x - 224.0
var recruit_ids: Array = market.state.available_recruit_offer_ids
var slot_width := minf(116.0, maxf(82.0, (right_edge - 24.0) / maxf(1.0, float(recruit_ids.size() + 2))))
for index in recruit_ids.size():
var recruit_id := String(recruit_ids[index])
var row: Dictionary = content.recruit_offers[recruit_id]
var actor_size := clampf(slot_width * 0.68, 54.0, 76.0)
var x := 18.0 + float(index) * slot_width
var y := 62.0
var actor := _add_actor(String(content.professions[row.profession_id].visual_rig), Vector2(x, y), actor_size, "P910HireActor_%s" % recruit_id, true)
actor.self_modulate = _p910_profession_color(String(row.profession_id))
display_root.add_child(_actor_target("P910HireTarget_%s" % recruit_id, Vector2(x - 4, y - 4), Vector2(actor_size + 8, 102), tap_hire_actor.bind(recruit_id)))
var label := _outlined_label("%s\n◆%d" % [_tr(String(row.name_zh), String(row.name_en)), int(row.list_price)], 8, GOLD_LIGHT if focused_hire_id == recruit_id else WHITE)
label.position = Vector2(x - 18, y + actor_size - 2)
label.size = Vector2(actor_size + 36, 42)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(label)
var equipment_ids: Array = market.state.available_equipment_offer_ids
for index in equipment_ids.size():
var offer_id := String(equipment_ids[index])
var row: Dictionary = content.equipment_offers[offer_id]
var x := 26.0 + float(recruit_ids.size()) * slot_width + float(index) * 80.0
var pedestal := TextureRect.new()
pedestal.name = "P910EquipmentObject_%s" % offer_id
pedestal.texture = _equipment_texture(String(row.catalog_id))
pedestal.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
pedestal.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
pedestal.position = Vector2(x, 70)
pedestal.size = Vector2(54, 54)
pedestal.self_modulate = GOLD_LIGHT if focused_equipment_id == offer_id else _equipment_color(String(row.catalog_id))
pedestal.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(pedestal)
display_root.add_child(_actor_target("P910EquipmentTarget_%s" % offer_id, Vector2(x - 4, 64), Vector2(64, 96), tap_equipment_offer.bind(offer_id)))
var label := _outlined_label("%s\n◆%d" % [_tr(String(row.name_zh), String(row.name_en)), int(row.list_price)], 8, WHITE)
label.position = Vector2(x - 18, 120)
label.size = Vector2(90, 40)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(label)
func _render_company_line() -> void:
var world_bottom := size.y - 58.0
var lane_width := size.x - 228.0
var count: int = market.state.company.size()
var actor_size := clampf(minf(size.y * 0.18, (lane_width - 20.0) / maxf(1.0, float(count))), 48.0, 68.0)
var spacing := minf(actor_size + 2.0, (lane_width - actor_size - 18.0) / maxf(1.0, float(count - 1)))
var y := world_bottom - actor_size - 12.0
for index in count:
var recruit_id: String = market.state.company[index]
var x := 12.0 + float(index) * spacing
var highlighted := recruit_id == focused_company_actor or recruit_id == focused_equipment_target
_add_ground_ring(Vector2(x + actor_size * 0.5, y + actor_size - 3), actor_size * 0.72, GOLD_LIGHT if highlighted else CYAN, "P910CompanyRing_%s" % recruit_id)
var actor := _add_actor(_recruit_rig(recruit_id), Vector2(x, y), actor_size, "P910CompanyActor_%s" % recruit_id, true)
actor.self_modulate = _recruit_color(recruit_id)
display_root.add_child(_actor_target("P910CompanyTarget_%s" % recruit_id, Vector2(x - 3, y - 4), Vector2(actor_size + 6, actor_size + 20), tap_company_actor.bind(recruit_id)))
var hp := _outlined_label("%d" % int(market.state.member_condition[recruit_id]), 8, WHITE)
hp.position = Vector2(x, y + actor_size - 5)
hp.size = Vector2(actor_size, 22)
hp.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(hp)
var instance_id := String(market.state.equipped_by_recruit.get(recruit_id, ""))
if not instance_id.is_empty():
var dot := _outlined_label("◆", 10, GOLD_LIGHT)
dot.position = Vector2(x + actor_size - 14, y + 2)
dot.size = Vector2(18, 18)
display_root.add_child(dot)
if market.state.current_market == 12 and not market.state.final_order_confirmed:
var reorder_text := _tr("确认换序", "CONFIRM ORDER") if not market.state.proposed_final_order.is_empty() else _tr("预览换序", "PREVIEW ORDER")
var reorder := _secondary_button("P910FinalReorderButton", reorder_text, commit_final_reorder)
reorder.position = Vector2(maxf(12, lane_width - 140), 164)
reorder.size = Vector2(132, 48)
display_root.add_child(reorder)
func _render_route_gates() -> void:
var routes: Array = market.state.available_battle_offer_ids
for index in routes.size():
var route_id := String(routes[index])
var row: Dictionary = content.routes[route_id]
var gate := ColorRect.new()
gate.name = "P910RouteGate_%s" % route_id
gate.position = Vector2(size.x - 214, 58 + float(index) * 82)
gate.size = Vector2(198, 70)
gate.color = _risk_color(int(row.risk), focused_route_id == route_id)
gate.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(gate)
var label := _outlined_label("%s ◆+%d\n%s" % [_tr(["稳行", "急行", "险行"][int(row.risk)], ["SURVEY", "PRESS", "HAZARD"][int(row.risk)]), int(row.reward), _tr(String(row.pressure_zh), String(row.pressure_en))], 9, WHITE)
label.position = gate.position + Vector2(8, 2)
label.size = gate.size - Vector2(16, 4)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(label)
display_root.add_child(_actor_target("P910RouteTarget_%s" % route_id, gate.position, gate.size, tap_route.bind(route_id)))
func _render_market_context() -> void:
var ribbon := ColorRect.new()
ribbon.name = "P910ContextRibbon"
ribbon.position = Vector2(0, size.y - 58)
ribbon.size = Vector2(size.x, 58)
ribbon.color = Color(0.015, 0.035, 0.075, 0.94)
ribbon.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(ribbon)
var context := _market_context_text()
var label := _outlined_label(context, 10, WHITE)
label.name = "P910ContextText"
label.position = Vector2(14, size.y - 55)
label.size = Vector2(size.x - 28, 52)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(label)
func _market_context_text() -> String:
if market.state.recovery_required:
return _tr("队伍战败。支付 ◆%d 恢复后再选择同一集市路线。" % market.state.recovery_cost, "COMPANY DEFEATED. Pay ◆%d to recover, then choose again from the same Market." % market.state.recovery_cost)
if not focused_equipment_id.is_empty():
var row: Dictionary = content.equipment_offers[focused_equipment_id]
var target_text := _tr("再次点击一名队员确认替换。", "Tap a Recruit again to confirm replacement.") if not focused_equipment_target.is_empty() else _tr("点击一名队员查看替换前后。", "Tap a Recruit to preview the replacement.")
return "%s · ◆%d · %s / %s %s" % [_tr(String(row.name_zh), String(row.name_en)), int(row.list_price), _tr(String(row.benefit_zh), String(row.benefit_en)), _tr(String(row.cost_zh), String(row.cost_en)), target_text]
if not focused_hire_id.is_empty():
var row: Dictionary = content.recruit_offers[focused_hire_id]
return "%s · ◆%d · %s / %s" % [_tr(String(row.name_zh), String(row.name_en)), int(row.list_price), _tr(String(row.benefit_zh), String(row.benefit_en)), _tr(String(row.cost_zh), String(row.cost_en))]
if not focused_route_id.is_empty():
var preview: Dictionary = market.route_preview(focused_route_id)
return _tr("再次点击进入:%s。奖励 ◆%d。" % [String(preview.pressure_zh), int(preview.reward)], "Tap again to enter: %s. Reward ◆%d." % [String(preview.pressure_en), int(preview.reward)])
if not focused_company_actor.is_empty():
var record: Dictionary = market.state.recruit_records[focused_company_actor]
return "%s · %s · %d HP" % [_tr(String(record.get("name_zh", focused_company_actor)), String(record.get("name_en", focused_company_actor))), _profession_name(focused_company_actor), int(market.state.member_condition[focused_company_actor])]
return _tr("点击人物、装备或路线查看;再次点击执行。战斗会自动进行。", "Tap a Recruit, gear object or route to inspect; tap again to act. Battles run automatically.")
func _render_recovery_ground() -> void:
var glow := ColorRect.new()
glow.name = "P910RecoveryGround"
glow.position = Vector2(size.x * 0.30, size.y * 0.25)
glow.size = Vector2(size.x * 0.40, size.y * 0.42)
glow.color = Color(0.18, 0.05, 0.09, 0.72)
display_root.add_child(glow)
var text := _outlined_label(_tr("整备倒下的队伍\n保持当前集市与所有报价\n◆%d" % market.state.recovery_cost, "RECOVER THE COMPANY\nKeep this Market and every offer\n◆%d" % market.state.recovery_cost), 16, WHITE)
text.position = glow.position + Vector2(14, 4)
text.size = glow.size - Vector2(28, 64)
text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(text)
var recover := _action_button("P910RecoveryButton", _tr("支付并恢复", "PAY AND RECOVER"), recover_company)
recover.position = Vector2(glow.position.x + glow.size.x * 0.5 - 92, glow.position.y + glow.size.y - 54)
recover.size = Vector2(184, 50)
recover.disabled = market.state.coin < market.state.recovery_cost
display_root.add_child(recover)
func _render_boss_muster() -> void:
_add_background(WINDGLASS_BACKGROUND)
_add_contract_header(_tr("风暴织机 · 集结", "GALE LOOM · MUSTER"), _tr("先看风向与弱轴,再选择计划", "Read the gust and weak spindle before choosing"))
_render_gust_path(false)
var boss := _add_actor("Riftblade", Vector2(size.x * 0.64, size.y * 0.22), minf(176.0, size.y * 0.46), "P910BossMusterActor", true)
boss.self_modulate = Color("#b5e8ef")
_add_boss_footprints(Vector2(size.x * 0.69, size.y * 0.69))
if market.state.recovery_required:
_render_boss_recovery()
return
var plans: Array = market.state.boss_muster.plans
for index in plans.size():
var row: Dictionary = plans[index]
var plan_id := String(row.id)
var target := ColorRect.new()
target.name = "P910BossPlanGround_%s" % plan_id
target.position = Vector2(18 + float(index) * 236, 72)
target.size = Vector2(220, 102)
target.color = Color(0.08, 0.16, 0.22, 0.90) if focused_boss_plan != plan_id else Color(0.34, 0.23, 0.08, 0.94)
target.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(target)
var label := _outlined_label("%s\n%s" % [_tr(String(row.name_zh), String(row.name_en)), _tr(String(row.summary_zh), String(row.summary_en))], 10, GOLD_LIGHT if focused_boss_plan == plan_id else WHITE)
label.position = target.position + Vector2(8, 3)
label.size = target.size - Vector2(16, 6)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(label)
display_root.add_child(_actor_target("P910BossPlanTarget_%s" % plan_id, target.position, target.size, tap_boss_plan.bind(plan_id)))
var context := _outlined_label(_boss_muster_context(), 10, WHITE)
context.name = "P910BossMusterContext"
context.position = Vector2(18, size.y - 66)
context.size = Vector2(size.x - 36, 58)
context.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(context)
func _boss_muster_context() -> String:
if focused_boss_plan == "break_spindle":
return _tr("再次点击:投射命中弱轴后逆转下一阵风,并打开短核心窗口;队序会移动。", "Tap again: projectile hits reverse the next gust and open a short core window; order will move.")
if focused_boss_plan == "hold_line":
return _tr("再次点击:保持队序并持续攻击核心;后端承受更强风压。", "Tap again: preserve order and race the core; the rear absorbs stronger pressure.")
return _tr("风从前端进入;弱轴只响应投射。半血后两端同时进风。战败恢复 ◆16,不重掷。", "Gust enters from the front; only projectile hits reverse the spindle. Both ends activate at half HP. Recovery costs ◆16 with no reroll.")
func _render_boss_recovery() -> void:
var band := ColorRect.new()
band.name = "P910BossRecoveryGround"
band.position = Vector2(22, size.y * 0.45)
band.size = Vector2(size.x * 0.52, 112)
band.color = Color(0.22, 0.05, 0.09, 0.88)
display_root.add_child(band)
var label := _outlined_label(_tr("织机仍保持同一风向与阶段\n支付 ◆16 恢复战前存活队员;此前阵亡者不会复活", "THE LOOM KEEPS THE SAME GUST AND PHASE\nPay ◆16 to restore pre-Boss survivors; prior casualties stay down"), 12, WHITE)
label.position = band.position + Vector2(10, 4)
label.size = Vector2(band.size.x - 20, 60)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(label)
var recover := _action_button("P910BossRecoveryButton", _tr("支付 ◆16 · 重新计划", "PAY ◆16 · REPLAN"), recover_company)
recover.position = Vector2(band.position.x + 10, band.position.y + 62)
recover.size = Vector2(214, 48)
recover.disabled = market.state.coin < 16
display_root.add_child(recover)
func _render_gust_path(in_battle: bool) -> void:
var count: int = boss_battle.state.member_ids.size() if in_battle else market.state.deployed_recruit_ids.size()
var direction: String = String(boss_battle.state.gust_direction) if in_battle else String(market.state.boss_muster.initial_gust_direction)
var start_x := 20.0
var width := minf(size.x * 0.56, 470.0)
var spacing := width / maxf(1.0, float(count))
for index in count:
var number := index + 1 if direction == "front_to_rear" else count - index
var label := _outlined_label(str(number), 10, CYAN)
label.name = "P910GustFootprint_%02d" % index
label.position = Vector2(start_x + spacing * index, 184 if not in_battle else 54)
label.size = Vector2(maxf(24.0, spacing - 2), 24)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(label)
var ends := _outlined_label(_tr("前端 → 后端", "FRONT → REAR") if direction == "front_to_rear" else _tr("后端 → 前端", "REAR → FRONT"), 10, GOLD_LIGHT)
ends.name = "P910GustDirection"
ends.position = Vector2(start_x, 205 if not in_battle else 75)
ends.size = Vector2(width, 24)
ends.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(ends)
func _add_boss_footprints(center: Vector2) -> void:
for index in range(2):
var footprint := ColorRect.new()
footprint.name = "P910BossFootprint_%d" % (index + 1)
footprint.position = center + Vector2(float(index) * 64 - 58, 0)
footprint.size = Vector2(54, 5)
footprint.color = VIOLET if index == 0 else CYAN
footprint.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(footprint)
func _render_boss_battle() -> void:
_add_background(WINDGLASS_BACKGROUND)
_render_gust_path(true)
var boss_size := clampf(size.y * 0.40, 126.0, 166.0)
var boss := _add_actor("Riftblade", Vector2(size.x * 0.70, size.y * 0.27), boss_size, "P910BossBattleActor", false)
boss.self_modulate = Color("#b5e8ef")
battle_actors[P910Content.BOSS_ID] = boss
_add_boss_footprints(Vector2(size.x * 0.75, size.y * 0.72))
var member_size := clampf(minf(64.0, (size.x * 0.60 - 24.0) / maxf(1.0, float(boss_battle.state.member_ids.size()))), 48.0, 64.0)
for recruit_id in boss_battle.state.member_ids:
var actor := _add_actor(_recruit_rig(recruit_id), Vector2.ZERO, member_size, "P910BossMember_%s" % recruit_id, false)
actor.self_modulate = _recruit_color(recruit_id)
battle_actors[recruit_id] = actor
_add_boss_battle_hud()
_update_boss_visuals()
if battle_result_visible:
_render_boss_battle_result()
func _add_boss_battle_hud() -> void:
var title := _outlined_label(_tr("风暴织机", "GALE LOOM"), 14, GOLD_LIGHT)
title.position = Vector2(14, 4)
title.size = Vector2(160, 42)
display_root.add_child(title)
var hp := _outlined_label("%d / %d" % [boss_battle.state.boss_hp, boss_battle.state.boss_max_hp], 12, WHITE)
hp.name = "P910BossHPText"
hp.position = Vector2(172, 4)
hp.size = Vector2(140, 42)
display_root.add_child(hp)
var status := _outlined_label(_boss_status_text(), 10, CYAN)
status.name = "P910BossRuleStatus"
status.position = Vector2(310, 4)
status.size = Vector2(maxf(120, size.x - 438), 42)
status.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(status)
var pause := _secondary_button("P910BossPauseButton", "▶" if battle_paused else "Ⅱ", toggle_battle_pause)
pause.position = Vector2(size.x - 112, 4)
pause.size = Vector2(48, 48)
display_root.add_child(pause)
var speed := _secondary_button("P910BossSpeedButton", "%dx" % battle_speed, toggle_battle_speed)
speed.position = Vector2(size.x - 58, 4)
speed.size = Vector2(50, 48)
display_root.add_child(speed)
func _boss_status_text() -> String:
var phase := _tr("双端风", "OPPOSED GUSTS") if boss_battle.state.phase_two else _tr("单向风", "SINGLE GUST")
var spindle := _tr("弱轴就绪", "SPINDLE READY") if boss_battle.state.spindle_ready else _tr("弱轴重整", "SPINDLE REARMING")
return "%s · %s · %s" % [_boss_plan_name(), phase, spindle]
func _update_boss_visuals() -> void:
if not boss_battle_active:
return
var count: int = boss_battle.state.member_ids.size()
var lane_width := size.x * 0.60
var spacing := (lane_width - 72.0) / maxf(1.0, float(count - 1))
for recruit_id in boss_battle.state.member_ids:
var actor: TextureRect = battle_actors.get(recruit_id)
if actor == null:
continue
var position := int(boss_battle.state.member_positions[recruit_id])
actor.position = Vector2(18 + spacing * position, size.y * 0.52)
var hp := int(boss_battle.state.member_hp[recruit_id])
var action: Dictionary = battle_actions.get(recruit_id, {})
actor.texture = animation.texture(_recruit_rig(recruit_id), String(action.get("state", "idle")), world_clock)
actor.modulate.a = 0.25 if hp <= 0 else 1.0
var boss: TextureRect = battle_actors.get(P910Content.BOSS_ID)
if boss != null:
var action: Dictionary = battle_actions.get(P910Content.BOSS_ID, {})
boss.texture = animation.texture("Riftblade", String(action.get("state", "idle")), world_clock)
boss.modulate.a = 0.35 if boss_battle.state.boss_hp <= 0 else 1.0
var hp_label := find_child("P910BossHPText", true, false) as Label
if hp_label != null:
hp_label.text = "%d / %d" % [boss_battle.state.boss_hp, boss_battle.state.boss_max_hp]
var status := find_child("P910BossRuleStatus", true, false) as Label
if status != null:
status.text = _boss_status_text()
func _handle_boss_event(event: Dictionary) -> void:
var kind := String(event.get("kind", ""))
presented_event_counts[kind] = int(presented_event_counts.get(kind, 0)) + 1
if kind == "member_action":
var source := String(event.get("source_member_id", ""))
var healing := String(event.get("action_family", "")) == "restore"
var target := String(event.get("target_member_id", P910Content.BOSS_ID))
battle_actions[source] = {"state": "lantern_heal" if healing else "draw_release" if String(event.get("action_family", "")) == "projectile" else "basic_attack"}
_spawn_number(int(event.get("delta", 0)), target, JADE if healing else CORAL)
if String(event.get("action_family", "")) == "projectile":
_spawn_projectile(source, P910Content.BOSS_ID)
elif kind == "boss_action":
var target := String(event.get("target_member_id", ""))
if not target.is_empty():
_spawn_number(int(event.get("delta", 0)), target, CORAL)
_spawn_vfx(target, HIT_VFX)
battle_actions[P910Content.BOSS_ID] = {"state": "attack"}
elif kind == "boss_rule":
last_boss_rule = String(event.get("rule", ""))
last_boss_direction = String(event.get("new_gust_direction", event.get("direction", boss_battle.state.gust_direction)))
var word: String = String({
"weak_spindle_hit": _tr("逆风", "REVERSE"),
"gust_through_enemy_footprints": _tr("反穿", "BACKFLOW"),
"half_vitality_opposed_gusts": _tr("双端风", "OPPOSED"),
"gale_path_advanced": _tr("阵风", "GUST"),
"opposed_gusts_resolved": _tr("夹风", "CROSSFLOW"),
}.get(last_boss_rule, last_boss_rule.to_upper()))
_spawn_word(String(event.get("target_member_id", P910Content.BOSS_ID)), word, CYAN)
elif kind == "boss_plan":
var target := String(event.get("moved_member_id", event.get("target_member_id", P910Content.BOSS_ID)))
_spawn_word(target, _tr("换序", "SHIFT") if String(event.get("plan", "")) == "break_spindle" else _tr("固线", "HOLD"), GOLD_LIGHT)
func _render_boss_battle_result() -> void:
var band := ColorRect.new()
band.name = "P910BossResultBand"
band.position = Vector2(0, size.y - 72)
band.size = Vector2(size.x, 72)
band.color = Color(0.015, 0.035, 0.075, 0.95)
display_root.add_child(band)
var won: bool = boss_battle.state.outcome == "victory"
var text := _outlined_label(_tr("织机停止 · 写入第二契约", "LOOM HALTED · RECORD CONTRACT 2") if won else _tr("队伍战败 · 返回同一集结", "COMPANY DEFEATED · RETURN TO THE SAME MUSTER"), 17, GOLD_LIGHT if won else CORAL)
text.position = Vector2(16, size.y - 68)
text.size = Vector2(size.x * 0.68, 60)
display_root.add_child(text)
var settle := _action_button("P910BossSettleButton", _tr("确认战果", "CONFIRM RESULT"), settle_and_return)
settle.position = Vector2(size.x - 212, size.y - 64)
settle.size = Vector2(194, 52)
display_root.add_child(settle)
func _handle_battle_event(event: Dictionary) -> void:
super._handle_battle_event(event)
if String(event.get("kind", "")) == "enemy_rule" and String(event.get("rule", "")) in ["route_gust", "opposed_route_gust"]:
var target := String(event.get("target_member_id", ""))
if target.is_empty() and not Array(event.get("target_member_ids", [])).is_empty():
target = String(event.target_member_ids[0])
_spawn_word(target, _tr("阵风", "GUST"), CYAN)
func _render_contract_result() -> void:
_add_background(WINDGLASS_BACKGROUND)
_add_contract_header(_tr("第二契约完成", "SECOND CONTRACT COMPLETE"), _tr("永久结算只执行一次", "Persistent settlement applies exactly once"))
var loom := _add_actor("Riftblade", Vector2(size.x * 0.42, size.y * 0.22), minf(160.0, size.y * 0.40), "P910StoppedLoom", true, "death")
loom.self_modulate = Color("#9ac4cc")
var summary := _outlined_label(_tr("风暴织机停止\n完成印记 CLEAR-RG01-C02\n+18 声望 · Company Record · 开启 RG-02", "GALE LOOM HALTED\nCLEAR-RG01-C02\n+18 RENOWN · COMPANY RECORD · RG-02 UNLOCK"), 16, WHITE)
summary.name = "P910ContractResultSummary"
summary.position = Vector2(size.x * 0.12, size.y * 0.22)
summary.size = Vector2(size.x * 0.42, size.y * 0.48)
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(summary)
var close := _action_button("P910CloseContractButton", _tr("写入公会记录", "WRITE GUILD RECORD"), close_contract)
close.position = Vector2(size.x * 0.62, size.y * 0.56)
close.size = Vector2(minf(248, size.x * 0.30), 54)
display_root.add_child(close)
func _add_contract_header(title_text: String, subtitle_text: String) -> void:
var shade := ColorRect.new()
shade.name = "P910ContractHeader"
shade.position = Vector2.ZERO
shade.size = Vector2(size.x, 56)
shade.color = Color(0.015, 0.035, 0.075, 0.88)
shade.mouse_filter = Control.MOUSE_FILTER_IGNORE
display_root.add_child(shade)
var title := _outlined_label(title_text, 16, GOLD_LIGHT)
title.position = Vector2(14, 2)
title.size = Vector2(size.x * 0.56, 50)
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
display_root.add_child(title)
var subtitle := _outlined_label(subtitle_text, 10, WHITE)
subtitle.position = Vector2(size.x * 0.48, 2)
subtitle.size = Vector2(size.x * 0.50 - 14, 50)
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(subtitle)
func _render_persistent_result() -> void:
_add_background(HALL_BACKGROUND)
_add_contract_header(_tr("风晶边境 · 两份契约完成", "WINDGLASS VERGE · TWO CONTRACTS CLEARED"), _tr("下一地区印记已点亮", "The next Region seal is now lit"))
var seal_width := (size.x - 36.0) / 8.0
for index in range(8):
var seal_id := "RG-%02d" % (index + 1)
var unlocked: bool = seal_id in market.state.unlocked_region_ids
var seal := _outlined_label("◆\n%s" % seal_id, 9, GOLD_LIGHT if unlocked else Color(0.42, 0.50, 0.58))
seal.name = "P910RegionSeal_%s" % seal_id
seal.position = Vector2(18 + seal_width * index, 56)
seal.size = Vector2(seal_width, 50)
seal.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
display_root.add_child(seal)
var record: Dictionary = market.state.company_records[-1]
var rows: Array = record.company
var actor_size := clampf(minf(68.0, (size.x * 0.60) / maxf(1.0, float(rows.size()))), 46.0, 68.0)
var spacing := minf(actor_size + 3.0, (size.x * 0.60 - actor_size) / maxf(1.0, float(rows.size() - 1)))
for index in rows.size():
var row: Dictionary = rows[index]
var profession: Dictionary = content.professions[String(row.profession_id)]
var actor := _add_actor(String(profession.visual_rig), Vector2(18 + spacing * index, size.y * 0.48), actor_size, "P910RecordActor_%s" % String(row.recruit_id), true, "victory")
actor.self_modulate = _p910_profession_color(String(row.profession_id))
var summary := _outlined_label(_tr("声望 %d\n完成印记 CLEAR-RG01-C02\nCompany Record %d 名 / 12 回合\nRG-02 已开启" % [market.state.persistent_renown, rows.size()], "RENOWN %d\nCLEAR-RG01-C02\nCOMPANY RECORD %d / 12 ROUNDS\nRG-02 UNLOCKED" % [market.state.persistent_renown, rows.size()]), 15, WHITE)
summary.name = "P910PersistentSummary"
summary.position = Vector2(size.x * 0.64, size.y * 0.30)
summary.size = Vector2(size.x * 0.33, size.y * 0.50)
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
display_root.add_child(summary)
func _boss_plan_name() -> String:
var plan: String = String(boss_battle.state.plan) if boss_battle_active else String(market.state.selected_boss_plan)
return _tr("破轴逆风", "BREAK SPINDLE") if plan == "break_spindle" else _tr("固线攻核", "HOLD LINE")
func _p910_profession_color(profession_id: String) -> Color:
return {
"PF-B01": GOLD_LIGHT,
"PF-B02": CORAL,
"PF-B03": JADE,
"PF-B04": CYAN,
"PF-B05": GOLD,
"PF-B06": VIOLET,
"PF-B07": JADE,
"PF-B08": Color("#ef9cbc"),
"PF-B09": Color("#78d9d0"),
"PF-B11": GOLD_LIGHT,
}.get(profession_id, WHITE)
func _battle_actor_name(actor_id: String) -> String:
if content.enemies.has(actor_id):
return _tr(String(content.enemies[actor_id].name_zh), String(content.enemies[actor_id].name_en))
var profession_id := String(_recruit_record(actor_id).get("profession_id", ""))
return _tr({
"PF-B01": "守卫",
"PF-B02": "长弦",
"PF-B03": "术士",
"PF-B04": "游侠",
"PF-B05": "弓手",
"PF-B06": "咒师",
"PF-B07": "治疗",
"PF-B08": "铸师",
"PF-B09": "潮语",
"PF-B11": "风匠",
}.get(profession_id, profession_id), {
"PF-B01": "GUARD",
"PF-B02": "BOW",
"PF-B03": "MAGE",
"PF-B04": "SCOUT",
"PF-B05": "ARCHER",
"PF-B06": "HEXER",
"PF-B07": "HEAL",
"PF-B08": "SMITH",
"PF-B09": "TIDE",
"PF-B11": "GALE",
}.get(profession_id, profession_id))