Files
gplan-2/game/presentation/main.gd
T
DKChan ae2ca4ffd4 feat(ux): T2 独立屏落地——RouteOverlay 升格 LevelMapScreen+组合根屏级互斥
类/文件改名 route_overlay.gd→level_map_screen.gd(RouteOverlay 退役,uid 随迁);屏底=暗幕 0.6 半透→不透明 BG_ROOT+石纹底噪(TEX_BACKDROP_STONE load_tex 守卫缺位纯色回退,spec Layout 形态升格);open_choices 进入转场 alpha 淡入 200ms(UiKit.motion;就地刷新/模态栈恢复不重放)。
组合根:_battle_root 宿主引用留存+_level_screen_active 状态+_enter/_exit_level_screen 单一出口(进屏隐战斗屏/出屏显战斗屏);_on_choices_presented 走进屏;_on_route_chosen 出屏分支;_on_adventure_finished 强制回战斗屏呈现摘要(放弃入口终局兜底)。_input_locked/模态登记序(节点层与商店层之间)不变——兑付覆盖层域内弹出的遮挡恢复语义由模态栈零改动复用。
探针机械改名 main.route_overlay→main.level_screen(ui_probe 5/ui_click_probe 4/ui_input 5/ui_tune 2 处)。坑⑤再证:新 class_name 后未刷编辑器缓存致四探针 Parse Error 超时——--editor --quit 后全绿。轻闸=冒烟 557/0+五探针 PASS。
2026-09-28 06:40:16 +08:00

872 lines
42 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——本类不拼呈现字符串。
# ADR-0001 纪律:呈现层永不直接订阅 排雷玩法/行动经济——只订 战斗结算/runner 转发信号;相位读取仅用于驱动视图状态。
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 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 _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 之上、模态之下)
# 模态 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 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()
# 元进度账本(元进度实现批):存档就绪后建——余额/定价/购买/入账/注入全经此单点
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()
_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
log_view.review_requested.connect(_open_review_from_band)
board_zone.action_requested.connect(_on_board_action)
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)
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)
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, level_screen, _end_overlay, shop_overlay, camp_overlay, tune_panel]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
var start: Dictionary = UiAssembler.build_start_overlay(self, db)
_start_overlay = start.overlay
_seed_edit = start.seed_edit
start.lvbtn.pressed.connect(_start_adventure_from_menu)
start.tunebtn.pressed.connect(_show_tune_panel)
start.dbgbtn.pressed.connect(_start_debug_levels)
start.campbtn.pressed.connect(_open_camp)
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)
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:
# 打开时显式隐藏树序在其上方的可见层(遮挡物隐藏),关闭恢复——单一模态栈
_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:
# 模态/非战斗期棋盘输入锁(各 overlay 自身可见即锁定)
return legend_overlay.is_open() or review_overlay.is_open() or _end_overlay.is_open() \
or node_overlay.visible or level_screen.visible or shop_overlay.visible or tune_panel.visible \
or _start_overlay.visible or _inter_overlay.visible or settlement == null
# ==================== 冒险模式入口(唯一试玩入口——单关测试直入口退役 2026-09-23 用户拍板)====================
func _start_adventure_from_menu() -> void:
_start_overlay.visible = false
_start_adventure()
# 冒险模式(三关连探——AdventureRunner 编排;转发信号签名与 runner 一致,接线照原样对接;
# 信号连接只在组合根——ADR-0001)
func _start_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)
adventure.big_reward_paid.connect(_on_big_reward)
adventure.adventure_finished.connect(_on_adventure_finished)
adventure.level_started.connect(_on_adventure_level_started)
_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 显式传参(全零 = 白板)
if not adventure.start_adventure(seed_val, {"hero_override": meta.hero_override() if meta != null else {}}):
_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":
_open_modal(node_overlay)
node_overlay.show_reward()
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()
# ---- 冒险模式终局面(大奖励屏 / 冒险结算屏——冒险结构实现批)----
# 大奖励兑付(非末关通关恰一次):复用 _inter_overlay(label + 确认按钮,零新增交互范式);
# 文案装配归 Narrator
func _on_big_reward(reward: Dictionary) -> void:
_inter_label.text = Narrator.big_reward_text(reward)
_inter_overlay.visible = true
_refresh_presentations()
# 冒险结算(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() # → 全关
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体 2026-09-26 迁 B 区站桩)
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
# 存档入账(元进度实现批):settle = Boss 两档入账 + 工坊解锁 + 摘要入档 + 写盘(唯一写档点不变——
# 调试两层不经过本处理器,天然不写档不污染生涯数据)。
var detail: Dictionary = {}
if meta != null:
detail = 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())
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()
_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_presentations()
func _on_end_review() -> void:
review_overlay.open(log_view.parsed_text())
# ==================== 调试模式(连续两层——编排归 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 域外零读、零诊断噪音)
func _identity_lookup(c: Vector2i):
if settlement == null or settlement.minesweeper.current_board() == null or settlement.action_economy.phase != settlement.action_economy.PHASE_ACTIVE:
return null
return settlement.query_reward_identity(c)
# 地形变异 数字遮蔽解析(信息型 hide_number——格带该效果变异即遮蔽;读取方按格子变异数据行动)
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 = settlement.db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
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 反馈面)
stage.gray_all_enemies() # 死亡兜底:站桩灰化残位(禁瞬消语义的战斗收尾面)
ap_bar.set_rap(0) # RAP 清零脉冲 + 微文案(载体 2026-09-26 迁 D 区 AP 条)
stage.hero_standee.set_effects_cleared_feedback() # 状态全清可见反馈(增益状态×4④——载体迁 B 区站桩)
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)
func _on_step_row(row: Dictionary) -> void:
log_view.append_row(row)
stage.on_step_row_fx(row)
_play_cross_zone_fx(row) # 跨区因果演出(结算演出链批):开格之因 → 站桩之果
# 高光演出(战斗结算×3①② 占位):完美排雷横幅 / 标中旗加重弹道
if String(row.get("type", "")) == "highlight":
board_zone.fx.play_perfect_sweep()
elif String(row.get("highlight", "")) == "flag_hit":
board_zone.fx.play_flag_hit_projectile(true)
_refresh_presentations()
# 跨区因果演出(§Transitions 结算逐行兑现——动画密度终稿归战斗表现样张批):
# 伤害行 → ⚔ 弹道自棋盘飞向目标敌方站桩(crit 金档加重);治疗行 → ✚ 弹道飞向主角站桩;
# 命中反馈(闪白/血条递减/灰化)由站桩侧 on_step_row_fx/set_data 承接
func _play_cross_zone_fx(row: Dictionary) -> void:
var from_v: Vector2 = board_view.get_global_rect().get_center()
match String(row.get("type", "")):
"damage":
var target := String(row.get("target", ""))
if target == "" or target == "hero":
return
var to_v = stage.enemy_standee_center(target)
if to_v != null:
var crit := bool(row.get("crit", false))
settlement_fx.play_projectile(from_v, to_v, "⚔",
UiKit.GOLD if crit else UiKit.RED_MINE, crit)
"heal":
if String(row.get("target", "")) != "hero" and String(row.get("target", "")) != "":
return
settlement_fx.play_projectile(from_v, stage.hero_standee_center(), "✚", Color("7cc47c"))
func _on_round_started(ri: int) -> void:
log_view.finish_settlement_round() # 上轮摘要行(挖掘期 1 行——明细带 pattern 3)
log_view.append_bb("[color=#e0b860]── 第 %d 轮 ──[/color]" % 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 runner == null:
return
log_view.append_bb(Narrator.route_line(candidates, runner))
_enter_level_screen()
level_screen.open_choices(candidates, runner)
_refresh_presentations()
func _on_route_chosen(node_id: int) -> void:
_close_modal(level_screen)
if not level_screen.visible:
_exit_level_screen() # 战斗/Boss 节点:切回战斗屏;奖励/恢复/商店:本屏保持激活(T3 接管域内弹出)
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
camp_overlay.open_camp(_camp_data())
_refresh_presentations()
func _on_camp_closed() -> void:
camp_overlay.visible = false
_start_overlay.visible = true
_refresh_presentations() # 关闭后刷新顶栏(工坊解锁可能变了余额侧读数面)
# 营地快照投影(纯读——锻造三行/工坊六行/余额/最近冒险;拼句归 CampOverlay,账面归 MetaProgression)
func _camp_data() -> Dictionary:
var forge_rows: Array = []
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)
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
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)
_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
board_view.set_desaturated(settling)
board_view.set_dig_glow_enabled(not settling)
stage.set_settle_focus(settling)
log_view.set_mode("settle" if settling else "dig")
stage.set_round_text(_phase_text())
func _is_settling() -> bool:
if settlement == null or not settlement.is_battle_live():
return false
return settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING
func _phase_text() -> String:
# 文案装配归 narrator(settlement 缺位 = 非战斗态——组合根模式状态守卫)
if settlement == null:
return "第 — 轮 · —"
return Narrator.phase_text(int(settlement.round_index), String(settlement.battle_result),
settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING,
_boss_active, Narrator.boss_name(runner, db))
# ==================== B 区明细带 / 图例(模态入口)====================
func _open_review_from_band() -> void:
review_overlay.open(log_view.parsed_text())
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
tune_panel.visible = true
func _close_tune_panel() -> void:
tune_panel.visible = false
_start_overlay.visible = true
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