class_name P98RuntimeController extends "res://p9_7/runtime_controller.gd" const P98Content = preload("res://content/p9_8_content.gd") const P98BattleService = preload("res://p9_8/battle_service.gd") const P98BossBattleService = preload("res://p9_8/boss_battle_service.gd") const P98BossService = preload("res://p9_8/boss_service.gd") const P98ContractState = preload("res://p9_8/contract_state.gd") const P98SaveService = preload("res://p9_8/save_service.gd") const PANE_COLORS := { "projectile": Color("#73e3e6"), "spell": Color("#b29aff"), "forced_movement": Color("#ffb15e"), } var boss_battle = P98BossBattleService.new() var boss_mode := false var boss_actor: TextureRect var boss_hp_fill: ColorRect var boss_pane_label: Label func _bootstrap_market() -> void: if entry_snapshot.is_empty(): last_code = "entry_snapshot_required" _queue_render() return _install_p98_content() market = P98BossService.new() var restored := false if bool(entry_snapshot.get("p98_activated", false)): restored = market.state.restore_snapshot(entry_snapshot) else: restored = market.state.initialize_from_p97(entry_snapshot) if not restored: market = null last_code = "p98_snapshot_invalid" _queue_render() return battle = P98BattleService.new() saves = P98SaveService.new() pending_line_ids.assign(market.state.deployed_recruit_ids) selected_inventory_instance = "" focused_company_id = "" last_code = "p98_contract_ready" battle_active = false battle_result_visible = false boss_mode = false _save_stable() _queue_render() func _install_p98_content() -> void: content = P98Content.snapshot() var view_professions: Dictionary = content.base_professions.duplicate(true) for promotion_id in content.promotions: var promotion: Dictionary = Dictionary(content.promotions[promotion_id]).duplicate(true) var base: Dictionary = Dictionary(content.base_professions[promotion.parent_id]).duplicate(true) base.merge(promotion, true) view_professions[promotion_id] = base content["professions"] = view_professions func choose_artifact(artifact_id: String) -> void: if market == null: return _accept_market_result(market.choose_artifact(_operation("artifact", artifact_id), artifact_id), true) func choose_promotion(promotion_id: String) -> void: if market == null or market.state.promotion_draft.is_empty(): return var recruit_id := String(market.state.promotion_draft.recruit_id) _accept_market_result(market.confirm_promotion(_operation("promotion", promotion_id), recruit_id, promotion_id), true) func choose_boss_plan(plan: String) -> void: if market == null: return var committed: Dictionary = market.commit_boss_plan(_operation("boss-plan", plan), plan) last_code = String(committed.code) if not committed.ok: _queue_render() return _save_stable() boss_battle = P98BossBattleService.new() var started: Dictionary = boss_battle.start_from_contract(market.state) last_code = String(started.code) if not started.ok: _queue_render() return boss_mode = 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() _queue_render() func prepare_full_boss_line() -> void: if market == null or int(market.state.contract_phase) != P98ContractState.ContractPhase.BOSS_MUSTER or market.state.recovery_required: return var candidates: Array[String] = [] for recruit_id in market.state.company.slice(0, mini(market.state.line_capacity, market.state.company.size())): if int(market.state.member_condition.get(recruit_id, 0)) > 0: candidates.append(String(recruit_id)) var ordered: Array[String] = market.auto_order_candidate(candidates) if ordered.is_empty(): last_code = "boss_line_unavailable" _queue_render() return pending_line_ids.assign(ordered) _accept_market_result(market.commit_deployment(_operation("boss-deploy", "full-line"), ordered), true) func advance_battle_ticks(ticks: int) -> Dictionary: if not boss_mode: return super.advance_battle_ticks(ticks) if not battle_active or battle_result_visible: return {"ok": false, "code": "boss_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 boss_mode: super.settle_and_return() return if not battle_active or not boss_battle.state.terminal: return var result: Dictionary = market.settle_boss(_operation("boss-settle", boss_battle.state.plan), boss_battle.state.snapshot()) last_code = String(result.code) if not result.ok: _queue_render() return battle_active = false battle_result_visible = false boss_mode = false pending_line_ids.assign(market.state.deployed_recruit_ids) _save_stable() _queue_render() func close_contract() -> void: if market == null: return var result: Dictionary = market.close_contract(_operation("contract-close", "windglass")) last_code = String(result.code) if result.ok: pending_line_ids.clear() focused_company_id = "" _save_stable() _queue_render() func _operation(kind: String, target: String) -> String: operation_index += 1 return "p98-ui-%s-%d-%d-%s" % [kind, market.state.revision, operation_index, target] func _render_market() -> void: match int(market.state.contract_phase): P98ContractState.ContractPhase.BOSS_MUSTER: _render_boss_muster() P98ContractState.ContractPhase.CONTRACT_RESULT: _render_contract_result() P98ContractState.ContractPhase.RUN_CLOSED: _render_persistent_result() _: if market.state.selected_artifact_id.is_empty(): _render_artifact_shrine() elif not market.state.promotion_draft.is_empty(): _render_promotion_anvil() else: super._render_market() func _render_artifact_shrine() -> void: _add_background(HALL_BACKGROUND) _add_contract_header(_tr("风晶契约 · 选择共享规则", "WINDGLASS CONTRACT · CHOOSE A SHARED RULE"), _tr("从下一座集市起永久生效", "Permanent from the next Market")) var draft: Dictionary = market.state.artifact_draft var offers: Array = draft.get("offer_ids", []) for index in range(offers.size()): var artifact_id := String(offers[index]) var row: Dictionary = content.artifacts[artifact_id] var center_x := size.x * (0.34 if index == 0 else 0.66) _add_ground_ring(Vector2(center_x, size.y * 0.62), 150, CYAN if index == 0 else GOLD, "P98ArtifactRing_%s" % artifact_id) var glyph := _outlined_label("◇" if index == 0 else "◆", 58, CYAN if index == 0 else GOLD_LIGHT) glyph.position = Vector2(center_x - 48, size.y * 0.25) glyph.size = Vector2(96, 112) glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(glyph) var name_label := _outlined_label(_tr(String(row.name_zh), String(row.name_en)), 19, WHITE) name_label.position = Vector2(center_x - 160, size.y * 0.52) name_label.size = Vector2(320, 42) name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(name_label) var consequence := _outlined_label(_tr("获得:%s\n代价:%s" % [row.benefit_zh, row.cost_zh], "GAIN: %s\nCOST: %s" % [row.benefit_en, row.cost_en]), 11, WHITE) consequence.position = Vector2(center_x - 180, size.y * 0.62) consequence.size = Vector2(360, 66) consequence.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER consequence.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(consequence) var choose := _action_button("P98ArtifactButton_%s" % artifact_id, _tr("立下规则", "BIND RULE"), choose_artifact.bind(artifact_id)) choose.position = Vector2(center_x - 86, size.y - 66) choose.size = Vector2(172, 50) display_root.add_child(choose) func _render_promotion_anvil() -> void: _add_background(HALL_BACKGROUND) var draft: Dictionary = market.state.promotion_draft var recruit_id := String(draft.recruit_id) _add_contract_header(_tr("晋阶铁砧 · %s" % _recruit_name(recruit_id), "PROMOTION ANVIL · %s" % _recruit_name(recruit_id)), _tr("保留等级、生命、装备与队列位置", "Keeps level, health, equipment and line position")) var actor := _add_actor(_recruit_rig(recruit_id), Vector2(size.x * 0.5 - 58, size.y * 0.20), 116, "P98PromotionActor", true) actor.self_modulate = _recruit_color(recruit_id) for index in range(Array(draft.offer_ids).size()): var promotion_id := String(draft.offer_ids[index]) var row: Dictionary = content.promotions[promotion_id] var x := size.x * (0.27 if index == 0 else 0.73) var title := _outlined_label(_tr(String(row.name_zh), String(row.name_en)), 19, GOLD_LIGHT) title.position = Vector2(x - 175, size.y * 0.51) title.size = Vector2(350, 40) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(title) var choice := _outlined_label(_tr(String(row.choice_zh), String(row.choice_en)), 11, WHITE) choice.position = Vector2(x - 180, size.y * 0.61) choice.size = Vector2(360, 54) choice.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER choice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(choice) var button := _action_button("P98PromotionButton_%s" % promotion_id, _tr("选择晋阶", "PROMOTE"), choose_promotion.bind(promotion_id)) button.position = Vector2(x - 88, size.y - 66) button.size = Vector2(176, 50) display_root.add_child(button) func _render_boss_muster() -> void: _add_background(WINDGLASS_BACKGROUND) _add_contract_header(_tr("Boss 集结 · 多棱镜鹿", "BOSS MUSTER · THE MANY-PANED STAG"), _tr("投射屏障起始 · 半血后每次转两棱 · 鹿角横扫最前列", "Projectile pane first · two-step turns below half · sweep hits the foremost")) _add_ground_ring(Vector2(size.x * 0.68, size.y * 0.71), 190, VIOLET, "P98BossFootprintRear") _add_ground_ring(Vector2(size.x * 0.77, size.y * 0.71), 190, CYAN, "P98BossFootprintFront") var stag := _add_actor("Riftblade", Vector2(size.x * 0.66, size.y * 0.30), minf(190, size.y * 0.48), "P98BossMusterActor", true, "idle") stag.self_modulate = Color("#d9c6ff") var company_text := _outlined_label(_tr("战线 %d/%d · ◆%d · 当前 Artifact:%s" % [market.state.deployed_recruit_ids.size(), market.state.line_capacity, market.state.coin, _artifact_name()], "LINE %d/%d · ◆%d · ARTIFACT: %s" % [market.state.deployed_recruit_ids.size(), market.state.line_capacity, market.state.coin, _artifact_name()]), 12, WHITE) company_text.position = Vector2(18, size.y * 0.27) company_text.size = Vector2(size.x * 0.48, 44) display_root.add_child(company_text) if market.state.recovery_required: var recovery := _outlined_label(_tr("队伍溃败:支付 ◆%d,回到同一只鹿、同一棱面与同一阵容。" % market.state.recovery_cost, "DEFEAT: pay ◆%d and return to the same Stag, pane and Company." % market.state.recovery_cost), 13, CORAL) recovery.position = Vector2(18, size.y * 0.46) recovery.size = Vector2(size.x * 0.48, 62) recovery.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(recovery) var recover := _action_button("P98BossRecoveryButton", _tr("整队恢复 ◆%d" % market.state.recovery_cost, "RECOVER ◆%d" % market.state.recovery_cost), recover_company) recover.position = Vector2(18, size.y - 68) recover.size = Vector2(220, 52) display_root.add_child(recover) return var rally := _secondary_button("P98BossRallyButton", _tr("最终整编 %d/%d" % [market.state.deployed_recruit_ids.size(), market.state.line_capacity], "FINAL RALLY %d/%d" % [market.state.deployed_recruit_ids.size(), market.state.line_capacity]), prepare_full_boss_line) rally.position = Vector2(18, size.y * 0.38) rally.size = Vector2(224, 52) display_root.add_child(rally) var plans: Array = market.state.boss_muster.get("plans", []) for index in range(plans.size()): var row: Dictionary = plans[index] var x := 18.0 + float(index) * 240.0 var summary := _outlined_label(_tr(String(row.summary_zh), String(row.summary_en)), 10, WHITE) summary.position = Vector2(x, size.y * 0.52) summary.size = Vector2(224, 80) summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(summary) var button := _action_button("P98BossPlanButton_%s" % String(row.id), _tr(String(row.name_zh), String(row.name_en)), choose_boss_plan.bind(String(row.id))) button.position = Vector2(x, size.y - 68) button.size = Vector2(224, 52) display_root.add_child(button) func _render_battle() -> void: if not boss_mode: super._render_battle() for node_name in ["P97PauseButton", "P97SpeedButton"]: var control := find_child(node_name, true, false) as Control if control != null: control.position.y = 4 control.size.y = 48 return _render_boss_battle() func _render_boss_battle() -> void: _add_background(WINDGLASS_BACKGROUND) var title := _outlined_label(_tr("多棱镜鹿 · %s" % _boss_plan_name(), "THE MANY-PANED STAG · %s" % _boss_plan_name()), 15, WHITE) title.position = Vector2(16, 6) title.size = Vector2(size.x * 0.52, 42) display_root.add_child(title) boss_pane_label = _outlined_label("", 12, PANE_COLORS.get(boss_battle.state.pane, CYAN)) boss_pane_label.position = Vector2(size.x * 0.48, 6) boss_pane_label.size = Vector2(size.x * 0.25, 42) boss_pane_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER display_root.add_child(boss_pane_label) var pause := _secondary_button("P98BossPauseButton", "▶" 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("P98BossSpeedButton", "%dx" % battle_speed, toggle_battle_speed) speed.position = Vector2(size.x - 58, 4) speed.size = Vector2(50, 48) display_root.add_child(speed) for recruit_id in boss_battle.state.member_ids: var actor := _add_actor(_recruit_rig(recruit_id), Vector2.ZERO, clampf(size.y * 0.25, 92, 132), "P98BossBattleActor_%s" % recruit_id, false) actor.self_modulate = _recruit_color(recruit_id) battle_actors[recruit_id] = actor boss_actor = _add_actor("Riftblade", Vector2.ZERO, clampf(size.y * 0.46, 160, 220), "P98BossBattleActor_BO-R1-01", false) boss_actor.self_modulate = Color("#d9c6ff") battle_actors["BO-R1-01"] = boss_actor var hp_back := ColorRect.new() hp_back.name = "P98BossVitality" hp_back.position = Vector2(size.x * 0.58, 56) hp_back.size = Vector2(size.x * 0.34, 14) hp_back.color = Color(0.02, 0.04, 0.07, 0.94) display_root.add_child(hp_back) boss_hp_fill = ColorRect.new() boss_hp_fill.position = Vector2(2, 2) boss_hp_fill.size = Vector2(hp_back.size.x - 4, 10) boss_hp_fill.color = VIOLET hp_back.add_child(boss_hp_fill) _update_boss_visuals() if battle_result_visible: _render_boss_battle_result() func _update_boss_visuals() -> void: if not boss_mode or boss_actor == null: return for recruit_id in boss_battle.state.member_ids: var actor: TextureRect = battle_actors.get(recruit_id) if actor == null: continue var hp := int(boss_battle.state.member_hp[recruit_id]) var x := _battle_x(int(boss_battle.state.member_positions[recruit_id])) actor.position = Vector2(x - actor.size.x * 0.5, size.y * 0.51) actor.texture = animation.texture(_recruit_rig(recruit_id), "idle" if boss_battle.state.terminal else "advance", world_clock) actor.modulate.a = 0.25 if hp <= 0 else 1.0 actor.visible = hp > 0 or not boss_battle.state.terminal boss_actor.position = Vector2(_battle_x(100) - boss_actor.size.x * 0.62, size.y * 0.34) boss_actor.texture = animation.texture("Riftblade", "death" if boss_battle.state.boss_hp <= 0 else "attack" if int(boss_battle.state.tick) % 12 <= 2 else "idle", world_clock) if boss_hp_fill != null: boss_hp_fill.size.x = maxf(0, (size.x * 0.34 - 4) * float(boss_battle.state.boss_hp) / maxf(1, boss_battle.state.boss_max_hp)) if boss_pane_label != null: boss_pane_label.text = _tr("当前屏障:%s%s" % [_pane_name(boss_battle.state.pane), " · 双步旋转" if boss_battle.state.phase_two else ""], "PANE: %s%s" % [_pane_name(boss_battle.state.pane), " · TWO-STEP" if boss_battle.state.phase_two else ""]) boss_pane_label.add_theme_color_override("font_color", PANE_COLORS.get(boss_battle.state.pane, CYAN)) func _update_battle_control_labels() -> void: if not boss_mode: super._update_battle_control_labels() return var pause := find_child("P98BossPauseButton", true, false) as Button var speed := find_child("P98BossSpeedButton", true, false) as Button if pause != null: pause.text = "▶" if battle_paused else "Ⅱ" if speed != null: speed.text = "%dx" % battle_speed 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 target := String(event.get("target_member_id", "BO-R1-01")) var delta := int(event.get("delta", 0)) _spawn_number(delta, target, JADE if delta > 0 else CORAL) if target == "BO-R1-01": _spawn_projectile(source, target) elif kind == "boss_action": var target := String(event.get("target_member_id", "")) _spawn_number(int(event.get("delta", 0)), target, CORAL) _spawn_vfx(target, HIT_VFX) elif kind == "boss_rule": _spawn_word("BO-R1-01", _tr("转棱", "PANE TURN") if String(event.get("rule", "")) == "pane_rotated" else _tr("屏障", "SCREENED"), PANE_COLORS.get(String(event.get("to_family", event.get("pane", "projectile"))), CYAN)) elif kind == "boss_plan": _spawn_word("BO-R1-01", _tr("固阵", "BRACE") if String(event.get("plan", "")) == "brace" else _tr("破面", "ROTATE"), GOLD_LIGHT) func _render_boss_battle_result() -> void: var band := ColorRect.new() band.name = "P98BossResultBand" band.position = Vector2(0, size.y - 76) band.size = Vector2(size.x, 76) band.color = Color(0.015, 0.035, 0.075, 0.95) display_root.add_child(band) var won: bool = String(boss_battle.state.outcome) == "victory" var result := _outlined_label(_tr("契约首领击破 · 结算永久记录", "CONTRACT BOSS DEFEATED · RECORD THE CLEAR") if won else _tr("战线溃败 · 可返回同一集结点恢复", "COMPANY DEFEATED · RECOVER AT THE SAME MUSTER"), 18, GOLD_LIGHT if won else CORAL) result.position = Vector2(18, size.y - 70) result.size = Vector2(size.x * 0.67, 58) display_root.add_child(result) var button := _action_button("P98BossSettleButton", _tr("确认战果", "CONFIRM RESULT"), settle_and_return) button.position = Vector2(size.x - 212, size.y - 66) button.size = Vector2(194, 52) display_root.add_child(button) func _render_contract_result() -> void: _add_background(WINDGLASS_BACKGROUND) _add_contract_header(_tr("风晶契约已完成", "WINDGLASS CONTRACT COMPLETE"), _tr("永久结算只执行一次", "The persistent settlement applies exactly once")) var result: Dictionary = market.state.pending_boss_result var summary := _outlined_label(_tr("多棱镜鹿已击破\n计划:%s\n永久获得:3 声望 · Company Record · Boss/Artifact/晋阶图鉴" % _boss_plan_name(), "THE MANY-PANED STAG DEFEATED\nPLAN: %s\nPERSIST: 3 RENOWN · COMPANY RECORD · BOSS/ARTIFACT/PROMOTION DISCOVERIES" % _boss_plan_name()), 17, WHITE) summary.position = Vector2(size.x * 0.16, size.y * 0.27) summary.size = Vector2(size.x * 0.68, size.y * 0.42) summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER summary.vertical_alignment = VERTICAL_ALIGNMENT_CENTER summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(summary) var close := _action_button("P98CloseContractButton", _tr("写入公会编年史", "WRITE THE GUILD RECORD"), close_contract) close.position = Vector2(size.x * 0.5 - 132, size.y - 72) close.size = Vector2(264, 54) display_root.add_child(close) func _render_persistent_result() -> void: _add_background(HALL_BACKGROUND) _add_contract_header(_tr("风晶契约 · 永久完成", "WINDGLASS CONTRACT · PERMANENT CLEAR"), _tr("下一份契约方向已开启", "The next Contract direction is open")) var record: Dictionary = market.state.company_records[-1] if not market.state.company_records.is_empty() else {} var company_size := Array(record.get("company", [])).size() var summary := _outlined_label(_tr("声望 %d\n完成印记 CLEAR-RG01-C01\nCompany Record %d 名成员 / 12 回合\n已发现 %d 项\n\n本轮金币、队伍、装备与市场已结清;永久记录保留。" % [market.state.persistent_renown, company_size, market.state.discovery_ids.size()], "RENOWN %d\nCLEAR MARK CLEAR-RG01-C01\nCOMPANY RECORD %d MEMBERS / 12 ROUNDS\nDISCOVERIES %d\n\nRun Coin, Company, gear and Market are closed; the permanent record remains." % [market.state.persistent_renown, company_size, market.state.discovery_ids.size()]), 16, WHITE) summary.position = Vector2(size.x * 0.17, size.y * 0.24) summary.size = Vector2(size.x * 0.66, size.y * 0.55) summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER summary.vertical_alignment = VERTICAL_ALIGNMENT_CENTER summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(summary) func _add_contract_header(title_text: String, subtitle_text: String) -> void: var shade := ColorRect.new() shade.position = Vector2.ZERO shade.size = Vector2(size.x, 58) 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, 17, GOLD_LIGHT) title.position = Vector2(16, 2) title.size = Vector2(size.x * 0.58, 52) display_root.add_child(title) var subtitle := _outlined_label(subtitle_text, 11, WHITE) subtitle.position = Vector2(size.x * 0.48, 2) subtitle.size = Vector2(size.x * 0.50 - 16, 52) subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART display_root.add_child(subtitle) func _artifact_name() -> String: var artifact_id := String(market.state.selected_artifact_id) if artifact_id.is_empty() or not content.artifacts.has(artifact_id): return _tr("未选择", "NONE") var row: Dictionary = content.artifacts[artifact_id] return _tr(String(row.name_zh), String(row.name_en)) func _boss_plan_name() -> String: var plan := String(market.state.selected_boss_plan) return _tr("固阵反击", "BRACE AND PUNISH") if plan == "brace" else _tr("转棱破面", "ROTATE THE PANE") func _pane_name(pane: String) -> String: var names := { "projectile": [_tr("投射", "PROJECTILE")], "spell": [_tr("法术", "SPELL")], "forced_movement": [_tr("位移", "FORCED MOVE")], } return String(names.get(pane, [pane])[0])