From d5997434337f8f33e30a544c755bd09f42a1a0e5 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 29 Sep 2026 19:25:19 +0800 Subject: [PATCH] =?UTF-8?q?iter8-T2/T3/T4=20=E5=8D=A1=E2=91=A1=E2=91=A2?= =?UTF-8?q?=E2=91=A3=EF=BC=9A=E7=BB=A7=E7=BB=AD=E4=B8=8A=E5=B1=80=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=88=E6=9C=89=E6=96=AD=E7=82=B9=E4=BA=AE=E8=B5=B7?= =?UTF-8?q?+=E6=96=87=E6=9C=AC=E5=B8=A6=E5=85=B3=E5=8D=A1=E5=BA=8F?= =?UTF-8?q?=E5=8F=B7+=E7=9C=9F=E5=AE=9E=E4=BF=A1=E5=8F=B7=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=BB=AD=E7=8E=A9=EF=BC=89+=E6=96=B0=E5=86=92?= =?UTF-8?q?=E9=99=A9=E4=BA=92=E6=96=A5=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=EF=BC=88=E9=A6=96=E5=87=BB=E6=8D=A2=E9=9D=A23s=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E4=BA=8C=E5=87=BB=E6=8F=90=E4=BA=A4+=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=8D=B3=E5=BC=83=E6=97=A7=E6=96=AD=E7=82=B9=EF=BC=89?= =?UTF-8?q?+T3=20=E4=B8=89=E7=BB=93=E5=B1=80=E6=B8=85=E6=96=AD=E7=82=B9?= =?UTF-8?q?=EF=BC=88=5Fsettle=20=E7=BB=9F=E4=B8=80=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=89+=E6=9C=80=E5=B0=8F=E8=AE=BE=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=EF=BC=88SettingsStore=20=E7=8B=AC=E7=AB=8B=E5=AD=98=E5=82=A8?= =?UTF-8?q?=20user://settings.cfg=20=E4=B8=89=E8=8A=82+SettingsOverlay=20?= =?UTF-8?q?=E9=9F=B3=E9=87=8F=20master=20bus=20=E5=AE=9E=E6=97=B6/?= =?UTF-8?q?=E5=85=A8=E5=B1=8F/=E5=87=8F=E5=8A=A8=E6=95=88=E8=BD=AC?= =?UTF-8?q?=E6=AD=A3/=E9=80=80=E5=87=BA=E6=B8=B8=E6=88=8F+DS=5FDEBUG=20?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E9=97=A8=E6=8E=A7+=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=9D=BF=E6=97=A7=E5=BC=80=E5=85=B3=E9=80=80=E5=BD=B9=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8E=A2=E9=92=88=E9=9A=94=E7=A6=BB=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=EF=BC=88click/input/layout/ui=5Fprobe/ui=5Ftune=20?= =?UTF-8?q?=E4=BA=94=E6=8E=A2=E9=92=88=20main.save=5Fmanager=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB+=E9=A6=96=E6=AC=A1=E5=B8=83=E5=B0=94=E7=BD=AE?= =?UTF-8?q?=E4=BD=8D=EF=BC=8C=E7=9C=9F=E5=AE=9E=E6=A1=A3=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E6=B1=A1=E6=9F=93=E4=BA=8B=E6=95=85=E4=B8=A4=E8=B5=B7=E5=B7=B2?= =?UTF-8?q?=E6=B8=85=E3=80=94=E6=95=99=E8=AE=AD=EF=BC=9A=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E5=86=99=E7=9B=98=E9=9D=A2=E9=9A=8F=20save=5Fmanager=20?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E8=B5=B0=EF=BC=8Cprobe=20=E6=8D=A2=20meta=20?= =?UTF-8?q?=E4=B8=8D=E6=8D=A2=20save=5Fmanager=20=E5=8D=B3=E6=BC=8F?= =?UTF-8?q?=E3=80=95=EF=BC=89=E2=80=94=E2=80=94=E9=97=B8=EF=BC=9A=E5=86=92?= =?UTF-8?q?=E7=83=9F=20593/0+=E4=BA=94=E6=8E=A2=E9=92=88=E5=85=A8=E7=BB=BF?= =?UTF-8?q?+click=20=E6=96=AD=E7=82=B9=E6=AE=B5=E9=97=AE=E9=A2=98=3D0?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=BB=A3=E6=8B=8D=E6=9D=BF=EF=BC=9A=E4=BA=92?= =?UTF-8?q?=E6=96=A5=E7=A1=AE=E8=AE=A4=E6=96=87=E6=A1=88/=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=9D=A2=E6=8E=92=E5=B8=83/=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=203s=EF=BC=88=E8=BF=BD=E8=AE=A4=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E5=91=88=E6=8A=A5=EF=BC=89+=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E6=9C=AC=E5=9C=B0=E5=8C=96=20v0.2=20=E9=9A=8F?= =?UTF-8?q?=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/gdd/systems/表现与支撑/设置与本地化.md | 3 +- game/core/adventure_runner.gd | 4 +- game/presentation/main.gd | 97 +++++++++++- game/presentation/settings_store.gd | 50 ++++++ game/presentation/settings_store.gd.uid | 1 + game/presentation/ui_assembler.gd | 9 +- game/presentation/views/settings_overlay.gd | 121 ++++++++++++++ .../views/settings_overlay.gd.uid | 1 + game/presentation/views/tune_panel.gd | 13 +- game/smoke/ui_click_probe.gd | 149 ++++++++++++++++++ game/smoke/ui_input_probe.gd | 8 + game/smoke/ui_layout_probe.gd | 7 + game/smoke/ui_probe.gd | 3 + game/smoke/ui_tune_probe.gd | 6 + .../unit/core/breakpoint_resume_test.gd.uid | 1 + 15 files changed, 452 insertions(+), 21 deletions(-) create mode 100644 game/presentation/settings_store.gd create mode 100644 game/presentation/settings_store.gd.uid create mode 100644 game/presentation/views/settings_overlay.gd create mode 100644 game/presentation/views/settings_overlay.gd.uid create mode 100644 game/tests/unit/core/breakpoint_resume_test.gd.uid diff --git a/design/gdd/systems/表现与支撑/设置与本地化.md b/design/gdd/systems/表现与支撑/设置与本地化.md index bc4def77..274ccc9b 100644 --- a/design/gdd/systems/表现与支撑/设置与本地化.md +++ b/design/gdd/systems/表现与支撑/设置与本地化.md @@ -93,7 +93,7 @@ MVP 不要求本系统的闭环:[不做清单](../../05-不做清单.md)把设 | 优先级 | 内容 | 后置原因 | |---|---|---| -| P1 | 已承诺设置项落位:减动效开关、音量控制入口 | 上游已承诺,随打磨期先落这两项 | +| ~~P1~~ | ~~已承诺设置项落位:减动效开关、音量控制入口~~ | **已兑现**(2026-09-29 迭代 8 卡④最小设置面:音量 master bus 实时+持久、减动效开关转正〔测试调整板旧开关退役,设置面唯一入口〕、全屏切换、退出游戏;载体 `presentation/settings_store.gd` 独立存储 + `views/settings_overlay.gd`;键位映射/多语言仍按后置表排期) | | P1 | 多语言生产流程与首批语言清单 | 生产流程未定:文案怎么管理、谁负责校对 | | P2 | 设置项全集(画面 / 键位 / 可访问性 / 语言各类别) | 全集无来源,待设计问题清单裁决后展开 | | P2 | 结算风味词输入的承接 | 归档只登记了存在,各归各的时机 | @@ -129,3 +129,4 @@ MVP 不要求本系统的闭环:[不做清单](../../05-不做清单.md)把设 | 版本 | 日期 | 改动 | |---|---|---| | v0.1 | 2026-09-15 | 自归档线索整合为范围草案(旧体系未单独成文) | +| v0.2 | 2026-09-29 | **最小设置面落地(迭代 8 卡④)**:P1 两项(音量入口+减动效开关)随迭代 8 兑现——三件面(音量 master bus 实时生效+即改即存/全屏/减动效转正)+退出游戏入口+调试入口 DS_DEBUG 门控;独立存储落地=`user://settings.cfg`(ConfigFile 三节 audio/display/motion,与存档两分按 2026-09-19 拍板);减动效开关自测试调整板退役(设置面唯一入口,battle.md 呈现承诺不变) | diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index ed943562..24c6a503 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -223,7 +223,9 @@ func _start_current_level() -> bool: Callable(self, "apply_rest"), _base_override, true, template_pick, resume_state) if ok: level_started.emit(level_index, String(_runner.level_row.get("display_name", ""))) - if resume_state.is_empty(): + # 入口停点断点(node_id=-1)与 defer_entry 同态(node_index=-1+单候选)——走入口呈现; + # 中途停点(node_id>=0)走停点呈现(restore_aux_items 先于呈现=C 区库存就位)。 + if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0: _runner.present_entry_choice() else: _runner.restore_aux_items(_resume_bp.get("aux_items", []) as Array) diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 21d7a2cd..b32047ad 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -39,6 +39,9 @@ var _adventure_mode := false # 冒险模式标记(inter 层确认分派 var logger = null # 试玩日志器(JSONL 持久化 + 卡死诊断转储——呈现层组件) var _level_active := false var _seed_edit: LineEdit +var _lvbtn: Button # 开始冒险按钮句柄(迭代 8 卡③——有断点时互斥二次确认文案换面) +var _resume_btn: Button # 继续上局按钮句柄(迭代 8 卡②——有断点才亮) +var _lv_start_armed := false # 新冒险二次确认态(首击置位、二击提交、3s 超时回落——仿放弃确认) var _start_overlay: Control var _end_overlay: BattleEndOverlay var _inter_overlay: Control @@ -70,6 +73,8 @@ var _adventure_gp_final := 0 # 收束清零拍暂存的最终合计 var _aux_use_selection := 0 # 魔法道具选中句柄(迭代 7 T4——0=未选中;选中期棋盘点击=使用目标拾取) var _aux_unlocked := {} # aux 解锁门控集(迭代 7 T5——工坊解锁×活行交集,冒险开始组装) var camp_overlay: CampOverlay # 营地覆盖层(元进度实现批——主菜单营地入口兑付呈现) +var settings_store # 系统级设置独立存储(迭代 8 卡④——音量/全屏/减动效) +var settings_overlay: SettingsOverlay # 设置覆盖层(迭代 8 卡④——最小设置面) var tune_panel: TunePanel var _modals = null # 模态层级状态机(modal_stack.gd——缺陷⑤纪律载体) @@ -91,6 +96,10 @@ func _ready() -> void: # (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"), @@ -98,6 +107,7 @@ func _ready() -> void: _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 @@ -162,18 +172,26 @@ func _build_ui() -> void: 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) _modals = ModalStack.new() - for m: Control in [legend_overlay, review_overlay, node_overlay, level_screen, _end_overlay, shop_overlay, camp_overlay, tune_panel]: + for m: Control in [legend_overlay, review_overlay, node_overlay, level_screen, _end_overlay, shop_overlay, camp_overlay, tune_panel, settings_overlay]: _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 + _lvbtn = start.lvbtn + _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 卡④——最小设置面入口 var inter: Dictionary = UiAssembler.build_inter_overlay(self) _inter_overlay = inter.overlay @@ -224,13 +242,60 @@ func _input_locked() -> bool: # ==================== 冒险模式入口(唯一试玩入口——单关测试直入口退役 2026-09-23 用户拍板)==================== func _start_adventure_from_menu() -> void: + # 互斥二次确认(迭代 8 卡②③):有断点时开新冒险须两击——首击转确认态(文案换面+3s 超时回落, + # 仿放弃确认形态),二击才提交;提交即弃旧断点(clear+写盘——「开新局」覆盖语义对盘面即时生效) + if save_manager != null and save_manager.has_breakpoint() and not _lv_start_armed: + _lv_start_armed = true + if _lvbtn != null: + _lvbtn.text = "已有未完冒险——再点一次开新冒险(旧断点将被覆盖)" + get_tree().create_timer(3.0).timeout.connect(_reset_lv_confirm) + return + if save_manager != null: + save_manager.clear_breakpoint() + save_manager.write_to_disk() + _lv_start_armed = false _start_overlay.visible = false _start_adventure() -# 冒险模式(三关连探——AdventureRunner 编排;转发信号签名与 runner 一致,接线照原样对接; -# 信号连接只在组合根——ADR-0001) -func _start_adventure() -> void: +func _reset_lv_confirm() -> void: + _lv_start_armed = false + if _lvbtn != null: + _lvbtn.text = "▶ 开始冒险(三关连探:地精洞窟 → 幽暗地域 → 灰矮人矿城)" + + +# 继续上局按钮可见性 = 断点在档态(迭代 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))) + _reset_lv_confirm() + + +# 继续上局(迭代 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) @@ -242,6 +307,13 @@ func _start_adventure() -> void: adventure.big_reward_paid.connect(_on_big_reward) 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() @@ -252,7 +324,6 @@ func _start_adventure() -> void: # 永久强化注入(元进度实现批):冒险开始参数面——meta 现算 override 显式传参(全零 = 白板) # 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签 var first_script: bool = not save_manager.is_first_adventure_done() - adventure.set_save_manager(save_manager) # 断点承载注入(迭代 8 卡①——停点快照写盘;调试/模拟不经过此路径) 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 @@ -370,6 +441,7 @@ func _on_battle_end_continue() -> void: _adventure_mode = false tune_panel.reset_sliders() _start_overlay.visible = true + _refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新(收束后已清=隐藏) _refresh_presentations() @@ -675,9 +747,23 @@ func _open_camp() -> void: func _on_camp_closed() -> void: camp_overlay.visible = false _start_overlay.visible = true + _refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新 _refresh_presentations() # 关闭后刷新顶栏(工坊解锁可能变了余额侧读数面) +# ---- 最小设置面(迭代 8 卡④——入口/开合仿营地先例:开始层互斥;生效与持久化归 SettingsStore)---- + +func _open_settings() -> void: + _start_overlay.visible = false + settings_overlay.visible = true + + +func _on_settings_closed() -> void: + settings_overlay.visible = false + _start_overlay.visible = true + _refresh_resume_button() + + # 营地快照投影(纯读——锻造三行/工坊六行/余额/最近冒险;拼句归 CampOverlay,账面归 MetaProgression) func _camp_data() -> Dictionary: var forge_rows: Array[Dictionary] = [] @@ -1037,6 +1123,7 @@ func _show_tune_panel() -> void: func _close_tune_panel() -> void: tune_panel.visible = false _start_overlay.visible = true + _refresh_resume_button() # 迭代 8 卡②——回菜单按断点在档态刷新 func _on_tune_apply(atk_pct: int, hp_pct: int) -> void: diff --git a/game/presentation/settings_store.gd b/game/presentation/settings_store.gd new file mode 100644 index 00000000..69b92986 --- /dev/null +++ b/game/presentation/settings_store.gd @@ -0,0 +1,50 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-09-29(迭代 8 卡④ 最小设置面——设置与本地化.md P1 两项兑现:音量入口+减动效开关) +# 系统级设置独立存储(设置与本地化.md——2026-09-19 用户拍板:一安装一份、删游戏才消失, +# 不进存档与生涯数据两分)。三件最小面:音量(master bus)/全屏/减动效。 +# 生效侧分工:入口与持久化归本类;音量对声音的生效口径归 音频.md(master bus 单点); +# 减动效对动画的分档兑现归 battle.md §Transitions(UiKit.motion_scale 既有承载,结算逐行 +# 兑现=信息载体只降速不跳过)。 +# 探针隔离:path 可注入(冒烟/探针禁碰真实 user://settings.cfg——同存档隔离纪律)。 +class_name SettingsStore +extends RefCounted + +const DEFAULT_PATH := "user://settings.cfg" + +var path: String = DEFAULT_PATH +var volume_linear := 1.0 # master bus 线性 0-1(0=静音) +var fullscreen := false +var reduced_motion := false + + +func _init(path_override: String = "") -> void: + if path_override != "": + path = path_override + + +func load_settings() -> void: + var cf := ConfigFile.new() + if cf.load(path) != OK: + return # 缺文件 = 出厂默认(合法态——非损坏不兜底) + volume_linear = clampf(float(cf.get_value("audio", "volume_linear", 1.0)), 0.0, 1.0) + fullscreen = bool(cf.get_value("display", "fullscreen", false)) + reduced_motion = bool(cf.get_value("motion", "reduced", false)) + + +func save_settings() -> bool: + var cf := ConfigFile.new() + cf.set_value("audio", "volume_linear", volume_linear) + cf.set_value("display", "fullscreen", fullscreen) + cf.set_value("motion", "reduced", reduced_motion) + return cf.save(path) == OK + + +# 应用到引擎态(启动恢复与实时调节同一口——headless 下窗口面跳过) +func apply() -> void: + var master := AudioServer.get_bus_index("Master") + AudioServer.set_bus_mute(master, volume_linear <= 0.001) + AudioServer.set_bus_volume_db(master, linear_to_db(maxf(volume_linear, 0.001))) + UiKit.motion_scale = 0.5 if reduced_motion else 1.0 + if DisplayServer.get_name() != "headless": + DisplayServer.window_set_mode( + DisplayServer.WINDOW_MODE_FULLSCREEN if fullscreen else DisplayServer.WINDOW_MODE_WINDOWED) diff --git a/game/presentation/settings_store.gd.uid b/game/presentation/settings_store.gd.uid new file mode 100644 index 00000000..fcd9199c --- /dev/null +++ b/game/presentation/settings_store.gd.uid @@ -0,0 +1 @@ +uid://clxn7dqtxo313 diff --git a/game/presentation/ui_assembler.gd b/game/presentation/ui_assembler.gd index b0539585..5c703eda 100644 --- a/game/presentation/ui_assembler.gd +++ b/game/presentation/ui_assembler.gd @@ -131,6 +131,9 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: seed_row.add_child(seed_edit) # 唯一试玩入口 = 正式「开始冒险」(三关序列);单关测试直入口(L2/L3)退役(2026-09-23 用户拍板) + var resumebtn := UiKit.button("▶ 继续上局(自最近停点恢复)") # 迭代 8 卡②——有断点才亮(可见性归组合根刷新) + resumebtn.visible = false + box.add_child(resumebtn) var lvbtn := UiKit.button("▶ 开始冒险(三关连探:地精洞窟 → 幽暗地域 → 灰矮人矿城)") box.add_child(lvbtn) var tunebtn := UiKit.button("测试用 · 角色数值调整板(模拟天赋树属性提升)") @@ -139,6 +142,8 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: box.add_child(dbgbtn) var campbtn := UiKit.button("营地 · 永久强化(钥石换永久属性——跨局成长账本)") # 元进度实现批——主菜单营地入口(双入口框架的主菜单一侧) box.add_child(campbtn) + var settingsbtn := UiKit.button("设置(音量 / 全屏 / 减动效 / 退出游戏)") # 迭代 8 卡④——最小设置面入口 + box.add_child(settingsbtn) # 玩法帮助文案(表值驱动——2026-09-16 AP 压速批遗留硬编码 8 随手修正;护甲等级掷点批同步"减伤"→"护甲强化") var help_text := "玩法:每轮 %d AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n每个非封锁格都藏一种格面资源(增伤/护甲强化/回血/爆裂/削弱)。开安全格、标中雷 = 正向兑付;\n开雷格、标错安全格 = 负向兑付——全部在轮末结算逐行兑现;吃掷点的基础攻击按护甲等级判定,无 miss、最低保底 1 点。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−%d 当场换图继续挖(每轮剩 %d 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。" % [ @@ -148,8 +153,8 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: box.add_child(help) return {"overlay": overlay, "box": box, "seed_edit": seed_edit, - "lvbtn": lvbtn, "tunebtn": tunebtn, "dbgbtn": dbgbtn, - "campbtn": campbtn} + "lvbtn": lvbtn, "resumebtn": resumebtn, "tunebtn": tunebtn, "dbgbtn": dbgbtn, + "campbtn": campbtn, "settingsbtn": settingsbtn} # 调试两层模式的层间过渡覆盖层(历史入口专用)。返回 {overlay, label, next_btn}——接线归组合根 diff --git a/game/presentation/views/settings_overlay.gd b/game/presentation/views/settings_overlay.gd new file mode 100644 index 00000000..6301eb59 --- /dev/null +++ b/game/presentation/views/settings_overlay.gd @@ -0,0 +1,121 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-09-29(迭代 8 卡④ 最小设置面) +# 设置覆盖层(纯视图边界规范 1.3 同款):音量滑杆/全屏/减动效三件+退出游戏+返回。 +# 生效与持久化全经 SettingsStore 单点(实时应用+即改即存);本类零核心触碰。 +# 减动效转正承载(测试调整板旧开关退役——设置面唯一入口,卡④ AC③)。 +# 探针接缝:retarget(store) = 隔离存档换注口(同 camp 换注先例);控件引用公有只读供断言。 +class_name SettingsOverlay +extends Control + +signal closed + +var _store +var _volume: HSlider +var _volume_val: Label +var _fs_btn: CheckButton +var _motion_btn: CheckButton + + +func _init(store) -> void: + _store = store + _build() + _sync() + visible = false + + +# 探针隔离换注(冒烟/探针禁碰真实 user://settings.cfg——换注后按新 store 重挂控件态) +func retarget(store) -> void: + _store = store + _sync() + + +func _build() -> void: + var dim := ColorRect.new() + dim.color = UiKit.BG_OVERLAY + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.add_child(center) + var box := VBoxContainer.new() + box.custom_minimum_size = Vector2(560, 0) + box.add_theme_constant_override("separation", 8) + center.add_child(box) + + var title := UiKit.label("设置", 26, true) + title.add_theme_color_override("font_color", UiKit.WARM) + box.add_child(title) + var hint := UiKit.label("系统级设置——独立存储不随存档(一安装一份,删游戏才消失)", 13, false) + hint.add_theme_color_override("font_color", UiKit.WARM_SOFT) + box.add_child(hint) + box.add_child(HSeparator.new()) + + # 音量(master bus 实时生效——入口归设置、生效口径归 音频.md) + var vol_row := HBoxContainer.new() + box.add_child(vol_row) + vol_row.add_child(UiKit.label("音量", 15, false)) + _volume = HSlider.new() + _volume.min_value = 0 + _volume.max_value = 100 + _volume.step = 1 + _volume.custom_minimum_size = Vector2(280, 24) + _volume.value_changed.connect(_on_volume_changed) + vol_row.add_child(_volume) + _volume_val = UiKit.label("100%", 15, true) + _volume_val.custom_minimum_size = Vector2(60, 0) + vol_row.add_child(_volume_val) + + # 全屏(art-bible 1920 档缩放策略冲突条目挂起注记——现行视口恒等拉伸,全屏切换即窗口模式切换) + _fs_btn = CheckButton.new() + _fs_btn.text = "全屏" + _fs_btn.toggled.connect(_on_fullscreen_toggled) + box.add_child(_fs_btn) + + # 减动效(battle.md §Transitions 承诺转正——时长减半,结算逐行兑现=信息载体不整体跳过) + _motion_btn = CheckButton.new() + _motion_btn.text = "减动效(动画时长减半——运动敏感/演示用)" + _motion_btn.toggled.connect(_on_motion_toggled) + box.add_child(_motion_btn) + + box.add_child(HSeparator.new()) + var quit_btn := UiKit.button("退出游戏") + quit_btn.pressed.connect(_on_quit_pressed) + box.add_child(quit_btn) + var close_btn := UiKit.button("返回") + close_btn.pressed.connect(_emit_closed) + box.add_child(close_btn) + + +func _sync() -> void: + _volume.set_value_no_signal(roundf(_store.volume_linear * 100.0)) + _volume_val.text = "%d%%" % roundi(_store.volume_linear * 100.0) + _fs_btn.set_pressed_no_signal(_store.fullscreen) + _motion_btn.set_pressed_no_signal(_store.reduced_motion) + + +func _on_volume_changed(v: float) -> void: + _store.volume_linear = v / 100.0 + _store.apply() + _store.save_settings() + _volume_val.text = "%d%%" % int(v) + + +func _on_fullscreen_toggled(on: bool) -> void: + _store.fullscreen = on + _store.apply() + _store.save_settings() + + +func _on_motion_toggled(on: bool) -> void: + _store.reduced_motion = on + _store.apply() + _store.save_settings() + + +func _on_quit_pressed() -> void: + _store.save_settings() + get_tree().quit() + + +func _emit_closed() -> void: + closed.emit() diff --git a/game/presentation/views/settings_overlay.gd.uid b/game/presentation/views/settings_overlay.gd.uid new file mode 100644 index 00000000..c842bb10 --- /dev/null +++ b/game/presentation/views/settings_overlay.gd.uid @@ -0,0 +1 @@ +uid://b7570rrk1jl7h diff --git a/game/presentation/views/tune_panel.gd b/game/presentation/views/tune_panel.gd index 902a9a20..54b7e619 100644 --- a/game/presentation/views/tune_panel.gd +++ b/game/presentation/views/tune_panel.gd @@ -159,14 +159,7 @@ func _build() -> void: var reset_btn := UiKit.button("重置为 0") reset_btn.pressed.connect(_reset_btn_pressed) btn_row.add_child(reset_btn) - # 减动效开关(battle.md §Transitions 内建承诺——切片期入口;正式设置层归设置批,两分制) - var motion_row := HBoxContainer.new() - box.add_child(motion_row) - motion_row.add_child(UiKit.label("减动效(动画时长减半——运动敏感/演示用)", 14, false)) - var motion_btn := CheckButton.new() - motion_btn.button_pressed = UiKit.motion_scale < 1.0 - motion_btn.toggled.connect(_on_motion_toggled) - motion_row.add_child(motion_btn) + # 减动效开关已转正迁出(迭代 8 卡④——设置面唯一入口,本面板退役该开关;motion_scale 引擎态由设置面承载) box.add_child(HSeparator.new()) var sim_row := HBoxContainer.new() @@ -221,10 +214,6 @@ func _scaled(base: int, pct: int) -> int: return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0))) -func _on_motion_toggled(on: bool) -> void: - UiKit.motion_scale = 0.5 if on else 1.0 - - func _emit_simulate_requested() -> void: simulate_requested.emit(int(_sim_count.value)) diff --git a/game/smoke/ui_click_probe.gd b/game/smoke/ui_click_probe.gd index 74e892c4..9b7e14d5 100644 --- a/game/smoke/ui_click_probe.gd +++ b/game/smoke/ui_click_probe.gd @@ -81,10 +81,78 @@ func _ready() -> void: await get_tree().process_frame if main.camp_overlay.visible or not main._start_overlay.visible: _problems.append("营地关闭后未恢复开始层") + # ---- 设置面段(迭代 8 卡④——隔离 store 换注禁碰真实 user://settings.cfg;真实按钮信号路径)---- + for p0 in ["user://smoke_click_probe_settings.cfg"]: + if FileAccess.file_exists(p0): + DirAccess.remove_absolute(p0) + var iso_s = load("res://presentation/settings_store.gd").new("user://smoke_click_probe_settings.cfg") + iso_s.load_settings() + main.settings_store = iso_s + main.settings_overlay.retarget(iso_s) + var settings_btn: Button = _find_button_by_prefix(main._start_overlay, "设置") + if settings_btn == null: + _problems.append("设置入口按钮缺失(开始层 settingsbtn)") + else: + settings_btn.pressed.emit() # 真实按钮信号路径 → _open_settings + await get_tree().process_frame + if not main.settings_overlay.visible or main._start_overlay.visible: + _problems.append("设置面未打开(点击路径)") + else: + # 减动效转正双态(卡④ AC③):开=0.5 引擎态即时生效+落盘持久;关=1.0 回常态 + var motion_btn: CheckButton = _find_checkbutton_by_text(main.settings_overlay, "减动效") + if motion_btn == null: + _problems.append("设置面减动效开关缺失") + else: + motion_btn.button_pressed = true + await get_tree().process_frame + if UiKit.motion_scale != 0.5: + _problems.append("减动效开未生效(motion_scale != 0.5)") + var chk = load("res://presentation/settings_store.gd").new("user://smoke_click_probe_settings.cfg") + chk.load_settings() + if not chk.reduced_motion: + _problems.append("减动效开未持久化(设置独立存储失守——卡④ AC⑤)") + motion_btn.button_pressed = false + await get_tree().process_frame + if UiKit.motion_scale != 1.0: + _problems.append("减动效关未恢复(motion_scale != 1.0)") + # 音量实时生效(卡④ AC①):滑杆 30% → master bus db 与 store 双读 + var vol: HSlider = main.settings_overlay._volume + vol.value = 30 + await get_tree().process_frame + var master_idx := AudioServer.get_bus_index("Master") + var expect_db := linear_to_db(0.3) + if absf(AudioServer.get_bus_volume_db(master_idx) - expect_db) > 0.1: + _problems.append("音量滑杆未实时生效(master bus db %f ≠ %f)" % [AudioServer.get_bus_volume_db(master_idx), expect_db]) + if absf(iso_s.volume_linear - 0.3) > 0.001: + _problems.append("音量未入 store(%f ≠ 0.3)" % iso_s.volume_linear) + # 调试入口默认隐藏门控(卡④ AC④——探针环境 DS_DEBUG 未设) + var dbg_btn: Button = _find_button_by_prefix(main._start_overlay, "调试") + if dbg_btn != null and dbg_btn.visible: + _problems.append("调试入口未隐藏(DS_DEBUG 门控失守——卡④ AC④)") + var settings_close: Button = _find_button_by_prefix(main.settings_overlay, "返回") + if settings_close == null: + _problems.append("设置面返回按钮缺失") + else: + settings_close.pressed.emit() + await get_tree().process_frame + if main.settings_overlay.visible or not main._start_overlay.visible: + _problems.append("设置面关闭后未恢复开始层") + # 旧入口退役断言(卡④ AC③——测试调整板不再含减动效开关) + var legacy_motion = _find_checkbutton_by_text(main.tune_panel, "减动效") + if legacy_motion != null: + _problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)") # 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底) var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json") iso_reset.load_from_disk() + iso_reset.mark_first_adventure_done() # 对齐基线(真实档已有游玩史)——非首次脚本模式=逐关抽签模板 + main.save_manager = iso_reset # 迭代 8 卡①——冒险断点写盘同步隔离(禁碰真实档 user://save.json) main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset) + main._refresh_resume_button() + var resume_btn0: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局") + if resume_btn0 == null: + _problems.append("继续上局按钮缺失(开始层 resumebtn——装配器)") + elif resume_btn0.visible: + _problems.append("无断点态继续上局按钮未隐藏(卡② AC①——新档合法空态)") main.tune_panel.set_sliders(400, 200) # 高加成保 L1 可通关——L2 岔路选路段只能经冒险推进到达(单关直入口退役) var t1 := Time.get_ticks_msec() main._start_adventure_from_menu() @@ -177,6 +245,77 @@ func _ready() -> void: if main.settlement.battle_result == "" and main.settlement.action_economy.phase == "Active": _run_modal_probes_once() _play_one_action() + # ---- 断点续玩段(迭代 8 卡②③——真实按钮信号路径;存档面已隔离 iso_reset)---- + # 前提注记:本探针首局常态 = L1 战败收口(问题=0 基线口径)——断点段①借战败结局验 T3 清除断点。 + await get_tree().process_frame + var bp_file := "user://smoke_click_probe_reset.json" + var iso_chk = load("res://core/save_manager.gd").new(bp_file) + iso_chk.load_from_disk() + # ① 战败结局清断点(T3 口径——三结局清断点之一):停点快照随局写入,战败结算须清 + iso_chk.load_from_disk() + if iso_chk.has_breakpoint(): + _problems.append("断点段:战败结算后断点未清(T3 三结局清断点失守)") + if not main._end_overlay.is_open(): + _problems.append("断点段:战败后未出冒险终局面") + if main._end_overlay._btn == null: + _problems.append("断点段:终局面返回按钮缺失") + else: + main._end_overlay._btn.pressed.emit() # 真实按钮信号路径(_btn=继续/返回共用句柄) + await get_tree().process_frame + if not main._start_overlay.visible: + _problems.append("断点段:终局返回未回开始菜单") + var resume_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局") + if resume_btn != null and resume_btn.visible: + _problems.append("断点段:退出清断点后继续上局按钮未隐藏(卡②/T3 AC③)") + # ② 中断→续玩:新冒险达入口停点=断点在档 → 合成回菜单(模拟关游戏重开)→ 按钮点亮 → 点击恢复至停点 + var lv_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 开始冒险") + if lv_btn == null or resume_btn == null: + _problems.append("断点段:开始冒险/继续上局按钮缺失") + else: + lv_btn.pressed.emit() # 无断点态单击直启(不触发二次确认) + await get_tree().process_frame + if not main.level_screen.visible: + _problems.append("断点段:无断点开新冒险未达入口停点") + else: + iso_chk.load_from_disk() + if not iso_chk.has_breakpoint(): + _problems.append("断点段:入口停点断点未写盘(卡①写面失守)") + var cand_before: Array = main.runner.next_candidate_ids() + # 合成「关游戏重开回菜单」(探针专用——真实路径无此入口;断点已在盘) + main._exit_level_screen() + main._start_overlay.visible = true + main.adventure = null + main._refresh_resume_button() + await get_tree().process_frame + if not resume_btn.visible: + _problems.append("断点段:有断点态继续上局按钮未点亮(卡② AC②前置)") + else: + resume_btn.pressed.emit() # 真实按钮信号路径 → _resume_from_menu → resume_adventure + await get_tree().process_frame + await get_tree().process_frame + if main.adventure == null or main.runner == null or not main.level_screen.visible: + _problems.append("断点段:续玩未恢复至停点(关卡画面未现)") + else: + var cand_after: Array = main.runner.next_candidate_ids() + if cand_after.size() != cand_before.size() or (cand_after.size() > 0 and int(cand_after[0]) != int(cand_before[0])): + _problems.append("断点段:续玩候选与中断前不一致(停点重建失准)") + if not iso_chk.load_from_disk() or not iso_chk.has_breakpoint(): + _problems.append("断点段:续玩误清断点(只在三结局清)") + # ③ 互斥二次确认(卡②③):断点在档时开新冒险首击=确认态、二击才提交 + main._exit_level_screen() + main._start_overlay.visible = true + main.adventure = null + main._refresh_resume_button() + await get_tree().process_frame + lv_btn.pressed.emit() + await get_tree().process_frame + if not main._start_overlay.visible or main.adventure != null: + _problems.append("断点段:有断点首击即开新冒险(互斥二次确认失守卡②③)") + lv_btn.pressed.emit() + await get_tree().process_frame + await get_tree().process_frame + if main.adventure == null or main._start_overlay.visible: + _problems.append("断点段:二次确认后未开新冒险") print("[click_probe] 循环退出(done=%s budget=%s)问题=%d" % [ str(_done), str(action_budget), _problems.size()]) for p in _problems: @@ -365,3 +504,13 @@ func _find_button_by_prefix(root: Node, prefix: String) -> Button: if found != null: return found return null + + +func _find_checkbutton_by_text(root: Node, needle: String) -> CheckButton: + if root is CheckButton and String(root.text).contains(needle): + return root + for child in root.get_children(): + var found := _find_checkbutton_by_text(child, needle) + if found != null: + return found + return null diff --git a/game/smoke/ui_input_probe.gd b/game/smoke/ui_input_probe.gd index 8b3b6400..92163c50 100644 --- a/game/smoke/ui_input_probe.gd +++ b/game/smoke/ui_input_probe.gd @@ -25,6 +25,14 @@ func _ready() -> void: main = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame + # 存档隔离(迭代 8 卡①②——断点写盘面随冒险挂 main.save_manager;探针禁碰真实档 user://save.json, + # 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变) + var iso = load("res://core/save_manager.gd").new("user://smoke_input_probe_save.json") + iso.load_from_disk() + iso.mark_first_adventure_done() + main.save_manager = iso + main.meta = load("res://core/meta_progression.gd").new(main.db, iso) + main._refresh_resume_button() main._start_adventure_from_menu() await get_tree().process_frame # ---- ⑫ 图上命中真实输入(关卡画面实装批 2026-09-28 回归闸):入口停点候选节点中心 diff --git a/game/smoke/ui_layout_probe.gd b/game/smoke/ui_layout_probe.gd index c01d0d26..5e8e28e7 100644 --- a/game/smoke/ui_layout_probe.gd +++ b/game/smoke/ui_layout_probe.gd @@ -10,6 +10,13 @@ func _ready() -> void: var main = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame + # 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律) + var iso = load("res://core/save_manager.gd").new("user://smoke_layout_probe_save.json") + iso.load_from_disk() + iso.mark_first_adventure_done() + main.save_manager = iso + main.meta = load("res://core/meta_progression.gd").new(main.db, iso) + main._refresh_resume_button() main._start_adventure_from_menu() for i in range(3): await get_tree().process_frame diff --git a/game/smoke/ui_probe.gd b/game/smoke/ui_probe.gd index c053c00e..bcc6637d 100644 --- a/game/smoke/ui_probe.gd +++ b/game/smoke/ui_probe.gd @@ -57,7 +57,10 @@ func _ready() -> void: DirAccess.remove_absolute(p) var iso_zero = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json") iso_zero.load_from_disk() + iso_zero.mark_first_adventure_done() # 对齐真实档游玩史口径(模板抽签路径不变) + main.save_manager = iso_zero # 迭代 8 卡①——断点写盘面同步隔离(禁碰真实档) main.meta = load("res://core/meta_progression.gd").new(main.db, iso_zero) + main._refresh_resume_button() main._start_adventure_from_menu() await get_tree().process_frame await get_tree().process_frame diff --git a/game/smoke/ui_tune_probe.gd b/game/smoke/ui_tune_probe.gd index 336f2182..0652cac8 100644 --- a/game/smoke/ui_tune_probe.gd +++ b/game/smoke/ui_tune_probe.gd @@ -59,6 +59,12 @@ func _ready() -> void: problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp]) # ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成" # (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获 + # 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律) + var iso_tune = load("res://core/save_manager.gd").new("user://smoke_tune_probe_save.json") + iso_tune.load_from_disk() + iso_tune.mark_first_adventure_done() + main.save_manager = iso_tune + main.meta = load("res://core/meta_progression.gd").new(main.db, iso_tune) main.tune_panel.set_sliders(100, 50) main.tune_panel.press_apply_for_probe() main._start_adventure_from_menu() # 完整冒险路径(唯一试玩入口;_start_single 按语义清零滑条 = 恒基线,不承载加成断言) diff --git a/game/tests/unit/core/breakpoint_resume_test.gd.uid b/game/tests/unit/core/breakpoint_resume_test.gd.uid new file mode 100644 index 00000000..3b761b07 --- /dev/null +++ b/game/tests/unit/core/breakpoint_resume_test.gd.uid @@ -0,0 +1 @@ +uid://byrnh81s2nda1