From be772cfb2f3b8e3b75ededd1d43c17b68a1d9f7b Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 2 Oct 2026 08:11:06 +0800 Subject: [PATCH] =?UTF-8?q?T17=E5=8F=8D=E9=A6=88=E6=89=B9=E6=94=B6?= =?UTF-8?q?=E5=AE=98:=20=E2=91=A0=E7=BB=93=E7=AE=97=E6=BC=94=E6=92=AD?= =?UTF-8?q?=E7=A1=AC=E9=94=81v3.1=E2=80=94=E2=80=94=E9=94=81=E6=9C=9F?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E7=BC=93=E5=AD=98+=E6=BC=94=E6=92=AD?= =?UTF-8?q?=E5=AE=8C=E8=BE=B9=E6=B2=BF=E8=87=AA=E5=8A=A8=E9=87=8D=E6=94=BE?= =?UTF-8?q?=EF=BC=88=E7=8E=A9=E5=AE=B6=E6=93=8D=E4=BD=9C=E9=9B=B6=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E2=80=94=E2=80=94=E5=AE=9E=E6=9C=BA=E5=8D=A1=E6=AD=BB?= =?UTF-8?q?=E6=84=9F=E6=A0=B9=E5=9B=A0=3D=E9=94=81=E6=9C=9F=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=A2=AB=E5=90=9E=E6=97=A0=E5=8F=8D=E9=A6=88=EF=BC=89?= =?UTF-8?q?=E2=91=A1=E6=81=A2=E5=A4=8D=E8=A2=AB=E5=B9=B3=E5=8F=8D=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E8=A1=80=E9=87=8F?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=88=E7=BD=AE=E6=BB=A1/=E5=9B=9E?= =?UTF-8?q?=E8=A1=80=E5=B0=81=E9=A1=B6=E5=90=ABpct=EF=BC=89+=E9=80=89?= =?UTF-8?q?=E8=B7=AF=E5=91=88=E7=8E=B0=EF=BC=88=E5=B9=B2=E7=BA=BF=E5=81=8F?= =?UTF-8?q?=E7=A7=BB+=E9=9D=9E=E5=80=99=E9=80=89=E9=99=8D=E9=80=8F?= =?UTF-8?q?=E6=98=8E=EF=BC=89=E2=80=94=E2=80=94probe=E5=8D=A1=E6=AD=BB?= =?UTF-8?q?=E7=9C=9F=E5=9B=A0=3Dprobe=E6=9C=AA=E9=80=82=E9=85=8D=E8=BF=AD?= =?UTF-8?q?=E4=BB=A310=20BonusPickOverlay=EF=BC=88=E5=81=87=E9=98=B3?= =?UTF-8?q?=E6=80=A7=E6=89=A3=E5=8E=8B=E5=B9=B3=E5=8F=8D=EF=BC=89=E2=91=A2?= =?UTF-8?q?=E5=87=8F=E5=8A=A8=E6=95=88=E6=8C=89=E9=92=AE=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=96=87=E6=A1=88=E4=B8=8D=E5=88=B7=E6=96=B0bug=E4=BF=AE+?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=91=BD=E5=90=8D=E3=80=8C=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E6=BC=94=E5=87=BA=E5=8A=A8=E7=94=BB=EF=BC=9A=E5=AE=8C=E6=95=B4?= =?UTF-8?q?/=E5=BF=AB=E9=80=9F=E3=80=8D+=E6=93=8D=E4=BD=9C=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=86=85=E8=81=94=E2=91=A3=E5=87=BA=E5=BA=A6=E2=89=A4?= =?UTF-8?q?2=E5=85=A8=E5=A5=97=EF=BC=88=E7=94=A8=E6=88=B7=E6=8B=8D?= =?UTF-8?q?=E6=9D=BF=EF=BC=89=E2=80=94=E2=80=94level=5Ftable=E7=AC=AC?= =?UTF-8?q?=E4=B8=83=E6=A3=80fanout=5Fviolation+L2L3=E5=8D=81=E5=BC=A0?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=87=8D=E5=BB=BA=EF=BC=88tools/t17=5Ffanout?= =?UTF-8?q?2=5Frebuild.py=E4=BA=94=E9=AA=A8=E6=9E=B6=E5=87=BA=E5=BA=A6?= =?UTF-8?q?=E2=89=A42/=E5=B1=82=E5=AE=BD=E2=89=A44/=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E4=BF=9D=E6=8C=81/=E8=87=AA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=9B=9B=E6=A3=80=EF=BC=89+=E5=B1=82=E5=AE=BD?= =?UTF-8?q?=E4=B8=8A=E9=99=905=E5=BA=9F=E6=AD=A2=EF=BC=88=E6=95=B0?= =?UTF-8?q?=E5=AD=A6=E4=B8=8D=E7=9B=B8=E5=AE=B9=EF=BC=89=E6=94=B94+?= =?UTF-8?q?=E9=92=89=E5=9B=BA=E6=96=AD=E8=A8=805=E5=A4=84=E9=9A=8F?= =?UTF-8?q?=E6=96=B0=E5=9B=BE+=E6=96=87=E6=A1=A3=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=EF=BC=88=E7=94=9F=E6=88=90v0.6/=E5=9C=B0=E5=9B=BEv1.3=EF=BC=89?= =?UTF-8?q?=E2=91=A4probe=E9=80=82=E9=85=8DBonusPick=E4=B8=89=E6=80=81?= =?UTF-8?q?=EF=BC=88=E6=9B=BF=E6=8D=A2/result=E7=BB=A7=E7=BB=AD/=E5=BE=85?= =?UTF-8?q?=E9=80=89=E7=A1=AE=E8=AE=A4=EF=BC=89+=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E8=80=97=E5=B0=BD=E6=94=BE=E5=BC=83=E6=94=B6=E5=8F=A3+?= =?UTF-8?q?=E6=8E=92=E6=92=AD=E8=B1=81=E5=85=8D=EF=BC=9B=E9=97=B8=3D?= =?UTF-8?q?=E5=86=92=E7=83=9F628/0+run=5Fall=2015=E5=A5=97+=E4=B8=89?= =?UTF-8?q?=E6=8E=A2=E9=92=88+doc=E4=B8=89=E9=97=B8=E9=9B=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=EF=BC=9B=E8=BF=9E=E5=B8=A6=E4=B9=89=E5=8A=A1=3DMC?= =?UTF-8?q?=E8=AF=BB=E6=95=B0=E5=9F=BA=E7=BA=BF=E9=9A=8F=E5=9B=BE=E5=8F=98?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=E5=85=A8=E6=97=A7=E9=94=9A=E4=BD=9C=E5=BA=9F?= =?UTF-8?q?=E5=8F=AA=E6=B5=8B=E4=B8=8D=E5=88=A4=EF=BC=8C=E9=87=8D=E9=94=9A?= =?UTF-8?q?=E5=BD=92=E4=B8=8B=E4=B8=80=E6=B5=8B=E9=87=8F=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/gdd/systems/冒险循环/关卡节点图生成.md | 3 + design/gdd/systems/冒险循环/关卡节点地图.md | 1 + game/core/adventure_runner.gd | 13 +- game/data/schemas/level_table.gd | 6 + game/data/tables/level.tres | 4 +- game/presentation/main.gd | 29 ++-- game/presentation/views/level_map_screen.gd | 10 +- game/presentation/views/settings_overlay.gd | 15 +- game/smoke/smoke_all.gd | 21 +-- tools/t17_fanout2_rebuild.py | 146 ++++++++++++++++++ 10 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 tools/t17_fanout2_rebuild.py diff --git a/design/gdd/systems/冒险循环/关卡节点图生成.md b/design/gdd/systems/冒险循环/关卡节点图生成.md index d39401aa..95c8e086 100644 --- a/design/gdd/systems/冒险循环/关卡节点图生成.md +++ b/design/gdd/systems/冒险循环/关卡节点图生成.md @@ -60,6 +60,8 @@ 抽签消费主种子派生的统一随机流,走扩签通道新立用途标签 `node_map`(统一流口径已定——[ADR-0007](../../../dev/architecture/adr-0007-rng-single-stream.md) 裁丙落地;标签不参与派生、只进事件日志)。 +**出度上限与层宽(2026-10-01 用户拍板 T17 目检)**:单节点出边数(出度)**最大为 2**——加载校验第七检(`fanout_violation` 加载致命)。规则连带(数学约束):出度 ≤2 + 入口唯一下,BFS 层宽上限 = **4**(层宽序列上限 1,2,4;2026-09-29 旧规则「层宽上限 5」与出度约束不相容,随本拍板废止)。现行池 = `tools/t17_fanout2_rebuild.py` 按两规则重建(五骨架:宽廊双干〔现行〕/三干中廊/宽廊斜岔/三干宽门/窄廊短支——层宽序列 1-2-4-1-1 / 1-2-3-2-1 / 1-2-2-2-1,最大层宽 4 应用足)。 + **内容纪律**:每张新模板等于一个小内容批——模板里每个战斗与 Boss 节点都必须在敌人与棋盘生成的数值表里有精确行,义务从冒险结构加关纪律的"每关一行集"细化到"每模板一行集";冒烟覆盖义务同批扩为遍历池内全部模板校验,不只抽中的那张。 ### 抽签时机与可复现 @@ -219,4 +221,5 @@ | v0.2 | 2026-09-27 | **首轮裁决落文批(用户拍板①④⑤+路线确认)**:①第一关参与抽签、仅首次冒险走固定脚本——新增「首次冒险的固定脚本」节(存档域布尔标志「首次冒险已进行」、收束点置位、边界与测量口径,P0/验收/依赖表同步);④首版池规模=每关 5 张(调参表投值);⑤抽签时点确认;用户并裁「骨架不完全固定、多份随机调取」= 台阶一路线确认(台阶节补白话块+教学位随骨架走注记)。②③补证据面待复裁——新增「拍板点证据面」节:A=随机流/增流白话释义+两选项;B=台阶一详解(玩家看到什么/做三件/不做两件/内容形态 15 骨架/遭遇表按编号共用约束)+实施时点三选项(A 立即全上/B 等调参批/C 机制先行骨架随后,荐 C);测量对账实施门条款同步改"随机量扩池守门、机制可先行"口径 | | v0.3 | 2026-09-27 | **②③终裁落文批(用户两轮复裁)**:②统一用一个流·全面执行——全游戏 rng 架构批先行([ADR-0007](../../../dev/architecture/adr-0007-rng-single-stream.md) 评估书交付:核心发现=七流系同一主种子的盐值分栏、字面目标现行已满足;待裁决=甲维持分栏/乙物理并流/丙并流+全事件日志),本特性实施排队其后(P0 补前置行、抽签节流形态改随 ADR 结论);③特性内机制先行骨架随后(池暂 N=1、骨架内容批扩池);证据面 A 补裁决注记。06 新立「全游戏随机流统一架构批」条目(数据配置节) | | v0.4 | 2026-09-27 | **实施前置解除批(统一随机流批随批——rng 架构批已按 ADR-0007 裁丙落地)**:状态行改实施解锁;抽签节流形态定稿 = 统一流扩签新用途标签 `node_map`(标签不参与派生、只进事件日志——并流后派生隔离概念退役);P0 闭环前置行改解除口径。本批只记架构依赖解除,特性实施仍未开工 | +| v0.6 | 2026-10-01 | **出度上限拍板批(T17 目检用户裁决「节点出度最大为 2」)**:① 加载校验第七检 `fanout_violation`(出度 >2 加载致命);② 规则连带——层宽上限随出度约束调整为 4(2026-09-29「层宽上限 5」废止,数学不相容);③ L2/L3 十张模板全池重建(tools/t17_fanout2_rebuild.py——出度 ≤2/层宽 ≤4/类型序列保持/可达自校验,五骨架换名宽廊双干〔现行〕等);④ 钉固断言 5 处随新图更新(min/max 路径/入口候选/奖励后候选)。**连带义务:MC 读数基线随图结构变化漂移——全旧 MC 锚作废,重锚归下一测量批**(当前只测不判) | | v0.5 | 2026-09-28 | **台阶一实施落地批(M-3 迭代 5 T5)**:P0 六项全落地——①关卡表逐关行 templates 池(3 关 × 5,模板 0 = 现行图回滚档;L1 单链排列变体〔图形态 Boss 最大编号 vs 遭遇深度 4 两不可全〕/L2L3 网状变体〔战斗编号恒 ∈ 遭遇表行集、商店必经位承袭〕)②node_map 扩签(标签不参与派生只进事件日志,黄金向量零改动)③首次冒险固定脚本(存档唯一新增布尔位「首次冒险已进行」、老档迁移=true、收束点置位幂等、测量夹具恒抽签)④池内全模板加载校验(validate 抽出 + config_db 跨表校验模板感知)⑤抽签确定性/零 scope 强制/N=1 等价/首冒分支 冒烟断言(558→576)⑥MC 分层出数(RUNSUM level_templates)。内容批生成器 = tools/t5_build_templates.py(生成前自校验)。遗留:台阶二/三后置不变;正式侧生存链/带面影响随读数批 | diff --git a/design/gdd/systems/冒险循环/关卡节点地图.md b/design/gdd/systems/冒险循环/关卡节点地图.md index ebd8bb60..601bf248 100644 --- a/design/gdd/systems/冒险循环/关卡节点地图.md +++ b/design/gdd/systems/冒险循环/关卡节点地图.md @@ -188,3 +188,4 @@ Boss 兑付(胜利或英雄死亡)触发关卡收束点,此后不再有选 | v1.0.1 | 2026-09-17 | **P1 分支节点图内容与实现批落地**(用户拍板任务批——切片代码 / 难度曲线与Boss v0.7 / registry / 06 待办同批):首张分支图 = 切片关卡 2「第二层 · 幽暗地域」行集落地(9 节点网状图:入口 0 → 中间三层 2/3/2 → Boss 8,真实岔路 4 处,奖励点 + 恢复点各 1,战斗节点 6 + Boss);行进器图版推进(`choices_presented` 选路态 + `pick_next_node`——零自动推进,战斗胜利不再自动推进、`continue_after_node` 泛化为兑付确认,06 接口漂移清单 ④⑤⑧ 清偿);图合法性校验落地 `LevelTable.validate`(单链与图同一套算法,L1 零迁移过校验,违规加载致命);节点图选路 UI(选路覆盖层整图自绘——房间方块 + 通道连线 + 当前/候选高亮,占位图形质量;顶栏图形态渲染行进序列 + 候选);首页"关卡 2 试玩"直入口(测试模式,≠ 两关序列生效)。本版本号只记实现批状态,图规则口径零改动 | | v1.1 | 2026-09-20 | **商店房扩类(GP+商店房批,用户拍板——与冒险结构 / 战斗结算 / 地图战斗资源 / 难度曲线与Boss / registry / 切片同批)**:① 节点封闭枚举四类→五类(+商店房);② 编排规则定稿——每关恰 1、关底 Boss 前一位必经位(哈迪斯式"决战前最后一个补给点"定位);必经位由冒烟断言承载、图合法性六检零新增;③ 兑付定稿——打开商店覆盖层、购买即时生效写统一冒险加算区(与关卡大奖励同账,归冒险结构)、离开后兑付确认;商店房不携带战斗行;④ 节点兑付表 / 纪律表 / 依赖表 / P0 闭环 / 验收标准 / P2「事件类节点候选」注记同步 | | v1.2 | 2026-09-23 | **停点前移批(冒险入口流程倒置修复,用户 2026-09-23 拍板;上位裁决 `../../../dev/architecture/adr-0006-board-node2d-rendering.md` 与 `../../../dev/architecture/board-node2d-migration-plan.md` §1,代码实装另批并行)**:① 行进循环改停点前移口径——关卡开始即呈现节点图选路停点(关卡画面 = 选路覆盖层常驻化,入口节点不自动进入,第一次选路就是"出发"),选路确认进节点,兑付确认后返回关卡画面再选路(战后回图);兑付后确认、零自动推进两条纪律语义不变,只改承载位;② 呈现侧口径补进度承载条款——关卡画面本体即节点链进度呈现载体(节点态直接读图:已过暗金 / 当前金 / 候选下一跳通道亮金 / Boss 旗朱描边),不另设进度条组件(战斗屏 A 区进度条随批退役);③ P0 闭环两条、依赖表 HUD 框架行、验收标准「行进自主」条同步。**批内代拍板三项已追认(2026-09-24 用户确认)**:① B/C/D 字母保持不重编;② 28px 纵向空间划归 C/D 底行;③ 关卡画面进度呈现由选路覆盖层节点态承载(登记面见 HUD框架 v0.8) | +| v1.3 | 2026-10-01 | **图合法性第七检(T17 目检用户拍板「节点出度最大为 2」)**:`validate` 新增 `fanout_violation`(单节点出边 >2 加载致命);规则连带层宽上限 4(原 5 与出度约束不相容,废止)——生成侧规则与模板池重建归[关卡节点图生成](关卡节点图生成.md) v0.6 同批 | diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 09bc6b89..d77421c0 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -113,7 +113,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _base_override = _normalize_override(params.get("hero_override", {})) _first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入) _aux_unlocked = params.get("aux_unlocked", {}) if params.get("aux_unlocked", {}) is Dictionary else {} # additive(迭代 7 T5) - ledger["hero_hp"] = int(db.hero.max_hp) + int(_base_override["max_hp"]) # 生命账本置满(表值+永久强化 flat——加算区刚归零) + ledger["hero_hp"] = effective_max_hp() # 生命账本置满(生效上限口径——T17 修复:含 TunePanel pct,与战斗内同源) state = "running" return _start_current_level() @@ -211,6 +211,15 @@ func set_hero_override(atk_pct: int, hp_pct: int) -> void: _runner.set_hero_override(atk_pct, hp_pct) +# 冒险账本侧生效上限(T17 修复——账本置满/回血封顶与战斗内上限同式同源): +# (表值 + 永久强化 flat + 加算区 flat)后乘 TunePanel 百分比——叠加次序与 LevelRunner.effective_max_hp +# 逐字一致。旧口径两处只算 flat 不乘 pct:调生命上限 26→104 时开局血仍 26(账本 26 / 战斗内 104)、 +# 通关 20% 回血封顶按 26 算。pct=0(模拟/冒烟默认)时乘法恒等——MC 锚零漂移。 +func effective_max_hp() -> int: + var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + int(bonus.get("max_hp", 0)) + return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0))) + + # 断点承载注入(迭代 8 卡①——组合根冒险开始时调用;模拟/冒烟不注入 = 断点面整体静默) func set_save_manager(sm) -> void: _save_manager = sm @@ -609,7 +618,7 @@ func _on_level_finished(payload: Dictionary) -> void: # 通关兑付(非末关恰一次;迭代 10 三层奖励框架):部分回血先行 → 加权洗牌掷 3 → 三选一停点 #(顺序即契约——回血按当刻账本与上限结算,选择停点零自动推进) var hp_before := int(ledger.get("hero_hp", 0)) - var max_eff := int(db.hero.max_hp) + int(_base_override["max_hp"]) + int(bonus.get("max_hp", 0)) + var max_eff := effective_max_hp() # T17 修复:回血封顶口径含 TunePanel pct(同战斗内上限) var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before) ledger["hero_hp"] = hp_before + heal # 置位先于 emit(信号同步派发——处理器内立即确认时 _pending_next_level 必须已就位) diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index 64619156..3fe10427 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -130,6 +130,12 @@ func _validate_graph(L: int, nodes: Array, is_graph: bool, tag: String = "") -> errs.append({"category": "dangling_next", "message": "level %d%s 节点 %d 出边指向不存在编号: %d" % [L, tag, ids[i], tid]}) elif tid <= ids[i]: errs.append({"category": "structural", "message": "level %d%s 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, tag, ids[i], tid]}) + # ③b 出度上限(T17 用户拍板 2026-10-01「节点出度最大为 2」):单节点出边 >2 即加载致命。 + # 规则连带:出度 ≤2 + 入口唯一下 BFS 层宽上限 = 4(序列 1,2,4——原「层宽上限 5」与出度约束 + # 不相容,模板池随本检同批按层宽 ≤4 重建)。 + for i in nodes.size(): + if nexts[i].size() > 2: + errs.append({"category": "fanout_violation", "message": "level %d%s 节点 %d 出度 %d > 2(出度上限拍板 2026-10-01)" % [L, tag, ids[i], nexts[i].size()]}) # ④ 入口唯一(无入边节点恰一——唯一入口) var has_inbound := {} for i in nodes.size(): diff --git a/game/data/tables/level.tres b/game/data/tables/level.tres index a2b175fa..8589bab1 100644 --- a/game/data/tables/level.tres +++ b/game/data/tables/level.tres @@ -6,8 +6,8 @@ script = ExtResource("1") rows = [ {"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30, "templates": [{"display_name": "地精洞窟·主线洞道(现行)", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先赏后整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先整后赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·赏战整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·整战赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"node_depth": 4, "type": "boss"}]}]}, -{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "幽暗地域·五岔环廊(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2, 3, 4, 5]}, {"node_depth": 1, "type": "battle", "next": [6]}, {"node_depth": 2, "type": "reward", "next": [6, 7]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [6, 7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "幽暗地域·四岔宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2, 3, 4]}, {"node_depth": 1, "type": "battle", "next": [5]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [8]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "幽暗地域·深层四岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4, 5, 6]}, {"node_depth": 2, "type": "reward", "next": [3, 4]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "幽暗地域·宽门赏后置", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 3, 4, 5]}, {"node_depth": 1, "type": "battle", "next": [2, 6]}, {"node_depth": 2, "type": "reward", "next": [7]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "幽暗地域·双干汇流", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [4, 5, 6]}, {"node_depth": 2, "type": "reward", "next": [3, 4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]}, -{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30, "templates": [{"display_name": "灰矮人矿城·五岔主竖井(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2, 3, 4, 5]}, {"node_depth": 1, "type": "battle", "next": [6]}, {"node_depth": 2, "type": "reward", "next": [6, 7]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [6, 7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "灰矮人矿城·四岔宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2, 3, 4]}, {"node_depth": 1, "type": "battle", "next": [5]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [8]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "灰矮人矿城·深层四岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4, 5, 6]}, {"node_depth": 2, "type": "reward", "next": [3, 4]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "灰矮人矿城·宽门赏后置", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 3, 4, 5]}, {"node_depth": 1, "type": "battle", "next": [2, 6]}, {"node_depth": 2, "type": "reward", "next": [7]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "灰矮人矿城·双干汇流", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [4, 5, 6]}, {"node_depth": 2, "type": "reward", "next": [3, 4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]} +{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]}, +{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]} ] ; 关卡节点图生成 v0.4 台阶一内容批(M-3 迭代 5 T5,2026-09-28——15 张骨架 = 3 关 × 5,拍板点④): diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 4b29cd45..8eaa675e 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -97,6 +97,17 @@ var _row_queue: Array = [] # 待排播行(step_row 载荷原样入队 var _row_pace_wait := 0.0 # 距下一行排播的剩余秒(_process 消耗) var _pace_prev_segment := "" # 上一排播行的段型(段间/段内延迟判据) var _was_pumping := false # 排播活跃边沿(T17——末行消费帧触发结算视觉回挖掘态刷新) +var _locked_click := {} # 锁期点击缓存(T17 v3.1——最后一次;演播完边沿重放,玩家操作零丢失) + + +# 锁期点击重放(v3.1):延迟一帧重入唯一动作通道——此刻队列空锁已解,按普通动作受理 +#(局面在演播期无变化:逻辑先行、演播只是回放——点击语义保真)。 +func _replay_locked_click() -> void: + if _locked_click.is_empty() or not _row_queue.is_empty(): + return + var click: Dictionary = _locked_click + _locked_click = {} + _on_board_action(click["cell"], click["kind"]) const PACE_SEGMENT_GAP := 0.3 # 段间阶梯延迟(代拍板:白名单 0.2–0.4s 内取值,追认清单呈报) const PACE_ROW_GAP := 0.22 # 段内间隔(三轮 #3 上调 0.06→0.22——逐条演出可感知,配合数字浮现/状态脉冲) @@ -811,6 +822,10 @@ func _process(delta: float) -> void: # 无核心信号驱动——此处主动刷回挖掘态(去饱和解除/明细带收起/mode=dig)。 _was_pumping = false _refresh_presentations.call_deferred() + # 锁期点击重放(v3.1——玩家操作零丢失):最后一次锁期点击延迟一帧重入 + # _on_board_action(此刻队列已空=锁已解,正常受理)。 + if not _locked_click.is_empty(): + _replay_locked_click.call_deferred() return _was_pumping = true _row_pace_wait -= delta @@ -928,9 +943,6 @@ func _on_node_continue() -> void: 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)) @@ -940,8 +952,6 @@ func _on_choices_presented(candidates: Array) -> void: # 的当前节点非 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), "下一节点", @@ -950,9 +960,6 @@ func _on_choices_presented(candidates: Array) -> void: _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() @@ -1207,9 +1214,11 @@ func _on_board_action(cell: Vector2i, kind: String) -> void: # 结算演播期棋盘冻结(T17 用户明确令 v3——不可点/不可移/不可挖): # 锁面=排播队列活跃(纯队列判据;_row_pace_wait 有末行尾迹窗口不可作演播中标志)。 # 队列排空即解锁、无状态残留;HUD 按钮不在锁面(停手/明细回看各自通路,开启前 - # _flush_row_queue 自行排空)。v1 撞收尾链的教训对策:ui_click_probe 的排播期 stall - # 豁免随批(合法停顿态),Waiting 收尾等待期**不豁免**——probe 报卡即为真回归信号。 + # _flush_row_queue 自行排空)。 + # **点击不丢弃**(v3.1——用户实机「卡死」根因):锁期点击缓存最后一次,演播完边沿 + # (_process 泵 _was_pumping 分支)自动重放——玩家操作零丢失、体感=「演播一停动作就生效」。 if not _row_queue.is_empty(): + _locked_click = {"cell": cell, "kind": kind} return # 魔法道具选中态劫持(迭代 7 T4——两步交互第二步):点格=使用目标拾取,不落挖格; # 只劫持挖掘点击(move/flag 语义不冲突——选中期玩家仍可移动/插旗) diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index c11a005a..9b58f4ef 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -394,6 +394,9 @@ func _draw_graph(canvas: Control) -> void: var visited: Array = _runner.visited_ids() var cand_ids: Array = _runner.next_candidate_ids() # 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗) + # T17 修复:选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的 + # 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象) + var dim_passages: bool = not cand_ids.is_empty() for node in nodes: var from: Vector2 = pos[node] for tid in _out_ids(node, nodes): @@ -403,7 +406,7 @@ func _draw_graph(canvas: Control) -> void: var to: Vector2 = pos[target] var lit: bool = _id_of(node) == current_id and cand_ids.has(tid) var hovered: bool = lit and _hover_id == tid - var col := COL_PASSAGE_LIT if lit else COL_PASSAGE + var col := COL_PASSAGE_LIT if lit else (Color(COL_PASSAGE, 0.35) if dim_passages else COL_PASSAGE) var width := 4.0 if hovered else (3.0 if lit else 2.0) # 样张 v1 冻结:2/3/4(原 1.5 取整) _draw_passage(canvas, from, to, col, width) # 房间方块 @@ -511,6 +514,9 @@ func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, widt var b := to - Vector2(NODE_HALF.x, 0) if b.x < a.x: return - var mid_x := (a.x + b.x) / 2.0 + # T17 修复:垂直干线 x 按源节点 y 偏移——不同源的边不再共线重叠(旧 mid_x=中点使相邻层 + # 全部通道焊成一条干系,「金岔路+整列可走」误读的直接几何根源)。同源双边仍共线(同出发 + # 点语义正确),跨源分离度 = 源行距 × 0.2(L2 行距 ~58px → 偏移 ~12px 可辨)。 + var mid_x := (a.x + b.x) / 2.0 + (a.y - b.y) * 0.2 var pts := PackedVector2Array([a, Vector2(mid_x, a.y), Vector2(mid_x, b.y), b]) canvas.draw_polyline(pts, col, width) diff --git a/game/presentation/views/settings_overlay.gd b/game/presentation/views/settings_overlay.gd index c42d08f9..14fcdbe8 100644 --- a/game/presentation/views/settings_overlay.gd +++ b/game/presentation/views/settings_overlay.gd @@ -73,7 +73,8 @@ func _build() -> void: box.add_child(_fs_btn) # 减动效(battle.md §Transitions 承诺转正——时长减半,结算逐行兑现=信息载体不整体跳过) - _motion_btn = UiKit.button("减动效:关(动画时长减半——运动敏感/演示用)") + # 文案由 _sync_motion_text 按状态装配(构造串仅占位——_sync 启动即覆盖) + _motion_btn = UiKit.button("结算演出动画") _motion_btn.toggle_mode = true _motion_btn.toggled.connect(_on_motion_toggled) box.add_child(_motion_btn) @@ -91,7 +92,16 @@ func _sync() -> void: _fs_btn.set_pressed_no_signal(_store.fullscreen) _fs_btn.text = "全屏:%s" % ("开" if _store.fullscreen else "关") _motion_btn.set_pressed_no_signal(_store.reduced_motion) - _motion_btn.text = "减动效:%s(动画时长减半——运动敏感/演示用)" % ("开" if _store.reduced_motion else "关") + _sync_motion_text() + + +# T17 修复:文案随状态即时刷新(旧 bug=切换后文案恒「开」,玩家无法得知当前态)+语义命名 +# (「减动效」玩家读不懂——实称「结算演出动画」速度档)+操作提示内联(所见即所得)。 +func _sync_motion_text() -> void: + if _store.reduced_motion: + _motion_btn.text = "结算演出动画:快速(时长减半——运动敏感/演示用)· 点击改回完整" + else: + _motion_btn.text = "结算演出动画:完整 · 点击改为快速(时长减半——运动敏感/演示用)" func _on_volume_changed(v: float) -> void: @@ -111,6 +121,7 @@ func _on_motion_toggled(on: bool) -> void: _store.reduced_motion = on _store.apply() _store.save_settings() + _sync_motion_text() # T17 修复:切换即时刷新文案(旧版漏刷新=状态文字恒「开」) func _emit_closed() -> void: diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 52fc2a40..6206158e 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -347,13 +347,14 @@ func _smoke_adventure() -> void: var hp_before: int = int(p.get("hp_before", -1)) var hp_after: int = int(p.get("hp_after", -1)) # 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15 + # T17 账本口径修复随批:置满/回血封顶改生效上限口径(flat 后乘 TunePanel pct)——窗口同步 ×5(本段 pct=400) var heal_ok_i := false for extra in 16: - var m := int(_db.hero.max_hp) + hp_acc + extra + var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0)) if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before): heal_ok_i = true break - if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(_db.hero.max_hp) + hp_acc + 15: + if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 15) * 5.0)): heal_ok = false ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: @@ -486,7 +487,7 @@ func _smoke_adventure() -> void: var expect_acc: int = acc_shop + (int(_db.adventure.bonus_max_hp_flat) if picked2 == "max_hp" else 0) check(int(a8.bonus.get("max_hp", -1)) == expect_acc, "三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.bonus.get("max_hp", -1)), acc_shop, picked2]) - var max_eff8 := int(_db.hero.max_hp) + int(a8.bonus["max_hp"]) + var max_eff8: int = a8.effective_max_hp() # T17 口径修复随批:账本生效上限含 TunePanel pct(a8 已 set 400) check(int(a8.ledger["hero_hp"]) <= max_eff8, "通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8]) check(a8.continue_to_next_level(), "兑付后推进受理(下一关开始)") @@ -2861,17 +2862,17 @@ func _smoke_level_graph() -> void: var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result) check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)") - check(win_min.visited_ids == [0, 1, 6, 8, 9], "min 路径 = 0→1→6→8(商店零购离开)→9(实测 %s——U2 返工批模板 0)" % str(win_min.visited_ids)) + check(win_min.visited_ids == [0, 1, 3, 7, 8, 9], "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids)) check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices) var win_max := {} var max_seed := -1 for s in range(42, 300): win_max = autoplay.run_level(_db, s, 2, 200, 100, false, null, "normal", "max", 0) - if win_max.result == "victory" and win_max.visited_ids == [0, 5, 7, 8, 9]: + if win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9]: max_seed = s break - check(max_seed > 0 and win_max.result == "victory" and win_max.visited_ids == [0, 5, 7, 8, 9], - "L2·满配 max 路径(max 取最大编号候选)通关 = 0→5(重击手)→7→8(商店零购离开)→9(seed=%d,实测 %s %s——U2 返工批模板 0)" % [max_seed, win_max.result, str(win_max.visited_ids)]) + check(max_seed > 0 and win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9], + "L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(seed=%d,实测 %s %s——T17 出度≤2 拍板模板 0 重建)" % [max_seed, win_max.result, str(win_max.visited_ids)]) # ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言) var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42)) runner.set_hero_override(200, 100) @@ -2886,13 +2887,13 @@ func _smoke_level_graph() -> void: autoplay._autoplay_battle(runner, first, st, null) check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(), "首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)") - check(runner.next_candidate_ids() == [1, 2, 3, 4, 5], "入口岔路候选 = [1, 2, 3, 4, 5](U2 返工批模板 0 五岔环廊——层宽 5)") + check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)") check(not runner.pick_next_node(8) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)") check(runner.pick_next_node(2), "选编号 2(奖励点)受理") check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(), "到达奖励点:直通兑付(确认前不出选路口)") - check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [6, 7], - "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [6, 7]") + check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6], + "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]") check(runner.pick_next_node(6) and runner.current_battle() != null, "选编号 6(巨蜘蛛巢群)开战") # ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描 # (Package B 第 7 轮阶段拔高后锚行 45→50 退出绿档——13/12 全胜改多数胜 + 零踩雷恒要求, diff --git a/tools/t17_fanout2_rebuild.py b/tools/t17_fanout2_rebuild.py new file mode 100644 index 00000000..b88877b3 --- /dev/null +++ b/tools/t17_fanout2_rebuild.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""T17 出度≤2 模板池重建器(2026-10-01 用户拍板「节点出度最大为 2」——一次性内容批工具)。 + +背景:现行 L2/L3 十张网状模板存在出度 3-5 的节点(2026-09-29 层宽 4-5 规则的副作用—— +无出边上限约束时「前层广连全层」是层宽加宽的几何必然)。用户 T17 目检重申「一个节点 +只有后续两个分叉」→本工具按新规则全池重生成 L2/L3 十张: + +新规则两条件: +① 单节点出度 ≤2(用户 2026-10-01 拍板); +② BFS 层宽上限随出度约束调整为 4(数学:入口唯一下层宽序列 ≤1,2,4——原「上限 5」与 + 出度 ≤2 不相容:层 2 达 5 宽需前层 3 节点各出 2 边,回溯入口即违 ①)。 + +五张骨架(层宽序列 1-2-4-1-1 / 1-2-3-2-1 / 1-2-2-2-1——最大层宽 4 应用足): +类型序列保持现行(depth 0-9 = b,b,reward,rest,b,b,b,b,shop,boss——五张原表全同), +仅重连 next。L1 五链出度恒 1 零改动。 + +自校验(python 侧四检):出度 ≤2 / 层宽 ≤4 / 全节点可达 Boss / 入口唯一。 +Godot 端 LevelTable.validate(含本批新增第七检出度检)为最终闸。 +""" + +import json +import re +from collections import deque + +TRES = "game/data/tables/level.tres" + +# 五骨架:depth → (type, next)——类型序列与现行五张逐 depth 一致 +TYPE_SEQ = {0: "battle", 1: "battle", 2: "reward", 3: "rest", 4: "battle", + 5: "battle", 6: "battle", 7: "battle", 8: "shop", 9: "boss"} + +SKELETONS = { + "A": {0: [1, 2], 1: [3, 4], 2: [5, 6], 3: [7], 4: [7], 5: [7], 6: [7], 7: [8], 8: [9], 9: []}, + "B": {0: [1, 2], 1: [3, 5], 2: [4, 5], 3: [6], 4: [6, 7], 5: [7], 6: [8], 7: [8], 8: [9], 9: []}, + "C": {0: [1, 2], 1: [3, 5], 2: [4, 6], 3: [7], 4: [7], 5: [7], 6: [7], 7: [8], 8: [9], 9: []}, + "D": {0: [1, 2], 1: [3, 4], 2: [4, 5], 3: [6, 7], 4: [7], 5: [7], 6: [8], 7: [8], 8: [9], 9: []}, + "E": {0: [1, 2], 1: [3, 4], 2: [5], 3: [5, 6], 4: [6], 5: [7], 6: [7], 7: [8], 8: [9], 9: []}, +} + +# L2/L3 模板名 → 骨架(「(现行)」后缀钉模板 0 断言锚,随 A 位保持; +# L3 首张名「五岔主竖井」与 L2「五岔环廊」同构=w0 复制,同映射 A) +NAME_TO_SKEL = [ + ("五岔环廊", "A"), ("主竖井", "A"), ("四岔宽门", "B"), ("深层四岔", "C"), + ("宽门赏后置", "D"), ("双干汇流", "E"), +] +NEW_NAMES = { + "A": "宽廊双干(现行)", "B": "三干中廊", "C": "宽廊斜岔", + "D": "三干宽门", "E": "窄廊短支", +} + + +def build_nodes(skel): + nodes = [] + for d in range(10): + n = {"node_depth": d, "type": TYPE_SEQ[d], "next": list(SKELETONS[skel][d])} + nodes.append(n) + return nodes + + +def validate(nodes, label): + """python 侧四检:出度 / 层宽 / 可达 Boss / 入口唯一。""" + errs = [] + outs = {n["node_depth"]: n["next"] for n in nodes} + for d, nx in outs.items(): + if len(nx) > 2: + errs.append("%s 节点 %d 出度 %d > 2" % (label, d, len(nx))) + # 层宽(BFS) + layer = {0: 0} + q = deque([0]) + widths = {} + while q: + cur = q.popleft() + for t in outs.get(cur, []): + if t not in layer: + layer[t] = layer[cur] + 1 + q.append(t) + for d, lv in layer.items(): + widths[lv] = widths.get(lv, 0) + 1 + for lv, w in widths.items(): + if w > 4: + errs.append("%s 层 %d 宽 %d > 4" % (label, lv, w)) + # 可达 9 + seen = set(layer.keys()) + if 9 not in seen: + errs.append("%s Boss 9 不可达" % label) + if len(seen) != len(nodes): + errs.append("%s 有节点不可达:%s" % (label, sorted(set(outs) - seen))) + # 入口唯一(无入边恰一 = 0) + inbound = {t for nx in outs.values() for t in nx} + entries = [d for d in outs if d not in inbound] + if entries != [0]: + errs.append("%s 入口非唯一/非 0:%s" % (label, entries)) + return errs + + +def rebuild_line(line, level_tag): + """重写一行 level 的 templates(类型序列保持、next 按骨架重连)。""" + obj = json.loads(line.rstrip().rstrip(",")) + new_templates = [] + for tpl in obj["templates"]: + old_name = tpl["display_name"] + skel = None + for key, s in NAME_TO_SKEL: + if key in old_name: + skel = s + break + if skel is None: + raise SystemExit("%s: 未知模板名 %s" % (level_tag, old_name)) + # 类型序列保持校验(现行表与本工具 TYPE_SEQ 必须逐 depth 一致) + for n in tpl["nodes"]: + d = n["node_depth"] + if n["type"] != TYPE_SEQ[d]: + raise SystemExit("%s/%s depth %d 类型漂移:表 %s vs 工具 %s" + % (level_tag, old_name, d, n["type"], TYPE_SEQ[d])) + new_templates.append({"display_name": NEW_NAMES[skel], "nodes": build_nodes(skel)}) + obj["templates"] = new_templates + errs = [] + for tpl in new_templates: + errs += validate(tpl["nodes"], "%s/%s" % (level_tag, tpl["display_name"])) + if errs: + raise SystemExit("自校验失败:\n" + "\n".join(errs)) + return json.dumps(obj, ensure_ascii=False) + + +def main(): + with open(TRES, "r", encoding="utf-8") as f: + content = f.read() + out_lines = [] + lines = content.splitlines() + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith('{"level_index"'): + m = re.search(r'"level_index": (\d+)', stripped) + if m and m.group(1) in ("2", "3"): + rebuilt = rebuild_line(line, m.group(1)) + had_comma = line.rstrip().endswith(",") # 行尾逗号语义保持(tres 行序列) + out_lines.append(rebuilt + ("," if had_comma else "")) + continue + out_lines.append(line) + with open(TRES, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(out_lines) + "\n") + print("[t17_fanout2] L2/L3 十张模板已按出度≤2/层宽≤4 重建,自校验四检全过") + + +if __name__ == "__main__": + main()