Files
gplan-2/game/presentation/main.gd
T

1238 lines
63 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# 实现 GDD:design/ux/battle.md(战斗屏 A/B/C/D 四区 + 全屏 States + 遥测;E 区随可选类删除下线 2026-09-15)。
# Date: 2026-09-12(battle.md 战斗屏改造批;基础 = 2026-09-11 HUD框架-lite 组合根;code-review 修复批重构)
# 2026-09-13(封装审查修复批):核心状态读取收口至 战斗结算/runner 成文只读面(presentation_snapshot /
# current_board / current_battle 等——禁链式穿透 settlement.enemy_system.hero 一类路径);调试模式编排抽离
# debug_session.gd(规范 1.1 审视线 + 调试语义单一实现)。
# 组合根(composition root):唯一的模式流转与 core↔view 接线处。
# 职责边界:① 构建四区视图并接线信号(节点构建归 ui_assembler.gd——规范 1.1,本类只接线)
# ② 模式管理(正式冒险 AdventureRunner——唯一试玩入口;调试两层编排归 debug_session.gd;单场原语仅探针使用)
# ③ 核心写调用只在此处发生(minesweeper.settle_batch / 刷新编排入口 / runner 推进;
# E4 收口:加成值零表写——面板百分比经 runner.set_hero_override / debug_session 生效值 Callable
# 以 encounter 参数入战斗,db.hero 运行期只读)
# ④ 全屏 States 状态机(挖掘/结算/终局)与一次性提示去重。
# 2026-09-15 格面资源批:道具窗口与库存面板随可选类道具删除下线——E 区移除,窗口接线/选择序列删除。
# GP+商店房实施批(2026-09-21):商店覆盖层挂接与信号接线(装配器零信号连接纪律——句柄返回、
# 本类连接)+ runner.wallet_changed 转发试玩日志(wallet 行族)+ A 区顶栏金币余额。
# 渲染与文案归 views/,核心状态→文案装配归 hud_presenter.gd 与 battle_narrator.gd,节点构建归
# ui_assembler.gd,模态层级归 modal_stack.gd,批量模拟编排归 tune_sim_controller.gd,调试模式编排归
# debug_session.gd——本类不拼呈现字符串。
# T3 拆件批(2026-10-02,规范 1.1 单一职责):结算行排播泵迁 row_pacer.gd(本类只入队/排空/读队列,
# 探针/测试委托面 _row_queue/pace_delay_for/_flush_row_queue 原位保留);魔法道具两步交互迁
# aux_interact_controller.gd(本类只接线与转发意图,无目标使用显式化——哨兵坐标不出控制器);
# GP 冒险钱包影子账迁 gp_shadow_ledger.gd(本类留探针同名读写委托面)。模态开合全量走
# _open_modal/_close_modal 单口,输入锁=modal_stack.has_open() 开态查询+三例外;battle 收尾反馈与
# 结算视觉 setter 收敛 hud_presenter 单通道(时序敏感演出触发留本类并注释);settlement 相位读取改经
# presentation_snapshot 成文面、变异表读取直读本根 db(settlement.db 即同一实例)——禁
# settlement.action_economy / settlement.db 链式穿透。
# ADR-0001 纪律:呈现层永不直接订阅 排雷玩法/行动经济——只订 战斗结算/runner 转发信号;相位读取仅用于
# 驱动视图状态;信号连接只在组合根(row_pacer/aux_interact_controller 零连接,只收注入)。
extends Control
const UiAssembler := preload("res://presentation/ui_assembler.gd")
const Narrator := preload("res://presentation/battle_narrator.gd")
const ModalStack := preload("res://presentation/modal_stack.gd")
const TuneSimController := preload("res://presentation/tune_sim_controller.gd")
const DebugSessionScript := preload("res://presentation/debug_session.gd")
const RowPacer := preload("res://presentation/row_pacer.gd") # 结算行排播泵(T3 拆件批)
const AuxInteractController := preload("res://presentation/aux_interact_controller.gd") # 魔法道具两步交互(T3 拆件批)
const GpShadowLedgerScript := preload("res://presentation/gp_shadow_ledger.gd") # GP 影子账本(T3 拆件批)
const SaveManagerScript := preload("res://core/save_manager.gd") # 存档承载(preload 避开全局类缓存时序——与 DebugSessionScript 同款)
const MetaProgressionScript := preload("res://core/meta_progression.gd") # 元进度账本服务(元进度实现批——同 preload 纪律)
var db
var settlement = null # 当前战斗编排器(关卡与调试模式统一读它)
var runner = null # 关卡推进器(冒险模式每关重取 adventure.runner();单关 UI 直入口已退役——LevelRunner 仅存 headless 测试面)
var adventure = null # 冒险结构编排器(冒险结构实现批——三关连探;转发信号照 runner 接线原样对接)
var save_manager = null # 存档序列化承载(存档序列化批——启动读盘/冒险收束写盘;只经访问器与 apply 原语,禁读内部结构)
var meta = null # 元进度账本服务(元进度实现批——余额/定价/购买/工坊解锁/Boss两档入账/注入计算;零信号纯被动)
var _adventure_mode := false # 冒险模式标记(inter 层确认分派与终局分派)
var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件)
var _level_active := false
var _seed_edit: LineEdit
var _resume_btn: Button # 继续上局按钮句柄(迭代 8 卡②——有断点才亮)
var confirm_dialog: ConfirmDialog # 通用确认弹窗(二轮修改批 A——新冒险互斥确认弹窗化)
var _settlement_stop_pending := false # 卡⑥ 战斗胜利总结结算框挂起态(确认「下一节点」后进选路页)
var _pending_candidates: Array = [] # 结算框挂起期的选路候选暂存(choices_presented 载荷)
var _last_gain_amount := 0 # 最近一笔钱包入账(卡⑥ GP 增量取数面——wallet_changed gain 载荷)
var _pending_bonus := {} # 三选一停点载荷暂存(迭代 10——战斗总结结算确认后开三选一面;续玩停点复用)
var bonus_overlay: BonusPickOverlay # 提升三选一覆盖层(迭代 10——level-clear.md v2 选择面承载;追加批起奖励节点共用)
var _bonus_context := "level_clear" # 三选一面上下文(level_clear=通关停点→推进下一关 / reward_node=奖励节点→回图选路)
var _start_overlay: Control
var _end_overlay: BattleEndOverlay
var _inter_overlay: Control
var _inter_label: Label
var level_screen: LevelMapScreen # 关卡画面(独立屏——2026-09-28 形态升格批:激活期战斗屏宿主整体隐藏;零自动推进停点语义不变)
var _battle_root: Control # 战斗屏宿主(B/C/D 三区容器——独立屏激活期 visible=false 级互斥)
var _level_screen_active := false # 独立屏激活状态(覆盖兑付覆盖层在屏域内弹出期间——战斗屏保持隐藏)
# 调试模式编排(单场/连续两层——状态机与战斗生命周期归 debug_session.gd,本类只接线与出 UI)
var _debug = null
# 视图(views/)——battle.md 三区(A 区关卡进度条顶栏随 停点前移批 2026-09-23 退役——节点链进度
# 由关卡画面本体选路覆盖层承载;E 区道具面板随可选类删除下线)
var stage: BattleStageView # B 区(含 log_view 明细带)
var log_view: BattleLogView
var info_panel: HeroInfoPanelView # C 区(攻击/AC 数据框+两预留区——2026-09-26 信息归属拍板精简)
var board_zone: BoardZoneView # D 区(含 ap_bar/board_view/toast/fx)
var board_view: BoardView # 探针兼容别名(= board_zone.board_view)
var ap_bar: ApBarView # 探针兼容别名(= board_zone.ap_bar)
var settlement_fx: SettlementFxView # 结算演出覆盖层(跨区弹道/光带——A–D 之上、模态之下)
# T3 拆件批(2026-10-02)——三件迁出后的组合根持有面(职责见各类头注;装配见 _build_ui)
var _row_pacer = null # 结算行排播泵(row_pacer.gd——本类只入队/排空/读队列)
var _aux = null # 魔法道具两步交互(aux_interact_controller.gd——本类只接线与转发意图)
var _gp_ledger := GpShadowLedgerScript.new() # GP 冒险钱包影子账(gp_shadow_ledger.gd——wallet 事件投喂)
# GP 影子账委托面(探针/终局面板取数保留——ui_probe 合成注入经 set 转发账本,读面即账本实况)
var _adventure_gp_view: int:
get:
return _gp_ledger.view_total()
set(value):
_gp_ledger.set_view_total(value)
var _adventure_gp_final: int:
get:
return _gp_ledger.final_total()
# 排播队列只读委托面(探针/测试读 main._row_queue——RowPacer 迁移后保留的同名可读面,共享同一 Array 引用)
var _row_queue: Array:
get:
return _row_pacer.pending_rows()
# 模态 overlay(z 序 = 登记序;pattern 6 层级纪律)
var legend_overlay: LegendOverlay
var review_overlay: SettlementReviewOverlay
var node_overlay: NodeOverlay
var shop_overlay: ShopOverlay # 商店覆盖层(GP+商店房实施批——商店房节点兑付呈现)
var camp_overlay: CampOverlay # 营地覆盖层(元进度实现批——主菜单营地入口兑付呈现)
var settings_store # 系统级设置独立存储(迭代 8 卡④——音量/全屏/减动效)
var settings_overlay: SettingsOverlay # 设置覆盖层(迭代 8 卡④——最小设置面)
var tune_panel: TunePanel
var _modals = null # 模态层级状态机(modal_stack.gd——缺陷⑤纪律载体)
var _tune_sim = null # 调整板批量模拟控制器(tune_sim_controller.gd)
var _hints_shown := {} # 一次性非模态提示去重(每 run 一次:chord/no_adjacent)
var _refresh_used_round := 0 # 本轮已受理刷新次数(遥测 round_refresh_index——每轮 round_started 归零)
var _boss_active := false # Boss 遭遇变体(阶段指示附 Boss 名)
var _was_settling := false # 结算相位上升沿检测(结算轮光带触发——演出链批)
func _ready() -> void:
db = get_node("/root/ConfigDB")
if not db.is_ready:
var ok: bool = db.load_tables("res://data/tables", false)
if not ok:
_fatal_screen("数值表加载失败——见输出错误清单(fail-fast)")
return
# 存档读盘(存档序列化批):拒读/损坏均已在 SaveManager 内部兜底,失败不阻断启动
# (fatal 只留给数值表——fail-fast 边界在表加载,不在档);高版本拒读态后续写盘被类内拒绝。
save_manager = SaveManagerScript.new()
save_manager.load_from_disk()
# 系统级设置(迭代 8 卡④——独立存储不随存档):启动即恢复音量/全屏/减动效引擎态
settings_store = SettingsStore.new()
settings_store.load_settings()
settings_store.apply()
# 元进度账本(元进度实现批):存档就绪后建——余额/定价/购买/入账/注入全经此单点
meta = MetaProgressionScript.new(db, save_manager)
_debug = DebugSessionScript.new(db, get_node("/root/RngService"),
Callable(self, "_debug_hero_override"), Callable(self, "_seed_value"))
_debug.battle_created.connect(_on_debug_battle_created)
_debug.battle_ready.connect(_on_debug_battle_ready)
_build_ui()
_refresh_resume_button() # 迭代 8 卡②——启动读盘后按断点在档态点亮「继续上局」
_start_overlay.visible = true
# ==================== 视图接线(battle.md 四区布局 1280×800;节点构建归 UiAssembler) ====================
func _build_ui() -> void:
var root := VBoxContainer.new()
add_child(root)
_battle_root = root # 独立屏互斥面(2026-09-28 形态升格批)
var battle_ui: Dictionary = UiAssembler.build_battle_ui(root, db)
stage = battle_ui.stage
log_view = battle_ui.log_view
info_panel = battle_ui.info_panel
board_zone = battle_ui.board_zone
board_view = battle_ui.board_view # 探针兼容别名(= board_zone.board_view)
ap_bar = battle_ui.ap_bar # 探针兼容别名(= board_zone.ap_bar)
settlement_fx = battle_ui.settlement_fx
# 结算行排播泵(T3 拆件批——迁 row_pacer.gd;本类注入视图句柄与呈现刷新回调,零信号连接)
_row_pacer = RowPacer.new()
_row_pacer.log_view = log_view
_row_pacer.stage = stage
_row_pacer.board_zone = board_zone
_row_pacer.settlement_fx = settlement_fx
_row_pacer.board_view = board_view
_row_pacer.refresh_request = _refresh_presentations
add_child(_row_pacer)
# 魔法道具两步交互(T3 拆件批——迁 aux_interact_controller.gd;settlement/runner 随关卡更替,
# 经现值回调注入——仿 identity_resolver 只读回调先例)
_aux = AuxInteractController.new(db, meta, info_panel, board_zone.toast, board_view,
func() -> Variant: return settlement, func() -> Variant: return runner)
log_view.review_requested.connect(_open_review_from_band)
board_zone.action_requested.connect(_on_board_action)
info_panel.aux_slot_clicked.connect(_aux.on_slot_clicked) # 迭代 7 T4——两步交互第一步
board_zone.stop_pressed.connect(_on_stop_pressed)
board_zone.refresh_confirmed.connect(_on_refresh_confirmed)
board_zone.legend_toggled.connect(_on_legend_toggled)
board_zone.hover_forbidden.connect(_on_hover_forbidden)
board_zone.chord_attempted.connect(_on_chord_attempted)
# 奖励徽章数据源:地图战斗资源 身份解析只读回调(已开格身份 icon——挖掘期 Active 相位内有效)
board_view.set_identity_resolver(_identity_lookup)
# 数字遮蔽数据源:地形变异 hide_number 解析只读回调(信息型呈现——board_view 渲染面读取)
board_view.set_variant_mask_resolver(_variant_mask_lookup)
board_view.set_variant_mark_resolver(_variant_mark_lookup) # 迭代 6 T6——flat_damage 未开格角标
logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局
# < 商店 < 营地 < 调整板 < 设置 < 确认弹窗)
var overlays: Dictionary = UiAssembler.build_overlays(self, db, int(db.hero.atk), int(db.hero.max_hp))
legend_overlay = overlays.legend_overlay
review_overlay = overlays.review_overlay
node_overlay = overlays.node_overlay
_end_overlay = overlays.end_overlay
shop_overlay = overlays.shop_overlay
camp_overlay = overlays.camp_overlay
tune_panel = overlays.tune_panel
level_screen = LevelMapScreen.new()
add_child(level_screen)
# 树序 = 模态登记序 = z 遮挡序(缺陷⑤纪律)——关卡画面须夹在节点层与商店层之间
#(兑付覆盖层在本屏域内弹出:模态栈打开低层时自动隐藏高层本屏、关闭自动恢复——零机制改动复用)
move_child(level_screen, node_overlay.get_index() + 1)
legend_overlay.closed.connect(_on_modal_closed.bind(legend_overlay))
review_overlay.closed.connect(_on_modal_closed.bind(review_overlay))
node_overlay.continue_pressed.connect(_on_node_continue)
level_screen.route_chosen.connect(_on_route_chosen)
level_screen.abandon_requested.connect(_on_abandon_requested) # 放弃入口(2026-09-21 用户拍板——台账 119)
shop_overlay.buy_requested.connect(_on_shop_buy)
shop_overlay.leave_pressed.connect(_on_shop_leave)
camp_overlay.buy_requested.connect(_on_camp_buy)
camp_overlay.closed.connect(_on_camp_closed)
_end_overlay.review_pressed.connect(_on_end_review)
_end_overlay.continue_pressed.connect(_on_battle_end_continue)
tune_panel.closed.connect(_close_tune_panel)
tune_panel.apply_requested.connect(_on_tune_apply)
tune_panel.simulate_requested.connect(_on_tune_simulate)
settings_overlay = SettingsOverlay.new(settings_store) # 迭代 8 卡④——最小设置面(音量/全屏/减动效)
add_child(settings_overlay)
settings_overlay.closed.connect(_on_settings_closed)
# 提升三选一覆盖层(迭代 10——z 序 = 节点层之上、终局层之下;确认后同拍推进)
bonus_overlay = BonusPickOverlay.new()
add_child(bonus_overlay)
bonus_overlay.pick_confirmed.connect(_on_bonus_pick_confirmed)
bonus_overlay.replace_chosen.connect(_on_bonus_replace_chosen)
bonus_overlay.continue_pressed.connect(_on_bonus_continue)
move_child(bonus_overlay, _end_overlay.get_index())
var start: Dictionary = UiAssembler.build_start_overlay(self, db)
_start_overlay = start.overlay
_seed_edit = start.seed_edit
_resume_btn = start.resumebtn
start.lvbtn.pressed.connect(_start_adventure_from_menu)
start.resumebtn.pressed.connect(_resume_from_menu) # 迭代 8 卡②——续玩入口
start.tunebtn.pressed.connect(_show_tune_panel)
start.dbgbtn.pressed.connect(_start_debug_levels)
start.dbgbtn.visible = OS.get_environment("DS_DEBUG") == "1" # 迭代 8 卡④——调试入口默认隐藏门控(DS_DEBUG=1 点亮)
start.campbtn.pressed.connect(_open_camp)
start.settingsbtn.pressed.connect(_open_settings) # 迭代 8 卡④——最小设置面入口
start.quitbtn.pressed.connect(_quit_game) # 二轮修改批 D1——退出游戏提级一级菜单
var inter: Dictionary = UiAssembler.build_inter_overlay(self)
_inter_overlay = inter.overlay
_inter_label = inter.label
inter.next_btn.pressed.connect(_on_inter_next_pressed)
var diag_btn := UiAssembler.build_diag_button(self)
diag_btn.pressed.connect(_on_diagnose_pressed)
# 确认弹窗(二轮修改批 A——新冒险互斥确认弹窗化):树序落在开始层/过场层/诊断钮之后 = 子级最末
#(T3 收口批——「登记序 = 树序 = z 序」纪律复位,confirm_dialog.open 的 move_child 运行时自提
# 创可贴随之删除;弹窗只在开始菜单域弹出,绘制序恒最上)
confirm_dialog = ConfirmDialog.new()
add_child(confirm_dialog)
confirm_dialog.confirmed.connect(_on_confirm_new_adventure)
confirm_dialog.cancelled.connect(_on_confirm_cancelled)
# 模态登记(缺陷⑤纪律——登记序 = 树序:含 move_child 调位后的关卡画面/三选一终位)
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, level_screen, bonus_overlay, _end_overlay, shop_overlay, camp_overlay, tune_panel, settings_overlay, confirm_dialog]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
func _fatal_screen(msg: String) -> void:
var l := UiKit.label(msg, 20, true)
l.add_theme_color_override("font_color", UiKit.RED_ALERT)
l.set_anchors_preset(Control.PRESET_CENTER)
add_child(l)
# ==================== 模态 overlay 层级管理(pattern 6 / 缺陷⑤——状态机归 modal_stack.gd) ====================
func _register_modal(m: Control) -> void:
# 撑满宿主(Control 默认锚 = 0×0——全屏变暗层与居中布局整体失效;route 选路层预览截图实证后
# 统一收口:六个模态同病,探针经信号直发绕过真实点击从未暴露。anchors_and_offsets 而非 anchors
# ——后者会把控件重置回最小内容尺寸,ui_assembler 头注同款纪律)
m.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_modals.register(m)
func _open_modal(m: Control) -> void:
# 打开时显式隐藏树序在其上方的可见层(遮挡物隐藏),关闭恢复——单一模态栈
_flush_row_queue() # 卡⑦——任何覆盖层开启前排空排播队列(明细带恒完整,回看/停点面不缺行)
_modals.open(m)
func _close_modal(m: Control) -> void:
_modals.close(m)
func _on_modal_closed(m: Control) -> void:
_close_modal(m)
func _input_locked() -> bool:
# 模态/非战斗期棋盘输入锁(T3 收口批——开态查询归 modal_stack.has_open(),登记册单一来源,
# 不再手工罗列各层;例外三件:开始层/过场层(非模态全屏态,不在登记册)与战斗缺位(棋盘输入无载体))
return _modals.has_open() or _start_overlay.visible or _inter_overlay.visible or settlement == null
# ==================== 冒险模式入口(唯一试玩入口——单关测试直入口退役 2026-09-23 用户拍板)====================
func _start_adventure_from_menu() -> void:
# 互斥确认弹窗化(二轮修改批 A——用户目检裁决「弹窗提示」):有断点时开新冒险弹实底确认框,
# 「开新冒险」提交(弃旧断点+写盘)/「取消」回菜单
if save_manager != null and save_manager.has_breakpoint():
var bp: Dictionary = save_manager.breakpoint_snapshot()
confirm_dialog.open("已有未完冒险(第 %d 关)——开新冒险将覆盖旧断点" % maxi(1, int(bp.get("level_index", 1))),
"开新冒险", "取消")
return
_start_overlay.visible = false
_start_adventure()
func _on_confirm_new_adventure() -> void:
confirm_dialog.close()
if save_manager != null:
save_manager.clear_breakpoint() # 提交即弃旧断点(覆盖语义对盘面即时生效)
save_manager.write_to_disk()
_start_overlay.visible = false
_start_adventure()
func _on_confirm_cancelled() -> void:
confirm_dialog.close() # 回开始菜单(无状态变更)
# 退出游戏(二轮修改批 D1——一级菜单入口;设置即时落盘后退出)
func _quit_game() -> void:
if settings_store != null:
settings_store.save_settings()
get_tree().quit()
# 继续上局按钮可见性 = 断点在档态(迭代 8 卡②——启动读盘后与每次回开始菜单刷新)
func _refresh_resume_button() -> void:
if _resume_btn == null or save_manager == null:
return
var has: bool = save_manager.has_breakpoint()
_resume_btn.visible = has
if has:
var bp: Dictionary = save_manager.breakpoint_snapshot()
_resume_btn.text = "▶ 继续上局(第 %d 关 · 自最近停点恢复)" % maxi(1, int(bp.get("level_index", 1)))
# 继续上局(迭代 8 卡②——卡① resume_adventure 的消费入口):断点重建至最近停点
func _resume_from_menu() -> void:
if save_manager == null or not save_manager.has_breakpoint():
return
var bp: Dictionary = save_manager.breakpoint_snapshot()
_start_overlay.visible = false
_wire_adventure()
_level_active = true
level_screen.set_abandon_available(true)
_reset_run_scope()
log_view.clear()
logger.start_session(int(bp.get("master_seed", 0)))
adventure.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct())
if not adventure.resume_adventure(bp, {"hero_override": meta.hero_override() if meta != null else {}, "aux_unlocked": _aux.unlocked_set()}):
_fatal_screen("续玩恢复失败——见输出日志")
return
# 冒险编排构建+接线(开新局/续玩共用——信号连接只在组合根,ADR-0001;断点注入同口)
func _wire_adventure() -> void:
adventure = AdventureRunner.new(db, get_node("/root/RngService"))
_adventure_mode = true
adventure.step_row.connect(_on_step_row)
adventure.round_started.connect(_on_round_started)
adventure.board_changed.connect(_on_board_changed)
adventure.node_entered.connect(_on_runner_node)
adventure.choices_presented.connect(_on_choices_presented)
adventure.wallet_changed.connect(_on_wallet_changed)
# 迭代 10 奖励重构批——big_reward_paid 退役,三选一信号族接线
adventure.bonus_choice_presented.connect(_on_bonus_presented)
adventure.bonus_resolved.connect(_on_bonus_resolved)
adventure.aux_replace_presented.connect(_on_aux_replace_presented)
adventure.adventure_finished.connect(_on_adventure_finished)
adventure.level_started.connect(_on_adventure_level_started)
adventure.set_save_manager(save_manager) # 断点承载注入(迭代 8 卡①——停点快照写盘;调试/模拟不经过此路径)
# 冒险模式(三关连探——AdventureRunner 编排;转发信号签名与 runner 一致,接线照原样对接;
# 信号连接只在组合根——ADR-0001)
func _start_adventure() -> void:
_wire_adventure()
_level_active = true
level_screen.set_abandon_available(true) # 放弃入口挂选路停点(天然非战斗期——2026-09-21 用户拍板)
_reset_run_scope()
log_view.clear()
var seed_val := _seed_value()
logger.start_session(seed_val)
adventure.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct())
# 永久强化注入(元进度实现批):冒险开始参数面——meta 现算 override 显式传参(全零 = 白板)
# 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签
var first_script: bool = not save_manager.is_first_adventure_done()
if not adventure.start_adventure(seed_val, {"hero_override": meta.hero_override() if meta != null else {}, "first_script": first_script, "aux_unlocked": _aux.unlocked_set()}):
_fatal_screen("冒险启动失败——见输出日志")
return
# 每关开始(冒险模式):重取 runner 实例 + 层名行(L1 与单关模式同序——层名行随首战横幅后到达;
# 停点前移批 2026-09-23:本回调返回后才有 choices_presented 到达——入口选路层随即弹出)
func _on_adventure_level_started(level_index: int, display_name: String) -> void:
runner = adventure.runner()
log_view.append_bb(Narrator.level_open_line(display_name, int(adventure.master_seed),
int(db.hero.atk), int(db.hero.max_hp),
tune_panel.atk_pct() > 0 or tune_panel.hp_pct() > 0))
func _reset_run_scope() -> void:
# 每 run 一次的提示去重与轮内计数(弃图刷新遥测轮内序号)
_hints_shown = {}
_refresh_used_round = 0
_boss_active = false
func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
# 冒险模式:runner 逐关更替——每次节点事件重取(单关模式幂等);须先于 board_changed 回退读面
if _adventure_mode and adventure != null:
runner = adventure.runner()
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
settlement = runner.current_battle() # 呈现层统一读 settlement(与调试模式共用接线)
logger.attach_battle(settlement)
_boss_active = t == "boss"
_show_board_fx_for_node(t)
log_view.append_bb(Narrator.node_banner(runner, node, t))
elif t == "reward":
# 奖励节点三选一(迭代 10 追加批——与通关三选一共享同池同权重同上限;BonusPickOverlay 共用面,
# 道具件满位替换子交互同构)。原「魔法道具投放渠道」三选一(迭代 7 过渡形态)随批退役。
_bonus_context = "reward_node"
var node_draw: Dictionary = adventure.node_bonus_candidates()
var node_cands: Array = node_draw.get("candidates", []) as Array
_open_modal(bonus_overlay)
bonus_overlay.open_pick(Narrator.node_bonus_header(), _bonus_card_texts(node_cands, node_draw.get("aux_detail", []) as Array))
adventure.present_node_bonus(node_cands, node_draw.get("aux_detail", []) as Array)
log_view.append_bb(Narrator.reward_line())
elif t == "rest":
var healed: int = int(info.get("healed", 0))
_open_modal(node_overlay)
node_overlay.show_rest(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0)))
log_view.append_bb(Narrator.rest_line(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0))))
elif t == "shop":
# 商店房兑付(GP+商店房实施批):到达即开店——商品快照/余额经 runner 只读投影喂入;
# 购买/离开归玩家意图(buy_requested/leave_pressed 接线),零自动推进
_open_modal(shop_overlay)
shop_overlay.open_shop(runner.shop_products(), int(runner.gp))
log_view.append_bb(Narrator.shop_line(int(runner.gp)))
logger.log_node(node, info, runner.node_index)
_refresh_presentations()
# ---- 冒险模式终局面(三选一选择面 / 冒险结算屏——迭代 10 奖励重构批)----
# 三选一停点(非末关通关恰一次):Boss 胜利先经战斗总结结算面(settlement-summary v1.1——钥石
# 增量行在其上),确认后进三选一面(level-clear.md v2);续玩停点恢复同入口(零自动推进契约)
func _on_bonus_presented(payload: Dictionary) -> void:
if int(payload.get("aux_cleared", 0)) > 0:
board_zone.toast.show_toast("上关剩余 %d 件魔法道具已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 迭代 7 T4 清空反馈
_pending_bonus = payload.duplicate(true)
_bonus_context = "level_clear"
if bool(payload.get("replace_pending", false)):
# 续玩:替换子交互在途中断——重建三选一面后直落替换子态(候选不重抽)
_show_bonus_pick()
return
_end_overlay.open_victory(Narrator.battle_end_text("victory", false), "继续",
board_view.get_global_rect(),
"%s\n%s" % [_gp_summary_text({"gp_dropped": _last_gain_amount}), Narrator.bonus_keystone_line(payload)])
_open_modal(_end_overlay)
_refresh_presentations()
# 三选一面开出(战斗总结结算确认 / 续玩停点恢复;替换在途由 aux_replace_presented 续发直落子态)
func _show_bonus_pick() -> void:
var payload: Dictionary = _pending_bonus
_open_modal(bonus_overlay)
bonus_overlay.open_pick(Narrator.bonus_pick_header(payload), _bonus_card_texts(payload.get("candidates", []) as Array, payload.get("aux_detail", []) as Array))
_refresh_presentations()
var _last_replace_items: Array = [] # 替换子态续玩重建缓存(aux_replace_presented 载荷)
var _last_replace_incoming := {} # 同上——incoming 记录
func _bonus_card_texts(candidates: Array, aux_detail: Array = []) -> Array:
var out: Array = []
var aux_i := 0
for id in candidates:
var cid := String(id)
var text := ""
if cid == "aux_item":
var iid := String(aux_detail[aux_i]) if aux_i < aux_detail.size() else ""
aux_i += 1
text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid)), _aux_card_line(iid))
else:
text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid)))
out.append({"id": cid, "text": text})
return out
# 道具件卡片行(掷定前置——具体道具名 + 效果摘要;空 iid = 无预掷回退态显示通用文案)
func _aux_card_line(iid: String) -> String:
if iid == "":
return ""
var row: Dictionary = db.aux_item_row(iid)
if row.is_empty():
return ""
var parts: Array[String] = []
for k in (row.get("effect_kinds", []) as Array):
if String(k) == "mine_reveal":
parts.append("真视")
elif String(k) == "reward_preview":
parts.append("预读")
elif String(k) == "ap_grant":
parts.append("AP +%d" % int(row.get("magnitude", 0)))
var tgt := String(row.get("target", "none"))
if tgt == "area_3x3":
parts.append("3×3")
elif tgt == "board_wide":
parts.append("全图")
return "%s · %s" % [String(row.get("display_name", iid)), " ".join(parts)]
func _on_bonus_pick_confirmed(item_id: String) -> void:
if adventure == null:
return
var ok: bool = adventure.apply_node_bonus(item_id) if _bonus_context == "reward_node" else adventure.pick_bonus(item_id)
if not ok:
push_error("[组合根] 三选一兑付被拒——见输出日志")
func _on_aux_replace_presented(items: Array, incoming: Dictionary) -> void:
_last_replace_items = items.duplicate(true)
_last_replace_incoming = incoming.duplicate(true)
if not bonus_overlay.visible:
_open_modal(bonus_overlay) # 续玩替换在途直落——三选一面未开时先入栈(模态栈纪律)
_open_bonus_replace(items, incoming)
func _open_bonus_replace(items: Array, incoming: Dictionary) -> void:
var named: Array = []
for it in items:
var rec: Dictionary = it.duplicate()
rec["display_name"] = String(_item_name_lookup().get(String(rec.get("item_id", "")), String(rec.get("item_id", ""))))
named.append(rec)
bonus_overlay.open_replace(named, String(_item_name_lookup().get(String(incoming.get("item_id", "")), String(incoming.get("item_id", "")))),
named.size(), int(db.adventure.transitional_sync_slots))
func _on_bonus_replace_chosen(discard: int) -> void:
if adventure == null or not adventure.resolve_bonus_replace(discard):
push_error("[组合根] 同调位替换受理被拒——见输出日志")
# 兑付反馈(bonus_resolved 到达——结果行上屏,唯一出口 = 继续)
func _on_bonus_resolved(result: Dictionary) -> void:
_pending_bonus = {}
bonus_overlay.show_result(Narrator.bonus_result_text(result))
_refresh_presentations()
# 继续钮:收三选一面 → 上下文分派(通关停点=推进下一关 / 奖励节点=兑付确认回图选路——零自动推进)
func _on_bonus_continue() -> void:
_close_modal(bonus_overlay)
bonus_overlay.close_pick()
if _bonus_context == "reward_node":
if runner != null:
runner.continue_after_node()
return
if _adventure_mode and adventure != null:
if not adventure.continue_to_next_level():
_fatal_screen("关卡推进失败——见输出日志")
# 冒险结算(completed/failed/abandoned):复用 BattleEndOverlay——摘要五件事 + 钥石入账明细进文案
func _on_adventure_finished(summary: Dictionary) -> void:
_level_active = false
var result: String = String(summary.get("result", ""))
settlement = null
_exit_level_screen() # 终局兜底:独立屏在开态(放弃入口)也先回战斗屏呈现摘要——屏级互斥单一出口
_sync_settlement_visuals() # → 全关
HudPresenter.battle_end_feedback(stage, ap_bar, false) # 灰化/idle 回正/状态全清——HUD 单通道(T3 收口批)
# 存档入账(元进度实现批):settle = Boss 两档入账 + 工坊解锁 + 摘要入档 + 写盘(唯一写档点不变——
# 调试两层不经过本处理器,天然不写档不污染生涯数据)。
var detail: Dictionary[String, Variant] = {}
if meta != null:
detail.assign(meta.settle_adventure(summary))
var text := Narrator.adventure_summary_text(summary)
if not detail.is_empty():
text += "\n" + Narrator.keystone_settle_text(detail, _item_name_lookup())
if result == "completed":
_end_overlay.open_victory(text, "返回冒险", board_view.get_global_rect(),
"本局 GP 入账 · 合计 %d" % _adventure_gp_final) # 迭代 6 T7 卡⑩——GP 行只上冒险终局面板(C5)
else:
_end_overlay.open_defeat(text, "返回", board_view.get_global_rect())
logger.log_level_end(summary) # 试玩时间线:冒险终局(摘要随载荷)
if runner != null:
runner.restore_hero_base()
_open_modal(_end_overlay)
_refresh_presentations()
# 工坊 item_id → 显示名投影(keystone_settle_text 文案数据源——装配归本根,拼句归 Narrator)
func _item_name_lookup() -> Dictionary:
var lookup := {}
if meta != null:
for row in db.meta_workshop_items():
lookup[String(row.get("item_id", ""))] = String(row.get("display_name", ""))
return lookup
func _on_battle_end_continue() -> void:
# 终局出口:返回开始界面(冒险摘要留档试玩日志;钥石已在收束时入存档——存档序列化批)
_close_modal(_end_overlay)
_end_overlay.close()
# 迭代 10——三选一停点挂起分支:战斗总结结算(Boss 胜利钥石增量行)确认后进三选一面
if not _pending_bonus.is_empty():
_show_bonus_pick()
return
# 卡⑥ 挂起分派:战斗胜利总结结算框的「下一节点」≠ 终局——进选路页(暂存候选恢复呈现)
if _settlement_stop_pending:
_settlement_stop_pending = false
_enter_level_screen()
level_screen.open_choices(_pending_candidates, runner)
_pending_candidates = []
_refresh_presentations()
return
_debug.reset()
if runner != null:
runner.restore_hero_base()
runner = null
settlement = null
adventure = null
_adventure_mode = false
tune_panel.reset_sliders()
_start_overlay.visible = true
_refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新(收束后已清=隐藏)
_refresh_presentations()
func _on_end_review() -> void:
review_overlay.open(log_view.parsed_text())
_open_modal(review_overlay) # 迭代 6 T7——走模态栈:终局层在册上方的可见遮挡被显式隐藏,
# 修复「回看层被终局层全屏 STOP 遮罩压死」(直开绕栈缺陷)
# ==================== 调试模式(连续两层——编排归 debug_session.gd;
# 单场遭遇用户入口已删(2026-09-13 用户裁决)——debug_session.start_single 原语仅探针使用)====================
func _start_debug_levels() -> void:
tune_panel.reset_sliders()
_start_overlay.visible = false
log_view.clear()
log_view.append_bb(Narrator.debug_open_line(_seed_value(), 1, true))
_reset_run_scope()
if not _debug.start_debug_levels():
_fatal_screen("战斗启动失败——见输出日志")
func _on_debug_battle_created(settlement_ref, _level: int, _carry: int) -> void:
# begin_battle 前接手 + 接线(ADR-0001:signal.connect 唯一归组合根)。settlement 必须先于 begin_battle
# 就位——begin 期间发出的 board_changed 要用新板重建棋盘并触发一次性提示(缺陷①同款时序义务)
settlement = settlement_ref
_wire_battle(settlement_ref)
func _on_debug_battle_ready(settlement_ref, _level: int, _carry: int) -> void:
settlement = settlement_ref
board_view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
_check_board_hints()
_refresh_presentations()
func _on_inter_next_pressed() -> void:
_inter_overlay.visible = false
if _adventure_mode:
# 冒险模式:大奖励屏确认 → 进下一关(AdventureRunner 序列推进——零自动推进的冒险级停点)
if adventure != null and not adventure.continue_to_next_level():
_fatal_screen("关卡推进失败——见输出日志")
return
if not _debug.next_layer():
_fatal_screen("战斗启动失败——见输出日志")
# E4:加成生效值供给(debug_session 注入 Callable——每次从面板百分比现算,经 encounter 传参零表写;
# 原表写路径 _apply_hero_override/_scaled_override 已废除,双写叠加 6→18→54 整类隐患随之移除)
func _debug_hero_override() -> Dictionary:
return {"atk": _scaled(int(db.hero.atk), tune_panel.atk_pct()),
"max_hp": _scaled(int(db.hero.max_hp), tune_panel.hp_pct())}
func _scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音;
# 相位取数经 presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透,T3 收口批)
func _identity_lookup(c: Vector2i):
if settlement == null or settlement.minesweeper.current_board() == null:
return null
if String(settlement.presentation_snapshot().get("phase", "")) != ActionEconomy.PHASE_ACTIVE:
return null
return settlement.query_reward_identity(c)
# 地形变异 数字遮蔽解析(信息型 hide_number——格带该效果变异即遮蔽;读取方按格子变异数据行动。
# 变异表直读本根 db——settlement.db 即组合根注入的同一 ConfigDB 实例(AdventureRunner/DebugSession
# 构造传参),免 settlement 内部链式穿透,T3 收口批)
func _variant_mask_lookup(c: Vector2i) -> bool:
if settlement == null:
return false
var b = settlement.minesweeper.current_board()
if b == null:
return false
for vid in b.variant_ids(c):
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
# 地形变异 角标解析(迭代 6 T6——伤害型变异未开格视觉标记;读取方按格子变异数据行动,规则本体零特判。
# 策略纹理非数值墙:标记=玩家绕行/承担的决策输入,难度本体仍住数值包络。变异表直读本根 db——同上款收口)
func _variant_mark_lookup(c: Vector2i) -> String:
if settlement == null:
return ""
var b = settlement.minesweeper.current_board()
if b == null:
return ""
for vid in b.variant_ids(c):
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "flat_damage":
return "flat_damage"
return ""
func _show_board_fx_for_node(node_type: String = "") -> void:
# Boss 遭遇变体:紫赤影子爬棋盘一次性占位演出(§2.6)
if node_type == "boss":
board_zone.fx.play_boss_shadow()
func _on_battle_finished(payload: Dictionary) -> void:
# 仅调试模式接线(冒险的关卡结束归 AdventureRunner._on_level_finished——大奖励/冒险终局分派)
var result: String = String(payload.get("result", ""))
_sync_settlement_visuals() # → 全关(battle_over 反馈面)
HudPresenter.battle_end_feedback(stage, ap_bar, true) # 灰化/idle 回正/RAP 清零/状态全清——HUD 单通道(T3 收口批)
var decision: Dictionary = _debug.on_battle_finished(payload)
match String(decision.outcome):
"next_layer":
_inter_label.text = "第 %d 层通过!\nHP %d/%d 带入下一层" % [
int(decision.layer), int(decision.carry_hp), int(db.hero.max_hp)]
_inter_overlay.visible = true
_refresh_presentations()
return
"level_end":
_end_overlay.open_victory(Narrator.battle_end_text("victory", true),
"返回", board_view.get_global_rect(), _gp_summary_text(payload))
"standalone_end":
if result == "victory":
_end_overlay.open_victory(Narrator.battle_end_text("victory", false),
"回到遭遇选择", board_view.get_global_rect(), _gp_summary_text(payload))
else:
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false),
"返回", board_view.get_global_rect())
_open_modal(_end_overlay)
_refresh_presentations()
# 战利品行装配(battle.md OQ14——胜利总结结算展示 GP 总量+增量;账本语义零改动,纯呈现读数):
# 增量 = battle_finished 载荷 gp_dropped(战斗域上抛);总量 = runner.gp 现值(LevelRunner 入账
# 连接先于组合根 handler——此读数即战后入账完毕的总量)。
func _gp_summary_text(payload: Dictionary) -> String:
return "战利品 GP +%d · 合计 %d" % [int(payload.get("gp_dropped", 0)), int(runner.gp)]
# ==================== core↔view 接线(战斗信号统一走这里)====================
func _wire_battle(settlement_ref) -> void:
settlement_ref.step_row.connect(_on_step_row)
settlement_ref.battle_finished.connect(_on_battle_finished)
settlement_ref.round_started.connect(_on_round_started)
settlement_ref.board_changed.connect(_on_board_changed)
# ==================== 结算行排播(泵本体归 row_pacer.gd——本节仅入口与探针/测试委托面)====================
func _on_step_row(row: Dictionary) -> void:
# 卡⑦ 排播入口:减动效=延迟归零同帧直落(行呈现不跳过);常态入队按段阶梯播放
if UiKit.motion_scale < 1.0:
_row_pacer.consume_now(row)
return
_row_pacer.enqueue(row)
# 段间延迟取数面(减动效双态断言用——pacing_queue_test 直读;委托 row_pacer 同名面)
func pace_delay_for(prev_seg: String, next_seg: String) -> float:
return _row_pacer.pace_delay_for(prev_seg, next_seg)
# 强制排空(模态开启前调用——明细回看/停点覆盖层/终局面所见明细带恒完整;pacing_queue_test 直读)
func _flush_row_queue() -> void:
_row_pacer.flush()
func _on_round_started(ri: int) -> void:
# 卡⑦:轮首分隔行随排播队列走(落在前轮各行之后——带序正确);轮横幅即轮切换时刻发射
#(view 内判减动效=静态化变体)。减动效快路=分隔行同帧直落(不跳过——consume_now 复用
# _consume_row 轮首分隔行分支,与原直写逐字等价)。
if UiKit.motion_scale < 1.0:
_row_pacer.consume_now({"__round_line": ri})
else:
_row_pacer.enqueue({"__round_line": ri})
settlement_fx.play_round_banner(ri)
_refresh_used_round = 0
_refresh_presentations()
func _on_board_changed() -> void:
# 关卡模式下 board_changed 可能先于主场景接手 settlement 到达(begin_battle 期间发)——先从 runner 同步
if settlement == null and _level_active and runner != null:
settlement = runner.current_battle()
if settlement == null:
return
board_view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
_check_board_hints()
_refresh_presentations()
# ---- 一次性非模态提示(每 run 一次——pattern 10)与诚实提示 ----
func _check_board_hints() -> void:
var board = settlement.minesweeper.current_board()
if board == null:
return
# 零合法板(棋盘生成 EC5 降级)诚实提示——非静默(toast,非模态)
if board.illegal_product:
board_zone.toast.show_toast("本板为零合法板降级产物——出图器已尽力,数据面诚实告知", "warn")
# 接受最优降级(有合法板但无一达标)——降级标记独立于非法产物标记,同走诚实告知
elif board.degraded_product:
board_zone.toast.show_toast("本板为接受最优降级产物(未达可推理率及格线)——数据面诚实告知", "warn")
func _on_hover_forbidden(_cell: Vector2i) -> void:
if not _hints_shown.has("no_adjacent"):
_show_hint("需先走近(点击已开格移动,免费)", "no_adjacent")
func _on_chord_attempted(_cell: Vector2i) -> void:
if not _hints_shown.has("chord"):
_show_hint("本作无双击快开——插旗后轮末自动判定标中;点数字格可移动小人", "chord")
func _show_hint(text: String, hint_id: String) -> void:
_hints_shown[hint_id] = true
logger.log_ux("ux_hint_shown", {"hint_id": hint_id})
board_zone.toast.show_toast(text, "info")
# ---- 节点覆盖层"继续"(奖励/恢复点兑付确认)与节点图选路覆盖层 ----
func _on_node_continue() -> void:
_close_modal(node_overlay)
if runner != null:
# 泛化语义(接口漂移清偿):确认 ≠ 推进——兑付确认后经 choices_presented 弹出选路口
runner.continue_after_node()
func _on_choices_presented(candidates: Array) -> void:
# 零自动推进的停点呈现:整幅节点图 + 图上点击选路(候选按钮退役 2026-09-28——战斗胜利/兑付确认统一到达此处)
if OS.get_environment("T17_LOCK_DEBUG") != "":
print("[choices_dbg] handler entered cands=%d runner_null=%s node=%s" % [
candidates.size(), str(runner == null), str(runner.node_index) if runner != null else "-"])
if runner == null:
return
log_view.append_bb(Narrator.route_line(candidates, runner))
# 卡⑥ 战斗胜利总结结算(battle.md OQ14 拍板面接线——欠账清偿):战斗胜利停点先弹 BattleEndOverlay
# 胜利形态(金蜡封+GP 增量+总量+判定行+「下一节点」+明细回看),确认后进选路页。判据 = 战斗节点
# 的选路停点且战斗已收场(battle 收尾分支 _settlement 置 null 先于本信号;入口/兑付/商店停点
# 的当前节点非 battle 型——三态天然分流)。Boss 胜/失败走 level_finished 另路不经此处。
if _adventure_mode and runner.current_battle() == null \
and String(runner.current_node().get("type", "")) == "battle" and not candidates.is_empty():
if OS.get_environment("T17_LOCK_DEBUG") != "":
print("[choices_dbg] -> battle stop overlay (node %s)" % str(runner.node_index))
_settlement_stop_pending = true
_pending_candidates = candidates.duplicate()
_end_overlay.open_victory(Narrator.battle_end_text("victory", false), "下一节点",
board_view.get_global_rect(),
"战利品 GP +%d · 合计 %d" % [_last_gain_amount, int(runner.gp)])
_open_modal(_end_overlay)
_refresh_presentations()
return
if OS.get_environment("T17_LOCK_DEBUG") != "":
print("[choices_dbg] -> level_screen (node %s type %s cands %d)" % [
str(runner.node_index), String(runner.current_node().get("type", "")), candidates.size()])
_enter_level_screen()
level_screen.open_choices(candidates, runner)
_refresh_presentations()
func _on_route_chosen(node_id: int) -> void:
# 形态升格分支(2026-09-28):视图已按节点类型定去留——战斗/Boss 出屏(visible=false,此处收栈
# 记录一致性+回战斗屏);奖励/恢复/商店本屏保持激活(兑付覆盖层由节点事件域内弹出,模态栈
# 打开低层时自动隐藏本屏、关闭自动恢复)
if not level_screen.visible:
_close_modal(level_screen)
_exit_level_screen()
if runner != null and not runner.pick_next_node(node_id):
push_error("[组合根] 选路被拒(编号 %d 不在候选)" % node_id)
# ---- 独立屏屏级互斥(形态升格批 2026-09-28——单一出口:进屏隐战斗屏 / 出屏显战斗屏)----
# 兑付覆盖层(节点/商店)在本屏激活域内弹出期间保持隐藏态不变——模态栈打开低层时隐藏本屏、
# 关闭自动恢复本屏(遮挡语义复用),战斗屏不在此期间回显。
func _enter_level_screen() -> void:
_level_screen_active = true
_battle_root.visible = false
_open_modal(level_screen)
func _exit_level_screen() -> void:
_level_screen_active = false
_battle_root.visible = true
if level_screen.visible:
_close_modal(level_screen)
func _on_abandon_requested() -> void:
# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——推翻 冒险结构.md「MVP 无界面入口」后置条款;
# 入口挂选路停点 = 天然非战斗期,拒绝分支仅防御在案):受理 → 冒险放弃结算(同失败型,
# 已获大奖励不追回)→ adventure_finished 同步到达 → 终局面收层
if not _adventure_mode or adventure == null:
return
if not adventure.abandon():
logger.log_ux("ux_reject", {"source": "abandon", "sub_reason": "battle_in_flight"})
board_zone.toast.show_toast("战斗进行中——无法放弃", "warn")
# ---- 营地覆盖层与钥石账本(元进度实现批——写调用唯一归组合根)----
# 打开营地(主菜单入口——开合与开始层互斥仿 TunePanel 先例:开始覆盖层非模态且树序在后,
# 规范 2.3 遮挡纪律;打开时先刷工坊解锁——达标未解锁就地入账)
func _open_camp() -> void:
if meta == null:
return
meta.refresh_workshop_unlocks()
_start_overlay.visible = false
_open_modal(camp_overlay) # 模态栈单口(T3 收口批——登记层开合一律走栈)
camp_overlay.open_camp(_camp_data())
_refresh_presentations()
func _on_camp_closed() -> void:
_close_modal(camp_overlay) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新
_refresh_presentations() # 关闭后刷新顶栏(工坊解锁可能变了余额侧读数面)
# ---- 最小设置面(迭代 8 卡④——入口/开合仿营地先例:开始层互斥;生效与持久化归 SettingsStore)----
func _open_settings() -> void:
_start_overlay.visible = false
_open_modal(settings_overlay) # 模态栈单口(T3 收口批)
func _on_settings_closed() -> void:
_close_modal(settings_overlay) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button()
# 营地快照投影(纯读——锻造三行/工坊六行/余额/最近冒险;拼句归 CampOverlay,账面归 MetaProgression)
func _camp_data() -> Dictionary:
var forge_rows: Array[Dictionary] = []
if meta != null:
for attr in MetaTable.FORGE_ATTRS:
var row: Dictionary = db.meta_forge_row(attr)
forge_rows.append({
"attr": attr,
"display_name": String(row.get("display_name", "")),
"level": meta.forge_level(attr),
"flat": int(row.get("flat_value", 0)),
"price": meta.forge_price(attr),
})
return {
"balance": meta.keystone_balance() if meta != null else 0,
"forge": forge_rows,
"workshop": meta.workshop_status() if meta != null else [],
"recent": save_manager.recent_summaries() if save_manager != null else [],
}
func _on_camp_buy(attr: String) -> void:
if meta == null:
return
if not meta.buy_forge(attr):
# 余额不足(按钮禁用为常态防线——兜底拒绝可见,pattern 1)
board_zone.toast.show_toast("钥石不足——买不起这件强化", "warn")
return
camp_overlay.refresh_camp(_camp_data())
_refresh_presentations()
# ---- 商店覆盖层与 GP 钱包(GP+商店房实施批——写调用唯一归组合根)----
func _on_shop_buy(product_id: String) -> void:
if runner == null:
return
if not runner.buy_product(product_id):
# 余额不足(按钮禁用为常态防线——兜底拒绝可见,pattern 1)
logger.log_ux("ux_reject", {"source": "wallet", "sub_reason": "insufficient_gp"})
board_zone.toast.show_toast("金币不足——买不起这件商品", "warn")
return
shop_overlay.refresh_state(runner.shop_products(), int(runner.gp))
_refresh_presentations()
func _on_shop_leave() -> void:
_close_modal(shop_overlay)
if runner != null:
# 复用兑付确认纪律(接口漂移清偿):离开 ≠ 推进——确认后经 choices_presented 弹出选路口
runner.continue_after_node()
func _on_wallet_changed(event: String, gp: int, extra: Dictionary) -> void:
# wallet 行族转发(到账/购买/清零——试玩日志验收取数面)+ 常驻余额与在开商店覆盖层重刷
logger.log_wallet(event, gp, extra)
_gp_ledger.apply_event(event, gp) # GP 影子账(迭代 6 T7——收束清零前取值,纯呈现零核心触碰=C1;账本归 gp_shadow_ledger.gd)
if String(event) == "gain":
_last_gain_amount = int(extra.get("amount", 0)) # 卡⑥ GP 增量取数(每战恰一笔 gain)
if shop_overlay.visible and runner != null:
shop_overlay.refresh_state(runner.shop_products(), int(runner.gp))
_refresh_presentations()
# ==================== 玩家意图 → 核心写调用(唯一出口)====================
func _on_board_action(cell: Vector2i, kind: String) -> void:
# 玩家动作 → 核心写调用的唯一通道(模态/无战斗拒绝在此收口)
if _input_locked():
return
# 结算演播期棋盘冻结(T17 用户明确令 v3——不可点/不可移/不可挖):
# 锁面=排播队列活跃(纯队列判据;排播泵尾迹窗口不可作演播中标志——见 row_pacer._process 注)。
# 队列排空即解锁、无状态残留;HUD 按钮不在锁面(停手/明细回看各自通路,开启前
# _flush_row_queue 自行排空)。v1 撞收尾链的教训对策:ui_click_probe 的排播期 stall
# 豁免随批(合法停顿态),Waiting 收尾等待期**不豁免**——probe 报卡即为真回归信号。
if not _row_queue.is_empty():
return
# 魔法道具选中态劫持(迭代 7 T4——两步交互第二步):点格=使用目标拾取,不落挖格;
# 只劫持挖掘点击(move/flag 语义不冲突——选中期玩家仍可移动/插旗)
var aux_sel: int = _aux.active_selection()
if aux_sel != 0 and kind == "dig":
_aux.attempt_use(aux_sel, cell)
return
var outcomes: Array = settlement.minesweeper.settle_batch([{"action": kind, "cell": cell}])
var accepted: bool = not outcomes.is_empty() and bool(outcomes[0].accepted)
logger.log_action(kind, cell, accepted)
if runner != null and runner.aux_inventory != null:
_aux.refresh_slots() # 迭代 7 T4——授予发生在 settle_batch 结算段内(同步走完,行尾重查)
_handle_outcome_feedback(outcomes[0] if not outcomes.is_empty() else {})
# settle_batch 内部同步走完 结算→战斗结束→关卡推进(事件同步发射)——关卡终局时 settlement 已被
# runner 置空,清图演出判定须先判 settlement(基线既有重入隐患,2026-09-13 加固)
if accepted and kind == "dig" and settlement != null and settlement.minesweeper.current_board() != null and settlement.minesweeper.clear_map_cleared():
board_zone.fx.play_clear_burst()
if accepted and kind == "dig" and settlement != null and settlement.minesweeper != null:
# 挖掘演出(棋盘小人贴图批 2026-09-25):挥铲三帧 + 角色弹跳——触发=格子点开那一刻
#(2026-09-25 用户裁决;settle_batch 同步受理即状态已变,动画纯表现与 refresh 同拍零竞态;
# 挖掘不移动英雄,出手方向 = 目标格−英雄格,圆心=英雄持握手位)
var hero_cell: Vector2i = settlement.minesweeper.hero_pos()
board_zone.fx.play_shovel_swing(board_view.hero_hand_point(), cell - hero_cell, board_view.hero_sprite_scale())
board_view.play_hero_dig_bounce()
elif accepted and kind == "move":
board_view.notify_hero_move() # 原地移动(目标=当前格幂等受理)帧闪回执——refresh diff 前置告知
board_view.refresh()
_refresh_presentations()
func _on_stop_pressed() -> void:
if _input_locked():
return
# stop 经批次提交(排雷玩法 D1×6①——不直调 行动经济)
var outcomes: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
logger.log_action("stop", Vector2i(-1, -1), not outcomes.is_empty() and bool(outcomes[0].accepted))
if not outcomes.is_empty() and String(outcomes[0].get("outcome", "")) == "voided":
# voided ≠ 拒绝——独立文案(pattern 1:防静默改判读感欺骗)
board_zone.toast.show_toast("本轮已清图,停手未执行 · 剩余 AP 已作废", "warn")
logger.log_ux("ux_reject", {"source": "voided", "sub_reason": "stop_voided"})
board_view.refresh()
_refresh_presentations()
func _on_refresh_confirmed() -> void:
# 弃图刷新(挖掘期 Active 当场换图继续挖——D4;RAP 按门槛扣费 · 旧板旗作废,每轮上限读表)
if _input_locked():
return
var ok: bool = false
if _level_active and runner != null:
ok = runner.player_request_refresh()
elif settlement != null:
ok = settlement.player_request_refresh()
logger.log_action("refresh", Vector2i(-1, -1), ok)
if ok:
_refresh_used_round += 1
logger.log_ux("ux_refresh_attempt", {"result": "accepted", "round_refresh_index": _refresh_used_round})
board_zone.toast.show_toast("RAP−%d · 旧板旗作废——新图已就位,AP 保留继续挖" % int(db.action_economy.rap_refresh_cost), "gold")
else:
# 资源面/上限拒绝(EC13 合法拒绝可见;行动经济 无载荷——客户端按上下文推断)
var rap: int = int(settlement.action_economy.rap) if settlement != null else 0
var cost: int = int(db.action_economy.rap_refresh_cost)
var result := "rejected_rap" if rap < cost else "rejected_cap"
logger.log_ux("ux_refresh_attempt", {"result": result, "round_refresh_index": _refresh_used_round + 1})
if result == "rejected_rap":
board_zone.toast.show_toast("RAP 不足 · 还差 %d 点(弃图刷新需 8)" % (cost - rap), "warn")
ap_bar.pulse_rap()
else:
board_zone.toast.show_toast("本轮刷新次数已用完(每轮 2 次)", "warn")
board_view.refresh()
_refresh_presentations()
func _handle_outcome_feedback(outcome: Dictionary) -> void:
# 双源拒绝反馈(pattern 1):每次拒绝玩家可感知 + 来源可区分 + 返回值命令式驱动(无拒绝事件)
if outcome.is_empty() or bool(outcome.get("accepted", false)):
return
logger.log_ux("ux_reject", {"source": String(outcome.get("reject_reason", "")),
"sub_reason": String(outcome.get("sub_reason", ""))})
board_zone.toast.show_toast(HudPresenter.outcome_text(outcome), "warn")
if String(outcome.get("reject_reason", "")) == "resource_face":
ap_bar.pulse_ap()
# ==================== 全屏 States 状态机(挖掘/结算/窗口/终局)====================
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_cap_per_round) - _refresh_used_round)
HudPresenter.refresh(stage, info_panel, ap_bar, settlement, runner, _level_active, db, refresh_remaining)
_sync_settlement_visuals()
func _sync_settlement_visuals() -> void:
# 结算阶段(round_end → 下一轮):D 区去饱和+金橙偏移锁输入(明度不变)+ 明细带展开 84px
var settling := _is_settling()
if settling and not _was_settling:
settlement_fx.play_round_sweep(board_view.get_global_rect()) # 结算轮光带(演出链批)——时序敏感演出触发,例外留在组合根
_was_settling = settling
HudPresenter.sync_settlement_visuals(stage, board_view, log_view, settling, _phase_text())
func _is_settling() -> bool:
# T17 修复:结算视觉期跟随排播演播到放完。核心层同步走完(轮末结算后逻辑秒回 Active),
# 旧判定让演播期棋盘提前恢复可挖观感=用户「没意识到在结算演出」感知缺口的根源;
# 演播期维持去饱和+禁挖光(结算中语义),放完自然回挖掘认知。判据=纯队列(排播泵尾迹
# 窗口不可作演播中标志——row_pacer._process 注)。battle 收尾演播例外:
# overlay 开启即 _flush_row_queue 排空,视觉随队列空同步交接结算框。
# 相位取数经 presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透(T3 收口批)。
if settlement == null or not settlement.is_battle_live():
return not _row_queue.is_empty()
if String(settlement.presentation_snapshot().get("phase", "")) == ActionEconomy.PHASE_WAITING:
return true
return not _row_queue.is_empty()
func _phase_text() -> String:
# 文案装配归 narrator(settlement 缺位 = 非战斗态——组合根模式状态守卫;相位取数经
# presentation_snapshot 成文面——禁 settlement.action_economy 链式穿透,T3 收口批)
if settlement == null:
return "第 — 轮 · —"
return Narrator.phase_text(int(settlement.round_index), String(settlement.battle_result),
String(settlement.presentation_snapshot().get("phase", "")) == ActionEconomy.PHASE_WAITING,
_boss_active, Narrator.boss_name(runner, db))
# ==================== B 区明细带 / 图例(模态入口)====================
func _open_review_from_band() -> void:
review_overlay.open(log_view.parsed_text())
_open_modal(review_overlay) # 迭代 6 T7 同款——净空态下栈上无可隐藏层,行为与旧直开逐位等价
func _on_legend_toggled() -> void:
if legend_overlay.is_open():
_close_modal(legend_overlay)
else:
_open_modal(legend_overlay)
# ==================== 诊断转储(卡死时点击——全量核心状态 + 环形缓冲)====================
func _on_diagnose_pressed() -> void:
var ctx := {"mode": "level" if _level_active else "debug", "seed": _seed_value()}
if _level_active and runner != null:
ctx["runner"] = {"node_index": runner.node_index, "level_result": String(runner.level_result),
"hero_hp": runner.hero_hp, "settlement_is_null": runner.current_battle() == null}
if settlement != null:
ctx["settlement"] = settlement.diagnostic_snapshot() # 诊断观测面(私有字段零直读——code-review 2026-09-12)
var path: String = logger.dump_diagnosis(ctx)
board_zone.toast.show_toast("已转储诊断: " + path, "info")
# ==================== 数值调整板(加成应用/批量模拟编排——组合根承接,视图纯 UI)====================
func _show_tune_panel() -> void:
_start_overlay.visible = false
_open_modal(tune_panel) # 模态栈单口(T3 收口批)
func _close_tune_panel() -> void:
_close_modal(tune_panel) # 模态栈单口(T3 收口批)
_start_overlay.visible = true
_refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新
func _on_tune_apply(atk_pct: int, hp_pct: int) -> void:
# E4:加成无持久态可"落"——各战斗开局经 _debug_hero_override/runner 现算生效(面板仅关闭回开始层)
_close_tune_panel()
func _on_tune_simulate(n: int) -> void:
# 批量模拟编排 = 控制器承接(tune_sim_controller.gd);E4 后无表态需复位——模拟走 runner 覆盖参数
_tune_sim.run(n)
# ==================== 工具 ====================
func _seed_value() -> int:
if _seed_edit.text.is_valid_int():
return int(_seed_edit.text)
return 42