iter41-卡1:核心数据面结构收敛批:85键全清——三族拆域+config_db归位容器+存档拆件+meta瘦身+adventure拆段+spawn束化;行为零变化,8新件在阈

This commit is contained in:
DKChan committed 2026-10-29 00:14:51 +08:00
1 parent bda377eb16
commit bc75dab615
157 files changed
+7453 -4288

No files matched your search

+36 -36
View File
@@ -1,23 +1,23 @@
# 美术资源依赖清单(Art Assets Checklist)
> **性质**:表现层升级批与战斗表现批的生产侧清单——把散在战斗表现 / HUD框架 / 关卡节点地图 / 敌人系统里的资产需求收拢成一张可执行的工单视图,**兼资产维度单一登记处**。**规范效力归各 owner 文档**(战斗表现、美术圣经、04-美术音频方向),本文只补「生产视角的需求规格」,冲突时以 owner 文档为准。
> **建立**:场景一(战斗画面呈现)讨论产物;原 asset-checklist.md 并入本文后删除,本文为两份清单合并后的唯一清单。历次修订明细见 git 提交史。
> **建立**:2026-09-18 场景一(战斗画面呈现)讨论产物。**2026-09-22 GDD 过期账清偿批**:原 asset-checklist.md 并入本文后删除,本文自此为两份清单合并后的唯一清单;同批修正敌人键集/徽章数量/样张状态等过期事实(详见 art-bible 的 git 提交史)。**2026-09-22 §5 角色设计落地批**:剪影引用迁 art-bible §5.1、护甲区分方案落 §5.3(清偿两处待设计项)、提示词卡 v2.0 重生成(详见 art-bible 的 git 提交史)。**2026-09-23 复用盘点批**:立素材站复用渠道与复用闸(§0);新增 §3.4–3.6 三条此前漏登记的需求(增益状态效果图标 / 营地与元进度件 / 字体);各条目标注来源渠道。**2026-09-23 §5 抽离批**:角色设计内容迁独立文档 `character-design.md`(art-bible §5 自此为引用存根,小节号不变),本文 §5.x 剪影/护甲引用经存根仍可达;主角特征批(直剑→挖掘铲、加旗朱背旗)顺带,主角形象锚见 character-design.md §5.4。**2026-09-23 §6–9 补全批**:art-bible 四章成文(§6 环境/§7 UI/§8 资产规范/§9 参考与反参考);本文 §0 交付格式与像素尺寸行、参考图行、§6 依赖链顺带改指 §8/§9 现行。
> **更新纪律**:各条目「现状」行顺带更新(未开始 / 候选已出 / 已裁决 / 已交付);做完不删条目,改状态。与待办清单的联动:本文是资产维度明细,待办清单只留指路存根。
## 0. 生产渠道、规格与风格宪法
**三条生产渠道**(第三条为**复用优先**——能从公开素材站轻改复用的不去生成,尽可能少生成):
**三条生产渠道**(2026-09-23 用户拍板补第三条:**复用优先**——能从公开素材站轻改复用的不去生成,尽可能少生成):
| 渠道 | 出什么 | 先例 |
|---|---|---|
| ♻ **素材站复用(轻改)** | UI 九宫格面板与按钮、字体、通用粒子与光效、通用图标、无缝纹理底噪 | 来源偏好 CC0(如 Kenney 全站、OpenGameArt 标 CC0 件、itch.io 免费包) |
| ✦ **ComfyUI(用户自跑)** | 角色、插画级素材;每个敌种/主角建议出 **2–3 版候选**供裁决,不直接出终稿 | 主角站姿样张(脚本管线产物,源文件已清理);提示词规格卡 art-prompts.md **v2.0**(按美术圣经 §5 现行重生成) |
| ♻ **素材站复用(轻改)** | UI 九宫格面板与按钮、字体、通用粒子与光效、通用图标、无缝纹理底噪 | 无(2026-09-23 本批立渠道);来源偏好 CC0(如 Kenney 全站、OpenGameArt 标 CC0 件、itch.io 免费包) |
| ✦ **ComfyUI(用户自跑)** | 角色、插画级素材;每个敌种/主角建议出 **2–3 版候选**供裁决,不直接出终稿 | 主角站姿样张(2026-09-16 脚本管线产物,源文件已清理);提示词规格卡 art-prompts.md **v2.0 已重生成**(2026-09-22,美术圣经 §5 落地后正序重生;v1.0 移除决策归状态记录 125) |
| ⚙ **脚本管线(代理出)** | 图标、格面、样张类精度资产 | 格面占位渲染与程序化徽标(现行) |
**通用规格纪律**:
- 交付格式 PNG 透明底;动画用序列帧或精灵表(SpriteFrames),帧数规格随样张定(候选档已成文 art-bible §8.4)。命名规范候选草案见 art-bible §8.3。
- **分辨率档位已定案:1280×800 + 1920×1080 两档**,窗口不可调、格边长按档位计算,后续调整走修订;像素画布基准见 **art-bible §8.2**(主角人设原图 48×72〔人设侧画布〕;站桩立绘/战斗状态图源高 48;棋盘小人 32×32 二头身;敌种逐键源档 36–94;整数倍缩放纪律;终值归样张冻结,待办清单已登记)。
- 交付格式 PNG 透明底;动画用序列帧或精灵表(SpriteFrames),帧数规格随样张批定(候选档已成文 art-bible §8.4)。命名规范候选草案见 art-bible §8.3。
- **分辨率档位已定案(2026-09-22 用户确认):1280×800 + 1920×1080 两档**,窗口不可调、格边长按档位计算,后续调整走修订;像素画布已定暂定基准(**art-bible §8.2**:主角原图与战斗状态图 48×72、棋盘小人 32×32 二头身〔用户暂定〕;敌种立绘三档 48/64/96 候选;1× 基准整数倍缩放纪律;1920 档缩放策略与帧数规格待样张批,待办清单已登记)。
- 程序化占位是现行结构冻结条款:资产未就位的槽位保持占位渲染,不空窗。
- **复用闸(♻ 渠道四道验收,一道不过不入包)**:① 色板量化——改色对齐 art-bible §4.1 token,不保留原包杂色;② 吸色检查——无 HSV 色相 300°–345°(紫赤禁入带);③ 灰度/色弱可辨——去色相后仍可辨且不与语义件混淆;④ 授权干净——CC0 优先,CC-BY 署名记入设置/关于页,license 不明的一律不用。
@@ -27,14 +27,14 @@
|---|---|
| 一句话视觉规则 | 明快的地牢,轻快的扫荡,仔细的推理——**明亮像素幻想,全年龄,非暗黑**,角色 2~3 头身 |
| 原则一「预告上格」 | 格内绘制优先级 **数字(含色牌)> 小人 > 封印印记 > 变异纹带 > 装饰变体 > 底色**;任何元素禁止遮挡数字字形区;类型微光为底效泛光层,不入遮挡栈 |
| 原则二「明亮为皮,明度分档为骨」 | 暖倾向;数字亮墨直画已开格底(亮墨 L≥0.75 / 已开格底 L 0.08–0.14,art-bible §4.1),对比 ≥4.5:1;色牌 `#F5EAD3` 为复杂格底后备(牌上深墨 L≤0.027);未开格底 L≥0.30、HSV 饱和 ≤0.55 |
| 原则二「明亮为皮,明度分档为骨」(v3.0) | 暖倾向;数字亮墨直画已开格底(候选亮墨 L≥0.55 / 已开底 L 0.20–0.32,样张 v3 冻结),对比 ≥4.5:1;色牌 `#F5EAD3` 降级为复杂格底后备(牌上深墨 L≤0.027);未开格底 L≥0.30、HSV 饱和 ≤0.55 |
| 原则三「一砖一世界」 | 变体优先换色板不加新资产;危险状态必须**形状+颜色双通道**(色弱灰度可辨) |
| 类型预读单通道(§4.6.3) | 未开格 = **类型常显微光**(五组色带 §4.2:芥末黄/黄绿/青/紫罗兰/绯红,色相+明度双码,避 300°–345° 禁入带,样张冻结);不呈现极性、不泄危险格与安全格(角嵌形状通道已不存在——微光格只留微光) |
| 类型预读单通道(§4.6.3) | 未开格 = **类型常显微光**(五组色带 §4.2:芥末黄/黄绿/青/紫罗兰/绯红,色相+明度双码,均候选值,避 300°–345° 禁入带,样张批冻结);不呈现极性、不泄危险格与安全格〔角嵌形状通道 2026-09-28 退役——用户裁微光格只留微光〕 |
| 剪影哲学(§5.1) | **所有角色自带轮廓通道:亮色描边+落影底盘**,识别不依赖背景;四类原型见各条目 |
| 语义色规则(§4.2) | 红只说危险(危险红 `#A8321F` 单档)、金只说收益(`#F0A828`)、蓝只说己方(`#2E86DE`);封印印记现行色 = 封印黑 `#000000`(色史归 art-bible §4.1);**紫赤 `#8F2F6A` 为 Boss 独占,HSV 色相 300°–345° 全场禁入带**(微光群伤组绯红为类型先验色带域,例外见 §4.2) |
| 语义色规则(§4.2) | 红只说危险(危险红 `#A8321F` 单档——旗朱已退役归封印色〔现行封印黑 `#000000`〕,2026-09-26 封印紫批→2026-09-28 封印黑批)、金只说收益(`#F0A828`)、蓝只说己方(`#2E86DE`)、**紫赤 `#8F2F6A` 为 Boss 独占,HSV 色相 300°–345° 全场禁入带**(微光群伤组绯红为类型先验色带域,例外见 §4.2) |
| UI 形状语法(§7.1,§3.3 存方向指导) | 半拟物:面板=石板圆角矩形(与地砖同圆角率),按钮=可按的砖,仪式确认=圆形蜡封;数据区直角细线无纹理 |
**参考图**:`design/art/references/` 三款×2 张(Into the Breach / Moonlighter / Peglin,与圣经点名池一一对应;gungeon 已退场,反面条款见 art-bible §9.2;正面取/弃与反面条款已成文 art-bible §9,反面图证待素材生成后增补)。
**参考图**:`design/art/references/` 三款×2 张(Into the Breach / Moonlighter / Peglin,与圣经点名池一一对应;gungeon 已退场〔2026-09-22 裁决〕;正面取/弃与反面条款已成文 art-bible §9,反面图证待素材生成后另批增补)。
---
@@ -50,25 +50,25 @@
### 1.2 战斗一屏样张(双舞台,2–3 版候选)⚙格面骨架 + ✦角色剪影候选
- **要什么**:按双舞台布局(战斗画面与扫雷图同屏常驻)出的整屏试验图。要锁的四件事:**角色剪影方向 / 动画方案 / 战斗空间 / 敌人层绘制优先级插入位置**;**敌种呈现形态已裁决:全站桩式(主角同站桩、状态小图标挂血条下),卡片式已弃,正式样张按站桩形态单版出样**。
- **要什么**:按双舞台布局(战斗画面与扫雷图同屏常驻)出的整屏试验图。要锁的四件事:**角色剪影方向 / 动画方案 / 战斗空间 / 敌人层绘制优先级插入位置**;~~顺带裁决**敌人卡形态:卡片式 vs 站桩式(无框立绘+底座)**——两版候选各出一版~~——**已提前裁决(2026-09-20 用户看对照样张拍板):全站桩式(主角同站桩、状态小图标挂血条下),卡片式退役,正式样张按站桩形态单版出样**(对照样张存档 `production/mockups/`)。
- **风格/规格**:敌人剪影按 art-bible §5.1 四类原型出(见 2.2);战斗舞台背景=明亮地窟,紫赤仅 Boss;像素尺寸按已定案两档换算(见 §0 通用规格纪律)。
- **验收**:战斗表现「一屏样张的验收标准」(已成文)。
- **联动**:样张未过,**不启动任何终稿资产与动画设计**(文档明文的排期硬前置);格边长的计算口径不在本清单——现行唯一出处是 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,HUD框架只判装板可行性(固定两档分辨率、窗口不可调、格子量增加则格边长相应变小;**档位值已定案 1280×800 + 1920×1080**)。
- **联动**:样张未过,**不启动任何终稿资产与动画设计**(文档明文的排期硬前置);格边长的计算口径不在本清单——现行唯一出处是 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,HUD框架只判装板可行性(2026-09-18 用户拍板:固定两档分辨率、窗口不可调、格子量增加则格边长相应变小;**档位值已定案 1280×800 + 1920×1080**)。
---
## 2. P1 —— 角色级(样张通过后开工,ComfyUI)
> 提示词规格卡 art-prompts.md 现行为 **v2.0**(主角三态+八敌种,逐资产自包含完整提示词,整段可直贴)。
> 提示词规格卡 art-prompts.md 已于 2026-09-22 按「圣经 §5 落地后重生成」拍板重写为 **v2.0**(主角三态+八敌种,逐资产自包含完整提示词,整段可直贴);v1.0 移除决策归状态记录 125 完成登记(production/session-state/active.md)。
### 2.1 主角战斗小人
- **要什么**:待机 / 攻击 / 受击 三态起步(移动态样张后定);透明底 PNG。
- **风格/规格**:小人原型——**窄、直立、对称,轮廓凹凸 ≤2**;2~3 头身两档下剪影均成立;主色小人蓝 `#2E86DE`(候选值,样张冻结终稿);格内占位最窄、不吞数字位。
- **风格/规格**:小人原型——**窄、直立、对称,轮廓凹凸 ≤2**;2~3 头身两档下剪影均成立;主色小人蓝 `#2E86DE`(候选值,样张批冻结终稿);格内占位最窄、不吞数字位。
- **来源**:✦ ComfyUI(prompt 卡 v2.0 §2);剪影原型是设计载荷(宽≥高/凹凸计数/甲档/紫赤独占),素材站无对的,不复用。
- **现状**:站姿样张已有先例(源文件已清理);战斗三态未出。
### 2.2 八敌种形象(键集事实源 = `data/tables/enemy.tres` 行键)
### 2.2 八敌种形象(键集事实源 = `data/tables/enemy.tres` 行键,2026-09-22 核)
| 敌种(enemy_id) | 剪影原型(§5.1) | 护甲类型 | 风格要点 |
|---|---|---|---|
@@ -81,11 +81,11 @@
| 钩爪恐魔 hook_horror | 普通怪(钩爪/多足轮廓注意凹凸计数控制) | light | 第三关群行捕食者,小体量可辨 |
| 灰矮人石君王 duergar_king | **Boss:锐角与不对称**,体量最大 | heavy | 第三关 Boss;紫赤色相独占+形状独占双通道 |
- **通用规格**:亮色描边+落影底盘轮廓通道硬规则;透明底 PNG;每敌种 2–3 版候选;护甲类型视觉区分方案已落 art-bible §5.3(候选方案,样张冻结终稿)。
- **通用规格**:亮色描边+落影底盘轮廓通道硬规则;透明底 PNG;每敌种 2–3 版候选;护甲类型视觉区分方案已落 art-bible §5.3(候选方案,样张批冻结)。
- **来源**:✦ ComfyUI(prompt 卡 v2.0 §3,八敌种+L2 熊地精变体共 9 卡);理由同 §2.1——不复用。
- **联动**:显示名以 `data/tables/enemy.tres` 与敌人系统文档为准(L2 熊地精=bugbear 复用,显示名熊地精系)。
- **联动**:显示名以 `data/tables/enemy.tres` 与敌人系统文档为准(L2 熊地精=bugbear 复用,显示名熊地精系——2026-09-17 用户拍板)。
### 2.3 意图准备动画三档
### 2.3 意图准备动画三档(2026-09-18 用户拍板)
- **要什么**:用**动作动画**替代文字徽标预告敌方下一拍,三档:
1. **普通攻击 = 常态准备动画**——待机姿态本身就是预示,不另做动作;
@@ -94,7 +94,7 @@
- **风格/规格**:三档在**灰度/剪影层也可辨**(双通道纪律);每档动画时长与结算节拍对齐(预告时序 = 下一拍、挖掘期全程可见)。
- **联动**:HUD框架「意图徽标不预告技能名与数值」条款由本拍板承接为动画承载(文字与技能名彻底退出);战斗表现「特殊技两型动画」输入清单按三档改写;映射按 enemy_id 全局恒定(跨 run 禁重掷);契约锚 = art-bible §4.6.4 意图姿态行。
- **来源**:✦/⚙ 自产——三档表演层挂在我们自己的精灵图上,动画本体不可复用;通用蓄力/施法光效素材(♻)仅可作参考与粒子底料,过复用闸。
- **现状**:占位符号已落地(普通攻击「—」/ ⚔ / ✦ / 重击类「蓄力」,技能名退出徽标);终稿动画待样张后开工。
- **现状**:占位符号已落地(2026-09-18:普通攻击「—」/ ⚔ / ✦ / 重击类「蓄力」,技能名退出徽标);终稿动画待样张后开工。
---
@@ -102,7 +102,7 @@
### 3.1 格面资源徽章 19 枚(九型 × 正负 18 枚 + 清图槽 1 枚)⚙
- **要什么**:九家族(增伤/减伤/回复/爆裂/削弱/穿刺/魔法/行动/GP)各正负一枚 + 清图群伤一枚。**键名录读 `reward.tres` 与地图战斗资源 GDD 九型字典,本文不抄键名**(防再腐烂:gp 组键名 gp_coin/gp_lose 以配置为准)。
- **要什么**:九家族(增伤/减伤/回复/爆裂/削弱/穿刺/魔法/行动/GP)各正负一枚 + 清图群伤一枚。**键名录读 `reward.tres` 与地图战斗资源 GDD 九型字典,本文不抄键名**(防再腐烂——2026-09-22 勘误先例:gp 组键名 gp_coin/gp_lose 以配置为准)。
- **风格/规格**:图标键契约 art-bible §4.6(冻结);**极性视觉 = 金族满框(正向)/ 红族缺框(负向)**,不新增语义色;徽章=附属小图标,非完整凸形(不构成第二焦点),栈位在旗之下、变异纹理之上;负向九枚 weight=0 纯字典,用于踩雷反噬结算呈现。
- **来源**:⚙ 脚本管线(键契约件,程序化最稳);通用图标包只作造型参考,不直接入包。
- **现状**:程序化占位徽标(文本/符号),够试玩不够观感。
@@ -112,34 +112,34 @@
- **要什么**:四类节点图标(战斗 / 奖励 / 恢复 / Boss)+ 图版行进器形象(棋子)。
- **风格/规格**:容器走石板圆角语法;Boss 节点可用紫赤;奖励节点可用奖励金;恢复节点绿=自然生机(零逻辑语义,仅氛围);行进器=小人同族蓝。
- **来源**:♻ 素材站复用优先——通用地图/桌游图标(如 Kenney 件)改色量化,过复用闸;⚙ 兜底。行进器与小人同族,宜 ✦/⚙ 与主角同批出。
- **联动**:正式节点图 UI 设计未成文(登记待办:排秘宝内容设计后与美术设计同期待裁决);HUD框架义务清单漏「关卡节点地图」行,同时补。
- **联动**:正式节点图 UI 设计未成文(登记待办:排秘宝内容批后与美术批同期待拍板);HUD框架义务清单漏「关卡节点地图」行,顺带补。
- **现状**:程序化占位,够试玩。
### 3.3 雷 / 封印印记 / 数字格面像素化 ⚙
- **要什么**:格子样张通过后按冻结参数出全套砖面资产(含图类型换色板组)。
- **来源**:⚙ 脚本管线自产——未开/已开/危险格/变异纹带是扫雷专属语义(角嵌纹样已不存在),素材站没有对的;砖面**纹理底噪**可 ♻ 用 CC0 无缝石/土纹理作脚本管线的纹理输入,量化到色板(混合路线)。
- **来源**:⚙ 脚本管线自产——未开/已开/危险格/变异纹带是扫雷专属语义〔角嵌纹样 2026-09-28 退役〕,素材站没有对的;砖面**纹理底噪**可 ♻ 用 CC0 无缝石/土纹理作脚本管线的纹理输入,量化到色板(混合路线)。
- **前置**:§1.1 格子样张通过(**未跑**)。
- **现状**:程序化绘制,可读性达标(对比硬指标已是现行实现依据),观感待升级。
### 3.4 增益状态效果图标 9 枚 ♻优先/⚙兜底
### 3.4 增益状态效果图标 9 枚 ♻优先/⚙兜底(2026-09-23 新登记,此前清单漏登记)
- **要什么**:HUD 状态槽挂图(敌人血条下小图标),九行键族——**键名录读 art-bible §4.6.1 与 `status_effects.tres`,本文不抄键名**(键名录指路纪律,同 §3.1)。
- **风格/规格**:♻ 素材站复用优先——通用增益/减益图标包改色为墨族,按 §4.6.1 极性列统一叠极性视觉(有利=金族满框/有害=红族缺框,art-bible §4.6 通道分配全键族通用),过复用闸四道(灰度可辨=色弱冗余通道);⚙ 兜底程序化。一键一纹样、映射按 effect_id 全局恒定禁换图(图标键契约)。
- **现状**:已接入(2026-09-24,定稿九枚入 HUD 状态槽,合并 71ae51c;色板量化豁免=用户逐键验收的自产件,非外采)。
### 3.5 营地场景图
### 3.5 营地场景图(2026-10-22 重写;原「营地与元进度件·三柜台」条目作废)
- **要什么**:营地场景图 9 张——`scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件)。钥石顶栏图标沿用现役 `icon_keystone`。
- **来源/规格**:**✦ 插画级**(作者自跑远程生图管线);烛光黄 `#F6C667` + 木色 `#8A5A34`(art-bible §6.2);2× 源档出图、最近邻降采样到 1280×800;后处理=抠底/降采样/色板量化/描边统一。**每张出图须登记模型名+分辨率+seed+对话/日期**。
- **排期**:营地交互重做**美术自立批**(不占迭代位;UI 贴图重构批已搁置)。终稿落 `game/assets/art/scene/`,消费常量 `UiKit.TEX_CAMP_*`。
- **现状**:全占位。
### 3.6 字体(全局 UI 像素字,已定稿单套)
### 3.6 字体(全局 UI 像素字,已定稿单套)(2026-09-23 新登记;2026-10-24 用户裁收束定稿)
- **要什么**:全局 UI 字体一套。**已定稿:缝合像素字体 Fusion Pixel 单套单字重即终稿**——拉丁字符与数字沿用其自带拉丁字形,不另接拉丁像素字专件(旧拉丁候选 Kenney Fonts 已弃)、不上第二个字重;现行已接入 `ui_font_fusion_pixel_zh_hans.ttf`。
- **来源/规格**:♻ 复用开源——缝合像素字体(Fusion Pixel,SIL OFL 1.1,授权总表在册);墨色走主墨系(art-bible §7.2)。换字体是全局决策;样张用字即此定稿件,过样张闸第 1 项(数字可读性 ≥4.5:1,禁以换字形逃避)。
- **现状**:✅ 已定稿在役(现状即终稿)。
- **要什么**:全局 UI 字体一套。**已定稿(2026-10-24 用户裁):缝合像素字体 Fusion Pixel 单套单字重即终稿**——拉丁字符与数字沿用其自带拉丁字形,不另接拉丁像素字专件(旧拉丁候选 Kenney Fonts 退役不再接入)、不上第二个字重;现行已接入 `ui_font_fusion_pixel_zh_hans.ttf`。
- **来源/规格**:♻ 复用开源——缝合像素字体(Fusion Pixel,SIL OFL 1.1,授权总表在册);墨色走主墨系(art-bible §7.2)。换字体是全局决策;样张批用字即此定稿件,过样张闸第 1 项(数字可读性 ≥4.5:1,禁以换字形逃避)。
- **现状**:✅ 已定稿在役(2026-09-27 用户拍板接入;2026-10-24 用户裁现状即终稿)。
---
@@ -150,7 +150,7 @@
| 受击反馈 / 死亡表现 / 顺位标记 | 敌人系统登记的资产需求**下限三类** | 死亡=血条归零后**灰化+碎裂退场,禁止瞬间消失**;顺位标记需在敌多时可读 | ⚙/✦ 挂自产精灵——死亡灰化碎裂是语义硬规则,不可整包复用;烟尘碎屑粒子可 ♻ 改色 |
| 特殊技动画终稿 | 并入 §2.3 三档意图动画,不另起体系 | 攻/防两型旧口径由三档替代 | ✦/⚙ 自产(同 §2.3) |
| 连锁坍塌显形演出 | 连锁展开触发时的连锁演出 | 与音频方向同批配对登记;「坍塌显形」=连锁展开的玩家可见名 | ♻ 粒子/碎块改色 + ⚙ 坍塌结构自产 |
| 封印特效 | 该动作的呈现名 = **封印**(art-bible §3.2):**施印瞬间特效 + 印记常显形态**两件 | 印记旗面走封印黑 `#000000`(现行封印色,色史归 art-bible §4.1);特效走运动+色相(§2:状态/情绪不走明度,空间光照可走);现行占位 = 封印黑「旗」字形;印记常显形态可与 §3.3 旗面像素化合并出 | ✦/⚙ 自产(精灵+粒子,烟尘光点可 ♻ 改色) |
| 封印特效(插旗动作) | 插旗的呈现名 = **封印**(2026-09-25 术语批,art-bible §3.2):**施印瞬间特效 + 印记常显形态**两件 | 印记旗面走封印黑 `#000000`(旗朱→封印紫→封印黑,2026-09-28 封印黑批);特效走运动+色相(§2:状态/情绪不走明度,空间光照可走——v3.0);现行占位 = 封印黑「旗」字形;印记常显形态可与 §3.3 旗面像素化合并出 | ✦/⚙ 自产(精灵+粒子,烟尘光点可 ♻ 改色) |
| 弹道/退场等动画密度 | 战斗表现输入清单,方案未定 | 样张锁动画方案后启动 | ♻ 通用弹道/光点/消散粒子改色为主 |
---
@@ -158,10 +158,10 @@
## 5. 明确不在本清单
- **音乐音效**:文档定稿「MVP 占位即可,核心循环验证后再定」。
- **秘宝图标**:内容归 Alpha 秘宝整体重设计,内容未定不能先做;渠道届时按 ♻/✦ 另评。
- **秘宝图标**:内容归 Alpha 秘宝整体重设计批(状态记录 112 口径),内容未定不能先做;渠道届时按 ♻/✦ 另评。
- **封面图/宣传图**:Alpha 后另批,不阻塞切片;需项目角色出镜,素材站合成观感差,宜 ✦。
- **地形变异专属纹理**:六类型已定稿,纹带需求 = 2 纹形(伤害轴刺缺形/信息轴遮蔽形)× 3 图类型色板,**样张后可排产**;形状通道规则见 art-bible §3.2。
- **精英/Boss 护甲类型视觉区分**:已落 art-bible §5.3(候选方案:甲面覆盖率×甲材质色三档,样张冻结终稿)。
- **地形变异专属纹理**:~~变异表为空,纹带种类待地形变异批定夺~~——**六类型已定稿(2026-09-20 地形变异批)**,纹带需求 = 2 纹形(伤害轴刺缺形/信息轴遮蔽形)× 3 图类型色板,**样张批后可排产**;形状通道规则见 art-bible §3.2。
- **精英/Boss 护甲类型视觉区分**:~~待设计项~~ **已落 art-bible §5.3**(候选方案:甲面覆盖率×甲材质色三档,样张批冻结)。
## 6. 执行顺序(依赖链)
@@ -170,16 +170,16 @@
│
战斗一屏样张(1.2) ──> 角色终稿(2.1/2.2) ──> 意图动画三档(2.3) ──> 演出类(P4)
│
└─> 敌种呈现形态裁决 ✅已完成(全站桩,卡片式已弃)
└─> 敌人卡形态裁决 ✅已完成(2026-09-20 拍板全站桩,卡片式退役)
徽章(3.1)/节点图(3.2)/效果图标(3.4) —— 独立线,可随时插队
♻ 复用件(3.2/3.4/3.5/3.6/演出粒子) —— 不依赖样张,可随时插队(入包闸 = §0 复用四道)
字体(3.6)接入归表现批;营地场景图(3.5)归营地交互重做美术自立批
分辨率档位 ✅已定案(1280×800 + 1920×1080,2026-09-22 用户确认);art-bible §8 资产规范 ✅已成文(2026-09-23)——像素基准/画布/命名/帧数候选值就位,终值归样张批冻结
```
## 附、已评素材包授权与结论登记(♻ 复用渠道)
## 附、已评素材包授权与结论登记(2026-09-26 并档,♻ 复用渠道)
> 来源:素材站复用三批复审记录(批目录已归档清退,本表为唯一仓内登记);详录已随复审出盘,沿革查 git 提交史。
> 来源:tile-reuse-2026-09-23 / ui-reuse-2026-09-23 / terrain-walls-2026-09-25 三批复审记录(批目录已归档清退,本表为唯一仓内登记);详录已随复审批出盘,沿革查 git 提交史。
### 授权总表(闸④预检结论)
@@ -198,5 +198,5 @@
| Golden UI 系列 | CC-BY-SA | — | **闸④不过,未下载** |
| Gemini 生图(用户自跑) | 自产 | 无 | **terrain_ 高墙瓦片材质源(现行定稿)** |
### 复用四闸(现行)
### 复用四闸(状态记录 130,现行)
色板量化 / 禁入带吸色 / 灰度可辨 / 授权干净(SA 不可、来路不明不可)。
+300 -305
View File
@@ -1,12 +1,12 @@
# 美术圣经(Art Bible)— Dungeon Sweeper
> 项目:Dungeon Sweeper(扫雷 × 自动战斗 闯关 Roguelike)· 状态:**第 1–9 节定稿**;现行为 v3.21,历次修订明细见 git 提交史
> 项目:Dungeon Sweeper(扫雷 × 自动战斗 闯关 Roguelike)· 状态:**第 1–9 节定稿**(§6 环境/§7 UI/§8 资产规范/§9 参考与反参考已于 2026-09-23 v1.8 按 D1/D2/D4/D5 框架成文;D3=§5 角色已于 v1.6 落地、v1.7 抽离独立文档;框架 2026-09-22 用户拍板,原登记于中间产物修订指导,经裁决不入库,明细归状态记录 125);现行为 v3.20,历次修订明细见 git 提交史
>
> **Art Director Sign-Off(AD-ART-BIBLE)**:历次批均未签核——此为 Lean/Solo 模式(非 PHASE-GATE)下的现行治理状态;full 门禁签核时机待用户表态。
> 风格基调:地牢题材但**非暗黑风**——轻快明亮的幻想冒险风,全年龄;像素风;角色 2~3 头身。
> 参考池(用户审阅选定,三者相融):**Into the Breach**(网格信息分层;雷图棋盘呈现基准 = 其战棋地块质感 × 正俯视角,见 §3.2)× **Moonlighter**(明亮像素地牢质感)× **Peglin**(明快全年龄地牢色彩);参考截图存于 `design/art/references/`(三款 × 2 张,与点名池一一对应);Enter the Gungeon 不作参考,反面条款见 §9.2。
> 约束:本圣经是**纯表现层文档**——明亮轻快是美术风格,不是玩法成分。圣经禁止引入、暗示或预设任何玩法语义,不改变任何 GDD 已锁机制;凡表现层需要玩法钩子才能表达之处(如完美封印检测、Boss 预警),一律标注「待 GDD 定夺」,圣经只规定"若有,则如何呈现"。GDD 机制与圣经冲突时,机制语义以 GDD 为准,呈现方案以本圣经为准。
> **条款验收标注纪律(全文书写规范)**:条款尾部按三档标注验收方式——**`验收:机检〈判据>`**(可量化,脚本能断言)/**`验收:目检〈判据>`**(机器测不了,但看什么、怎么算有明确判据)/**`验收:方向性`**(气质与方向原则,不承担验收);推导源尾注「←」继续表达推导来源,与验收标注同句并存时,验收标注放句末;数值类条款推荐「**待定现用**」态(现行即执法、修订走登记,释义见 §4.1)
> **Art Director Sign-Off (AD-ART-BIBLE)**:历次批均 SKIPPED(v1.8 §6–9 补全亦 SKIPPED)— Lean/Solo mode(非 PHASE-GATE;§6–9 已成文,full 门禁签核时机待用户表态)
> 风格基调(2026-09-10 用户定稿):地牢题材但**非暗黑风**——轻快明亮的幻想冒险风,全年龄;像素风;角色 2~3 头身
> 参考池(用户审阅选定,三者相融):**Into the Breach**(网格信息分层;雷图棋盘呈现基准 = 其战棋地块质感 × 正俯视角,见 §3.2)× **Moonlighter**(明亮像素地牢质感)× **Peglin**(明快全年龄地牢色彩);参考截图存于 `design/art/references/`(三款×2 张,与点名池一一对应;gungeon 两张经 2026-09-22 裁决退场;正面取/弃与反面条款已成文 §9,反面图证待素材生成后另批增补)
> 约束(2026-09-10 用户强调):本圣经是**纯表现层文档**——明亮轻快是美术风格,不是玩法成分。圣经禁止引入、暗示或预设任何玩法语义,不改变任何 GDD 已锁机制;凡表现层需要玩法钩子才能表达之处(如完美封印检测、Boss 预警),一律标注「待 GDD 定夺」,圣经只规定"若有,则如何呈现"。GDD 机制与圣经冲突时,机制语义以 GDD 为准,呈现方案以本圣经为准
> **条款验收标注纪律(2026-10-06 P2-4 用户拍板,全文书写规范)**:条款尾部按三档标注验收方式——**`←机检: <断言>`**(脚本可自动判定)/ **`←目检: <判据>`**(机器测不了,但看什么、算过有明确判据)/ **`←方向性`**(气质与方向原则,不承担验收);**新条款一律按此书写**,存量口号型条款分章补标(约 55%,待办清单挂账「口号型条款机检化批」分章推进、每章交付抽验);数值类条款推荐「**待定现用**」态(现行即执法、修订走登记,释义见 §4.1)
---
@@ -14,261 +14,255 @@
### 1.1 一句话视觉规则
**「明快的地牢,轻快的扫荡,仔细的推理。」** 验收:方向性
**「明快的地牢,轻快的扫荡,仔细的推理。」**
三拍定调:世界观(明快的地牢)× 玩法动作(轻快的扫荡)× 玩法心智(仔细的推理)。本句为气质宣言,不承担裁决;视觉决策歧义时,依 1.2 三条支撑原则依次裁决(每条附设计测试),原则全部不过的方案否决。本节是美术圣经的最高裁决层,后续章节与本节冲突时,以本节为准。
三拍定调:世界观(明快的地牢)× 玩法动作(轻快的扫荡)× 玩法心智(仔细的推理)。本句为气质宣言,不承担裁决;视觉决策歧义时,依 1.2 三条支撑原则依次裁决(每条附设计测试),原则全部不过的方案否决。本节是美术圣经的最高裁决层,后续章节与本节冲突时,以本节为准。
### 1.2 支撑原则
**原则一|预告上格**(取自 Into the Breach;锚定 P1 推理的掌控感、P2 规划的延迟兑现)
**原则一|预告上格**(取自 Into the Breach;锚定 P1 推理的掌控感、P2 规划的延迟兑现)
继承其格内信息分层与"攻击预告画在目标格上"的范式:一切威胁、伤害与结算预告必须直接绘制在雷图格本体上,禁止只进侧栏或悬浮提示;格内绘制优先级固定:**数字(含色牌) > 小人标记 > 旗 > 变异纹带 > 装饰变体 > 底色**,任何元素禁止遮挡数字。格面类型预读 = 类型微光单通道,色相+明度双码(色带见 §4.2、键契约见 §4.6;徽章小图标族保留给库存与结算明细;类型微光为底效泛光层,不入遮挡栈、不遮任何元素)。与 ITB 之别:只取其信息骨架,弃其冷暗军事色调。验收:目检〈预告画在雷图格本体上,任何元素不遮挡数字〉
继承其格内信息分层与"攻击预告画在目标格上"的范式:一切威胁、伤害与结算预告必须直接绘制在雷图格本体上,禁止只进侧栏或悬浮提示;格内绘制优先级固定:**数字(含色牌) > 小人标记 > 旗 > 变异纹带 > 装饰变体 > 底色**,任何元素禁止遮挡数字(格面类型预读 = 类型微光单通道〔色相+明度双码,2026-09-28 角嵌退役〕,色带见 §4.2、键契约见 §4.6;徽章小图标族保留给库存与结算明细;类型微光为底效泛光层,不入遮挡栈、不遮任何元素)。与 ITB 之别:只取其信息骨架,弃其冷暗军事色调。
*范围注:本原则约束"已存在的信息往哪里画",不新增信息种类;预告类信息以战斗侧 GDD 实际定义的为准(如 P2 已锁的结算来源标注),GDD 未定义的预告不因本条而存在。*
*范围注(2026-09-10):本原则约束"已存在的信息往哪里画",不新增信息种类;预告类信息以战斗侧 GDD 实际定义的为准(如 P2 已锁的结算来源标注),GDD 未定义的预告不因本条而存在。*
*设计测试:当某信息歧义于"画进格子"还是"收进面板"时,画进格子。*
*设计测试:当某信息歧义于"画进格子"还是"收进面板"时,画进格子。*
**原则二|明亮为皮,明度分档为骨**(取自 Moonlighter、Peglin;锚定 P1 可读性前提、P4 探险氛围)
**原则二|明亮为皮,明度分档为骨**(取自 Moonlighter、Peglin;锚定 P1 可读性前提、P4 探险氛围;2026-09-29 v3.0 修订——原「色牌为骨」降级,病根与修法见审视报告(已退役,git fbaadb8)病根 3)
取 Moonlighter 的暖质地砖与亲和冒险者气质、Peglin 的明快全年龄洞穴色彩:全局暖倾向、无压抑。可读性由**格底明度分档**承载——已开格(数字所在格)明度走世界底层档(§2 明度分层;格态语义见《排雷玩法》;美术侧据此未开格走受光墙顶档、已开格走挖出空间沉暗档,与 §8.5 图底分离同轴),亮族数字墨直接画在格底上,直画对比 ≥4.5:1 恒成立。**数字色牌为后备手段**——仅复杂格底(物件纹样/变异纹带/微光叠加致格底对比不足)时垫牌后备(画法 §3.3、色值 §4.1)。格子本体美术放开:纹理、材质、装饰变体、类型纹样皆可入画;格底参数域见 §4.1 硬指标(分档明度域、饱和上限),仍避 Boss 禁入色相带。与参考之别:ITB 以半透明压暗板承载信息;本作格底功能分档(信息可读性服务,属 §2 明度分层允许域),情绪压暗仍禁。验收:机检〈1–8 全档数字对所在已开格底对比 ≥4.5:1〉
取 Moonlighter 的暖质地砖与亲和冒险者气质、Peglin 的明快全年龄洞穴色彩:全局暖倾向、无压抑。可读性由**格底明度分档**承载——已开格(数字所在格)明度走世界底层档(§2 明度分层;格态语义见《排雷玩法》;美术侧据此未开格走受光墙顶档、已开格走挖出空间沉暗档,与 §8.5 图底分离同轴),亮族数字墨直接画在格底上,对比硬指标不变(≥4.5:1 恒成立)。**数字色牌降级为后备手段**——仅复杂格底(物件纹样/变异纹带/微光叠加致格底对比不足)时垫牌兜底(画法 §3.3、色值 §4.1)。格子本体美术自此放开:纹理、材质、装饰变体、类型纹样皆可入画;格底参数域见 §4.1 硬指标(分档明度域、饱和上限),仍避 Boss 禁入色相带。与参考之别:ITB 以半透明压暗板承载信息;本作格底功能分档(信息可读性服务,属 §2 明度分层允许域),情绪压暗仍禁。
*设计测试:当格子美术与数字可读性冲突时,先调格底明度档,复杂格底垫色牌后备,不动格子画。*
*设计测试:当格子美术与数字可读性冲突时,先调格底明度档,复杂格底垫色牌兜底,不动格子画。*
**原则三|一砖一世界**(融合 Moonlighter 地砖观;锚定 P3 风险与收益同源、P4 地牢下探隐喻)
**原则三|一砖一世界**(融合 Moonlighter 地砖观;锚定 P3 风险与收益同源、P4 地牢下探隐喻)
格子即地牢地砖:未开 = 覆尘完整砖,已开 = 碎裂裸露,雷 = 被惊动的巢穴(格态定义与玩家说法见《排雷玩法》),图类型(board_type)= 同款地砖换色板。图类型与变异格的视觉差异一律先做色板变体,不做新资产;一切危险状态必须"形状 + 颜色"双通道冗余,色弱可辨。像 Moonlighter 之处在砖缝质感与暖色道具,但装饰禁止伪装成逻辑差异——玩家须一眼分清"好看"与"有雷"。验收:目检〈危险状态均为形状+颜色双通道,色弱模拟下可辨〉
格子即地牢地砖:未开 = 覆尘完整砖,已开 = 碎裂裸露,雷 = 被惊动的巢穴(格态定义与玩家说法见《排雷玩法》),图类型(board_type)= 同款地砖换色板。图类型与变异格的视觉差异一律先做色板变体,不做新资产;一切危险状态必须"形状 + 颜色"双通道冗余,色弱可辨。像 Moonlighter 之处在砖缝质感与暖色道具,但装饰禁止伪装成逻辑差异——玩家须一眼分清"好看"与"有雷"。
格子同时是**内容舞台**,格与格的美术表达按格内实态分层多样化(画法细则 §3.2)。已开格按当格结果整格表达——产资源格嵌对应家族物件纹样(地图战斗资源九类型,§4.6.3)、纯安全格呈现干净裸土、危险格巢穴更凶。未开格在覆尘完整砖之上叠加**装饰变体**(裂纹/碎石斑/物件级点缀等氛围层),用途是破除大面积同砖平铺的重复感。物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**:形与信息元素(数字/小人/旗/纹样)形状语言隔离,灰度下不与任何逻辑元素同形。零信息纪律:随机分布、与雷/安/类型零相关、禁止构成可读线索、分布每板一次跨轮不复掷。焦点预算约束:装饰不构成完整凸形、不抢读(§3.4);数量配额按图类型定(候选 3–5 种,样张已冻结,§1.4)。未开格的类型预读画为**类型微光单通道**(色相+明度双码;仍只表达类型,不泄危险格与安全格——呈现契约见 §4.6.3;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节)。验收:目检〈物件级装饰与逻辑元素形状隔离;装饰不构成完整凸形〉
格子同时是**内容舞台**,格与格的美术表达按格内实态分层多样化(画法细则 §3.2):已开格按当格结果整格表达——产资源格嵌对应家族物件纹样(地图战斗资源九类型,§4.6.3)、纯安全格呈现干净裸土、危险格巢穴更凶;未开格在覆尘完整砖之上叠加**装饰变体**(裂纹/碎石斑/物件级点缀等氛围层;2026-09-29 v3.0 物件级解禁——物件级装饰〔小石块/陶片/骨殖等〕须走**专属装饰形状家族**:形与信息元素〔数字/小人/旗/纹样〕形状语言隔离,灰度下不与任何逻辑元素同形;零信息纪律不变——随机分布、与雷/安/类型零相关、禁止构成可读线索、分布每板一次跨轮不复掷;焦点预算约束不变——装饰不构成完整凸形、不抢读,§3.4;数量配额按图类型定,候选 3–5 种,样张 v3 冻结;用途=破除大面积同砖平铺的重复感);未开格的类型预读画为**类型微光单通道**(色相+明度双码;仍只表达类型,不泄危险格与安全格——呈现契约见 §4.6.3;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节)。
*设计测试:当新图类型或新变异格需要新视觉时,先换色板加形状,再谈新资产;当格子多样化与"好看/有雷"之辨冲突时,砍装饰,不动逻辑通道。*
*设计测试:当新图类型或新变异格需要新视觉时,先换色板加形状,再谈新资产;当格子多样化与"好看/有雷"之辨冲突时,砍装饰,不动逻辑通道。*
### 1.4 格子样张测试(验收标准)
> 立意:扫雷数字可读性与暗色氛围相冲突,由一次格子样张测试定风格,具体形态由样张测试定稿。**样张** = 一张按本圣经参数画成的格子风格试验图,是砖面形态的定稿闸——样张通过前,§3.2/§4.1 的一切参数只是候选,通过后冻结为制作标准。
**样张内容清单**(一图齐备,缺项即无效):未开格(覆尘完整砖,含至少两种装饰变体)、已开格(碎裂裸露,含 0 格与 1–8 全档数字)、危险格(被惊动的巢穴)、旗、小人两态(独立占格 + 与数字同格——后者须验"边框高亮不覆盖文本")、图类型色板变体组(§4.3 四槽换色,至少两套)、数字呈现组(已开格底亮墨直画组 + 复杂格底色牌后备组——素底/装饰变体底/变异纹带底至少三种格底上各一组数字)、已开格结果表达组(纯安全格 + 至少两类型物件纹样)、**类型微光呈现组**(至少三个类型的微光在纯砖格上呈现——样张为静态定格测试图,非运行时行为承诺,实际可见时机见《HUD框架》「未开格类型微光」节;含两两相邻格微光并列;微光格内零角嵌/箭头/加号类附加图形)。验收:目检〈样张图含清单全部组项〉
### 1.4 格子样张测试(验收标准,2026-09-13 成文)
**验收项按序判定,第一项不过 = 整张否决(数字可读性定风格,风格服从可读)**:
> 立意(自概念期 brainstorm 内联):扫雷数字可读性 × 暗色氛围的冲突 → 由一次格子样张测试定风格;具体形态由样张测试定稿。**样张** = 一张按本圣经参数画成的格子风格试验图,是砖面形态的定稿闸——样张通过前,§3.2/§4.1 的一切参数只是候选,通过后冻结为制作标准。
1. **第一验收项|数字可读性(硬指标,可测)**:1–8 全档数字对其所在**已开格底**直画 WCAG 对比 ≥4.5:1(参数域见 §4.1:已开格底走世界底层档、棋盘数字墨亮族);复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)启用**色牌后备**,牌墨组合 ≥4.5:1(色牌与牌上深墨族数字的定值见 §4.1),牌缘灰度可分;0.5× 缩放抽检数字仍可辨;纹带/裂纹/巢穴纹/物件纹样禁止侵入数字字形占位区(砖心留白,§3.2)。验收:机检〈1–8 数字对已开格底直画与牌墨组合对比均 ≥4.5:1〉
2. **格态三形可辨(灰度通道)**:未开/已开/雷三态转灰度截图后仍可逐格分类;明度差(墙亮/空间暗)与碎裂形同为合法分类通道,双通道冗余(§3.2 完整度叙事)。装饰变体禁止制造新的分类特征——同一格态下带与不带变体的灰度差异禁止成为三态分类线索。色弱模拟(去色相)下旗、巢穴纹不与裂纹混淆。**五组类型微光互辨**:色弱玩家的类型主通道 = 微光,色相+明度双码,微光以明度梯度承载分组(组内不细分,属 §2 信息面层内梯度);微光禁止侵入数字字形占位区,明度梯度禁止压破格底明度档(三态分类不受扰)。验收:目检〈三态灰度逐格可分类;五组微光互辨;色弱模拟下旗、巢穴纹不与裂纹混淆〉
3. **焦点预算成立(§3.4)**:任意一格至多一个完整凸形;数字所在格无第二焦点。验收:目检〈任一格至多一个完整凸形,数字所在格无第二焦点〉
4. **图类型换皮抽测(§4.3)**:变体组中数字墨亮族三档、封印黑、危险红、奖励金、小人蓝、数字色牌(后备件)、类型微光五组恒定不动;变体格底仍过第 1、2 项指标。验收:机检〈换皮后不变槽色值恒定,变体格底仍过第 1、2 项〉
**样张内容清单**(一图齐备,缺项即无效):未开格(覆尘完整砖,含至少两种装饰变体)、已开格(碎裂裸露,含 0 格与 1–8 全档数字)、危险格(被惊动的巢穴)、旗、小人两态(独立占格 + 与数字同格——后者须验"边框高亮不覆盖文本")、图类型色板变体组(§4.3 四槽换色,至少两套)、数字呈现组(2026-09-29 v3.0:已开格底亮墨直画组 + 复杂格底色牌后备组——素底/装饰变体底/变异纹带底至少三种格底上各一组数字)、已开格结果表达组(纯安全格 + 至少两类型物件纹样)、**类型微光呈现组**(至少三个类型的微光在纯砖格上呈现——样张为静态定格测试图,非运行时行为承诺,实际可见时机见《HUD框架》「未开格类型微光」节;含两两相邻格微光并列;微光格内零角嵌/箭头/加号类附加图形——2026-09-28 用户裁决)。
**判定与回退**:第 1 项不过时按原则二设计测试回退——先调格底明度档,复杂格底垫色牌后备,禁止缩字号换字形逃避;第 2–4 项不过时修形不修数。样张通过 = 砖面形态与色板冻结;新增图类型/变异色板须重跑本测试(§4.3)。执行时机:正式砖面资产开工前;《HUD框架》以本节为引用依据。验收:机检〈样张机检断言集全绿后方可冻结砖面形态与色板〉
**验收项按序判定,第一项不过 = 整张否决(数字可读性定风格,风格服从可读)**:
**整屏样张闸**:格子样张验"格",整屏样张验"屏"——战斗屏/关卡屏/菜单屏/营地屏各出一张整屏样张,验收四项:①全部文字(含菜单/说明/tooltip/按钮)对其直接背景 ≥4.5:1(WCAG AA),大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;②明度分层三档带成立(信息面层 > 世界中层 > 世界底层,层间灰度可分);③整屏焦点预算(§3.4:首焦唯一——数字区/立绘/主按钮,无第二焦点竞争;负空间服务焦点,不属空窗);④文字禁直接压纹理底(垫容器或罩层)。验收顺序 = **方向稿目检先行**(防"机检过闸但气质否决"式返工),目检过后机检冻结。执行时机 = 屏级视觉落码开工前;过闸前一切屏级视觉为占位临时态(§7.1 完成条款)。验收:机检〈整屏样张四项断言全绿,且目检先行过审〉
1. **第一验收项|数字可读性(硬指标,可测;2026-09-29 v3.0 改格底分档承载)**:1–8 全档数字对其所在**已开格底**直画 WCAG 对比 ≥4.5:1(§4.1 参数域:已开格底走世界底层档,候选 L 0.08–0.14;棋盘数字墨亮族,L≥0.75 候选——均待样张 v3 冻结〔v3.0 原拟 L 0.20–0.32/L≥0.55 经 WCAG 实测不可达 4.5:1,候选带随样张批修正〕);复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)启用**色牌后备**,牌墨组合 ≥4.5:1(色牌 L≥0.70、牌上数字用深墨族),牌缘灰度可分;0.5× 缩放抽检数字仍可辨;纹带/裂纹/巢穴纹/物件纹样禁止侵入数字字形占位区(砖心留白,§3.2)。
2. **格态三形可辨(灰度通道)**:未开/已开/雷三态转灰度截图后仍可逐格分类(§3.2 完整度叙事;v3.0 起明度差〔墙亮/空间暗〕为合法分类通道,与碎裂形双通道冗余);装饰变体禁止制造新的分类特征——同一格态下带与不带变体的灰度差异禁止成为三态分类线索;色弱模拟(去色相)下旗、巢穴纹不与裂纹混淆;**五组类型微光互辨**——色弱玩家的类型主通道 = 微光(色相+明度双码,2026-09-28 角嵌退役后微光以明度梯度承载分组(组内不细分),§2 信息面层内梯度),微光禁止侵入数字字形占位区、明度梯度禁止压破格底明度档(三态分类不受扰)。
3. **焦点预算成立(§3.4)**:任意一格至多一个完整凸形;数字所在格无第二焦点。
4. **图类型换皮抽测(§4.3)**:变体组中数字墨亮族三档、封印黑、危险红、奖励金、小人蓝、数字色牌(后备件)、类型微光五组恒定不动;变体格底仍过第 1、2 项指标。
**样张现状**:格子样张已冻结,冻结凭据 = git 提交 7a847c7(机检报告)与 §4.1/§8.2 定值表;样张 v3 产物原件已丢失、未入 git。现行冻结态:色板与帧数档维持冻结;数字形制 = 亮墨三档直画 + 色牌后备(值见 §4.1);新增图类型/变异色板须重跑本节测试。验收:机检〈冻结项定值均以 §4.1/§8.2 对应 owner 表为准〉
**判定与回退**:第 1 项不过 → 按原则二设计测试回退——先调格底明度档,复杂格底垫色牌兜底,禁止缩字号换字形逃避;第 2–4 项不过 → 修形不修数。样张通过 = 砖面形态与色板冻结;新增图类型/变异色板须重跑本测试(§4.3)。执行时机:正式砖面资产开工前;《HUD框架》以本节为引用依据(2026-09-07 门禁:样张测试并入 art-bible 执行)。
**整屏样张闸(2026-09-29 v3.0 新增)**:格子样张验"格",整屏样张验"屏"——战斗屏/关卡屏/菜单屏/营地屏各出一张整屏样张,验收四项:①全部文字(含菜单/说明/tooltip/按钮)对其直接背景 ≥4.5:1(WCAG AA;大字〔≥18px 或加粗档〕底线 3:1、目标 4.5:1);②明度分层三档带成立(信息面层 > 世界中层 > 世界底层,层间灰度可分);③整屏焦点预算(§3.4:首焦唯一——数字区/立绘/主按钮,无第二焦点竞争;负空间服务焦点,不属空窗);④文字禁直接压纹理底(垫容器或罩层)。闸形 = **方向稿目检先行**(防"机检过闸但气质否决"式返工),目检过后机检冻结。执行时机 = 屏级视觉落码开工前;过闸前一切屏级视觉为占位临时态(§7.1 完成条款)。现行 §1.4 格子样张机检断言集中涉色牌/明度档条目随样张 v3 重跑同步改造(登记待办清单)。
> **样张 v2 记录(2026-09-28,四冻结生效)**:机检 16/16 全绿(扩展断言集:A3 图底分离 Δ≥55/F6 墙格顶面立面双明度带 Δ≥18/F7 整板悬崖 DEPTH_PX 与游戏渲染常量同源/A6 观感密度梯度均值 ≥4.70=v1 实测 4.09×1.15)。**四冻结**——①砖面形态:方案甲 21 瓦片+高墙感 D 投影档+整板悬崖(§3.2 高墙瓦片体系条定稿参数);②色板:小人蓝 #2E86DE/接触带微光 #D6ECFF/类型微光五组定稿(§4.1/§4.2);③动画方案:§8.4 帧数档冻结;④数字形制:色牌形制定稿(#F5EAD3 素色直角小牌+Fusion Pixel+字号 max(18, ceil(格边×0.5))+墨三档,§7.4)。产物=`artwork/mockups/sample-batch-v2-2026-09-28.png`,机检报告=git 提交 f9b0765;气质终审=批末用户目检(闸形 A,2026-09-27 拍板)。**(v3.0 注:四冻结之①砖面形态的数字承载面与④数字形制随色牌降级解冻,待样张 v3 重冻;②色板③动画维持冻结不变)**
> **样张 v3 冻结记录(2026-09-30,迭代 9 T1 批 A——四冻结①④重冻生效,圣经升 v3.1)**:机检 17/17 全绿,断言定值两件落锁——A3b 图底分离**定值 Δ≥30**(方向稿实测 38;v2 阈值 55 系色牌制地板 L0.036 时代校定,与格底分档带数学冲突退役)/A4 尺寸基线**硬等冻结**(立绘 46×48+棋盘小人源 32×32;资产升档批换代须显式解冻重校);F6 沿用 v3.4 双明度带口径 Δ≥18。**重冻两件**——①砖面形态:E 格高墙 30% 承接词表扩展(南邻墙地面格南部 30% 承接墙面、墙顶纹理**原亮度**直贴零暗档零阴影、(b)-(c) 连接处无边框〔渲染格线跳段〕、连续墙体内侧不出立面;南向厚度面+v3.4 暗档 D 投影保留)+**格底明度分档**(已开格底=世界底层档 L≈0.10,候选带 0.08–0.14;落码程序归一,地板素材重产归资产批)+装饰物件级解禁(专属形状家族,零信息纪律不变)/④数字形制:**亮墨三档直画**(1–3 冷亮 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8,直画已开格底,实测对比 6.0–6.3:1)+**色牌后备**(#F5EAD3 仅复杂格底——物件纹样/装饰变体格——垫牌兜底,牌上深墨族 12:1)。②色板③动画维持 v2 冻结不变。产物=`artwork/mockups/sample-batch-v3-final-2026-09-30.png`,机检报告=git 提交 7a847c7【冻结】;气质终审=2026-09-29 用户验收豁免(新 v3.5 方向稿验收通过,状态记录 281)。
---
## 2. 氛围与情绪(Mood & Atmosphere)
> 本节服从第 1 节宣言「明快的地牢,轻快的扫荡,仔细的推理」。能量值为全作统一 0–10 刻度:0 静谧,10 沸腾。
> 本节服从第 1 节宣言「明快的地牢,轻快的扫荡,仔细的推理」。能量值为全作统一 0–10 刻度:0 静谧 → 10 沸腾。
>
> **明度分层(硬规则)**:明度按视觉层分带——**信息面层**(数字/UI 数据/预告/语义标识)最高、**世界中层**(格底顶面/角色/物件)居中、**世界底层**(背景/纵深/远景/已开空间)可更低;层间明度差服务空间纵深与光照(Moonlighter 式洞穴层次)。**情绪明暗禁令**:明度变化禁止用于**情绪表达**——画面禁止靠压暗/渐晕阴沉下来;roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**状态表达不受本禁令约束**:状态可用明度、色相、饱和、运动、元素任一手段承载。**允许域**:空间纵深、光照氛围、形体塑造。类型微光的明度梯度(§4.2)属信息面层内梯度,不扰世界层明度档。验收:目检〈情绪表达不走压暗/渐晕;三档明度带层间灰度可分〉
> **明度分层(硬规则,2026-09-29 v3.0 替代「明度恒定」)**:明度按视觉层分带——**信息面层**(数字/UI 数据/预告/语义标识)最高、**世界中层**(格底顶面/角色/物件)居中、**世界底层**(背景/纵深/远景/已开空间)可更低;层间明度差服务空间纵深与光照(Moonlighter 式洞穴层次)。**禁令保留(2026-10-22 缩窄至情绪域)**:明度变化禁止用于**情绪表达**——画面不得靠压暗/渐晕阴沉下来;roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**状态表达不受本禁令约束**:状态可用明度、色相、饱和、运动、元素任一手段承载。roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**允许域**:空间纵深、光照氛围、形体塑造(砖缘立体面既有例外上升为通则)。类型微光的明度梯度(§4.2)属信息面层内梯度,不扰世界层明度档。
### 2.1 挖掘阶段(核心状态,占时最长)
### 2.1 挖掘阶段(核心状态,占时最长)
- **主情绪**:棋手式清明专注——视线始终被数字牵引,画面无一处杂音抢焦;久推不生视觉疲劳
- **色彩性格**:暖石色低饱和打底(世界中层),高饱和高亮只属于信息面层(数字与封印印记)
- **氛围形容词**:清爽、井然、可读、耐心
- **能量**:2/10
- **承载元素**:可挖域**接触带呼吸微光**——光画在英雄格周界的接触面上(正交邻墙 = 整条边,斜向邻墙 = 共享角点 + 向两侧各延伸半条边;画法 §3.2,蓝族色 §4.2)——画面里唯一在动的,是玩家的思路。验收:目检〈挖掘期仅接触带微光在动,棋盘与数字静止〉
- **主情绪**:棋手式清明专注——视线始终被数字牵引,画面无一处杂音抢焦;久推不生视觉疲劳
- **色彩性格**:暖石色低饱和打底(世界中层),高饱和高亮只属于信息面层(数字与封印印记)
- **氛围形容词**:清爽、井然、可读、耐心
- **能量**:2/10
- **承载元素**:可挖域**接触带呼吸微光**——光画在英雄格周界的接触面上(正交邻墙=整条边,斜向邻墙=共享角点+向两侧各延伸半条边,画法 §3.2,蓝族色 §4.2)——画面里唯一在动的,是玩家的思路
### 2.2 轮末自动战斗结算
- **主情绪**:延迟兑现的清算爽感——扫雷之因逐行兑现为战果;完美封印(概念定义与兑付结构见《地图战斗资源》)的高光兑现——结算带高光行,以其为全场喝彩级的最高反馈档
- **色彩性格**:情绪不走明度(§2 禁令),色彩转向金橙主导、饱和拉高;数字色转译为弹道色沿行推进
- **氛围形容词**:酣畅、递进、一气呵成
- **能量**:7/10,随连击爬升
- **承载元素**:兑现光带逐行横扫棋盘,开格化箭雨、印记化暴击砸向敌人,因果全程可见可归因。验收:目检〈兑现光带逐行横扫、因果可见可归因;情绪表达不走压暗〉
- **主情绪**:延迟兑现的清算爽感——扫雷之因逐行兑现为战果;完美封印(概念定义与兑付结构见《地图战斗资源》)的高光兑现——结算带高光行,以其为全场喝彩级的最高反馈档
- **色彩性格**:情绪不走明度(§2 禁令),色彩转向金橙主导、饱和拉高;数字色转译为弹道色沿行推进
- **氛围形容词**:酣畅、递进、一气呵成
- **能量**:7/10,随连击爬升
- **承载元素**:兑现光带逐行横扫棋盘,开格化箭雨、印记化暴击砸向敌人,因果全程可见可归因
### 2.3 清图奖励与换图
- **主情绪**:短促丰收——宝箱揭盖式的喜悦,三五秒即收,不拖节奏
- **色彩性格**:全屏饱和峰值:金粒喷泉与彩纸迸发,饱和瞬时拉满后回落;明度不动(§2 情绪禁令)
- **氛围形容词**:丰收、轻快、干脆
- **能量**:8/10,尖峰后速降
- **承载元素**:最后一格碎砖中喷出的 GP 喷泉,叮当落入 UI 钱袋。验收:目检〈金粒喷泉短促收敛,饱和峰值后回落〉
- **主情绪**:短促丰收——宝箱揭盖式的喜悦,三五秒即收,不拖节奏
- **色彩性格**:全屏饱和峰值:金粒喷泉与彩纸迸发,饱和瞬时拉满后回落;明度不动(§2 情绪禁令)
- **氛围形容词**:丰收、轻快、干脆
- **能量**:8/10,尖峰后速降
- **承载元素**:最后一格碎砖中喷出的 GP 喷泉,叮当落入 UI 钱袋
### 2.4 战斗胜利
- **主情绪**:从容自信——「我算对了」的盖章确认;克制而非狂欢,胜利属于头脑
- **色彩性格**:暖白与金,不用红与爆炸
- **氛围形容词**:笃定、舒展、体面
- **能量**:6/10
- **承载元素**:棋盘中央落下一枚金色蜡封,碎砖自动拼出胜利纹章。验收:方向性
- **主情绪**:从容自信——「我算对了」的盖章确认;克制而非狂欢,胜利属于头脑
- **色彩性格**:暖白与金,不用红与爆炸
- **氛围形容词**:笃定、舒展、体面
- **能量**:6/10
- **承载元素**:棋盘中央落下一枚金色蜡封,碎砖自动拼出胜利纹章
### 2.5 战斗失败(冒险终结)
### 2.5 战斗失败(冒险终结)
- **主情绪**:温柔的惋惜与「再来一次」的邀请——有分量、不惩罚,像合上一册冒险日志
- **色彩性格**:情绪不走明度(§2 禁令),色相转暖灰掺暮蓝;全作禁血红与黑场,唯此一拍最静
- **氛围形容词**:惋惜、安静、余温、可再来
- **能量**:1/10
- **承载元素**:小人坐在碎砖上摇头掸灰,落下的封印印记逐枚化作光点收回行囊。验收:目检〈无血红与黑场;色相转暖灰掺暮蓝〉
- **主情绪**:温柔的惋惜与「再来一次」的邀请——有分量、不惩罚,像合上一册冒险日志
- **色彩性格**:情绪不走明度(§2 禁令),色相转暖灰掺暮蓝;全作禁血红与黑场,唯此一拍最静
- **氛围形容词**:惋惜、安静、余温、可再来
- **能量**:1/10
- **承载元素**:小人坐在碎砖上摇头掸灰,落下的封印印记逐枚化作光点收回行囊
### 2.6 Boss 与精英遭遇
- **主情绪**:被注视的郑重感——预告更华丽,但预告上格规则不变,掌控仍在玩家
- **色彩性格**:格底保持基准,Boss 专属紫赤为全场唯一占用的色相——郑重感来自色相独占,不来自压暗
- **氛围形容词**:郑重、锋利、仪式感
- **能量**:5/10,静水深流,结算时才沸腾
- **承载元素**:Boss 影子先于本体爬过棋盘,扫过处短暂留下紫赤足迹(纯视觉预告;是否绑定机制由战斗侧 GDD 定夺)。验收:目检〈紫赤足迹为纯视觉预告;高饱和紫赤仅 Boss 战占用〉
- **主情绪**:被注视的郑重感——预告更华丽,但预告上格规则不变,掌控仍在玩家
- **色彩性格**:格底保持基准,Boss 专属紫赤为全场唯一占用的色相——郑重感来自色相独占,不来自压暗
- **氛围形容词**:郑重、锋利、仪式感
- **能量**:5/10,静水深流,结算时才沸腾
- **承载元素**:Boss 影子先于本体爬过棋盘,扫过处短暂留下紫赤足迹(纯视觉预告;是否绑定机制由战斗侧 GDD 定夺)
### 2.7 下探过渡(关卡间)
### 2.7 下探过渡(关卡间)
- **主情绪**:纵深的期待——「下面还有一层」的探险引力,如降落时一瞬失重
- **色彩性格**:整层色板随图类型(board_type)递进换挡,以色相层进预告「更深更险」;世界层明度带允许随深度整体微移(空间纵深表达,非情绪;候选,待整屏样张冻结)
- **氛围形容词**:纵深、期待、换气、进阶
- **能量**:4/10
- **承载元素**:镜头沿竖井匀速下移,两侧砖层色板渐变,预演下一层主色。验收:目检〈下探过渡中砖层色板随图类型渐变〉
- **主情绪**:纵深的期待——「下面还有一层」的探险引力,如降落时一瞬失重
- **色彩性格**:整层色板随图类型(board_type)递进换挡,以色相层进预告「更深更险」;世界层明度带允许随深度整体微移(空间纵深表达,非情绪——候选,整屏样张冻结)
- **氛围形容词**:纵深、期待、换气、进阶
- **能量**:4/10
- **承载元素**:镜头沿竖井匀速下移,两侧砖层色板渐变,预演下一层主色
### 2.8 菜单与元进度(存档/永久强化)
### 2.8 菜单与元进度(存档/永久强化)
- **主情绪**:可靠的营地感——成长可见、可点、可规划,归来即蓄势
- **色彩性格**:全作最暖:烛光黄与木色,中等饱和,无棋盘式高对比
- **氛围形容词**:温暖、笃实、归属
- **能量**:3/10
- **承载元素**:**一张可点击的营地场景图**——远景天空与远山,中景木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与半开的营地大门,近景中央的篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板;点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。钥石余额常驻画面顶部的一行小 HUD 栏。场景构成、七物件绑定与呈现位见 §6.2。验收:目检〈点营地物件进入对应界面,钥石余额常驻顶部 HUD 栏〉
- **主情绪**:可靠的营地感——成长可见、可点、可规划,归来即蓄势
- **色彩性格**:全作最暖:烛光黄与木色,中等饱和,无棋盘式高对比
- **氛围形容词**:温暖、笃实、归属
- **能量**:3/10
- **承载元素**:**一张可点击的营地场景图**——远景天空与远山,中景木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与半开的营地大门,近景中央的篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板;点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。钥石余额常驻画面顶部的一行小 HUD 栏。场景构成、七物件绑定与呈现位见 §6.2
### 2.9 状态间区分度自检
### 状态间区分度自检
推理与兑现靠「谁在动 + 饱和」区分:挖掘时棋盘静止,唯光标微光在动、高饱和只在数字上;结算时棋盘自体运动,金色光带横扫、饱和扩散至全屏。胜利与失败以**色相**即判(金蜡封对暮蓝收印),Boss 战以紫赤色相是否登场作标记。验收:目检〈任两场景依「谁在动+饱和」或色相可即时区分〉
推理与兑现靠「谁在动 + 饱和」区分:挖掘时棋盘静止,唯光标微光在动、高饱和只在数字上;结算时棋盘自体运动,金色光带横扫、饱和扩散至全屏。胜利与失败以**色相**即判(金蜡封对暮蓝收印),Boss 战以紫赤色相是否登场作标记。
自动战斗过场化与「我算对了」缺高光的风险对策见 2.2;全年龄失败表现对策见 2.5。
(风险呼应:自动战斗过场化 → 2.2 可见兑现与爆发支点;「我算对了」缺高光 → 2.2 爆发支点;全年龄失败表现 → 2.5 有分量不压抑。)
---
## 3. 形状语言(Shape Language)
> 规则尾注「←」标注推导来源:P1–P4=游戏支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2 各原则「锚定」注);原一/二/三=1.2 支撑原则;§2.x=第 2 节条目。本节只定画法;凡涉具体变异、状态种类之处一律待 GDD 定夺,本节只规定"若有,则如何画"。
> 规则尾注「←」标注推导源:P1–P4=游戏支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻——支柱编号源自概念期 brainstorm,现行 GDD 树不再单列,语义内联于此并见 §1.2 各原则「锚定」注);原一/二/三=1.2 支撑原则;§2.x=第 2 节条目。本节只定画法;凡涉具体变异、状态种类之处一律待 GDD 定夺,本节只规定"若有,则如何画"。
**总纲|圆角为世,直线为信**:本作形状二分——一切**物件**用圆角方形(地砖、角色体块、UI 面板共用同一圆角率),一切**逻辑信息**用直线直角(数字框、准线、结算光带)。玩家凭"圆还是方"即分清世界与信息。验收:目检〈物件呈圆角方形、逻辑信息呈直线直角〉 ←气质宣言;原一
**总纲|圆角为世,直线为信**:本作形状二分——一切**物件**用圆角方形(地砖、角色体块、UI 面板共用同一圆角率),一切**逻辑信息**用直线直角(数字框、准线、结算光带)。玩家凭"圆还是方"即分清世界与信息。←气质宣言;原一
### 3.1 道具点剪影(角色剪影见 [character-design.md](character-design.md) §5.1)
### 3.1 道具点剪影(角色剪影已迁 §5.1)
角色四类 archetype(小人/普通怪/精英/Boss)与轮廓通道硬规则见 [character-design.md](character-design.md) §5.1;本节保留唯一非生物剪影:
角色四类 archetype(小人/普通怪/精英/Boss)与轮廓通道硬规则已迁 **§5.1**(2026-09-22 v1.6 迁入圣经 §5.1;2026-09-23 v1.7 随 §5 抽离迁独立文档 `character-design.md`,经 §5 存根可达);本节保留唯一非生物剪影:
- **道具点**:唯一非生物剪影——直线几何体(晶体/箱体),禁有机曲线;风险与收益在剪影层即为两类。验收:目检〈道具点均为直线几何体,无有机曲线〉 ←P3
- **道具点**:唯一非生物剪影——直线几何体(晶体/箱体),禁有机曲线;风险与收益在剪影层即为两类。←P3
### 3.2 环境几何
- **雷图棋盘呈现基准**:战棋地块质感 × 正俯视角——取 ITB 参考图(`references/itb_1.jpg`)的地表质感骨架:地块纹理多样、起伏体块感、岛缘/砖缘厚度、格上装饰小立牌;但相机一律正上方垂直向下,格线屏轴对齐,**禁等距/斜视角与菱形格**;形状语言仍守总纲圆角方形。砖缘厚度与岛缘立体面属形体塑造,不属状态/情绪表达,不受 §2 情绪明暗禁令约束(属 §2 明度分层允许域;顶面格底仍守 §4.1 参数域)。装饰小立牌属 ITB 参考的观感元素,本作不采用;装饰变体走氛围层纪律(纹理级/物件级形状家族,见下方装饰变体条),零信息纪律以原则三为准。验收:目检〈棋盘正俯视、格线屏轴对齐、无菱形格〉 ←原二;原三;§2
- **地砖**:一切环境体由同一圆角方形衍生:未开 = 完整凸砖,已开 = 同形缺角碎裂,雷 = 碎裂加巢穴纹(危险格玩家说法见《排雷玩法》);只以"完整度"叙事,不为状态新造形。验收:目检〈三格态仅以完整度区分,无新造状态形〉 ←原三;P4
- **装饰变体(未开格氛围层)**:覆尘完整砖之上叠加裂纹/碎石斑/物件级点缀等装饰。物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**(与信息元素形状语言隔离、灰度下不与任何逻辑元素同形),数量配额按图类型定(候选 3–5 种,样张已冻结,§1.4),一律残缺、内凹、低对比而自然后退(§3.4);零信息纪律(分布随机、与雷/安/类型零相关、灰度下禁止制造新分类特征、分布每板一次跨轮不复掷)以原则三为准,此处不赘。验收:目检〈物件级装饰不与任何逻辑元素同形;装饰残缺低对比后退〉 ←原三
- **结果整格表达(已开格)**:已开格按当格结果整格表达(结果清单见原则三);画法——产资源格的家族物件纹样用 §3.1 道具点剪影语言(直线几何体)角嵌于砖缘,砖心与数字字形占位区留白(已开格仍承载数字);危险格巢穴纹较已开格更凶一档,形状+颜色双通道。验收:目检〈产资源格嵌家族纹样且砖心留白;巢穴纹更凶一档〉 ←原三;P3
- **未开格预读通道(微光单通道)**:类型预读 = 类型微光单通道(色相+明度双码,色带 §4.2);色弱类型主通道 = 微光明度梯度(组粒度——组间互辨;§2 微光明度例外);格内零角嵌/箭头/加号类附加形(微光格只留微光)。验收:目检〈微光格内零角嵌/箭头/加号类附加形〉 ←原一;原三
- **封印印记**:封印动作的叙事呈现 = **封印**(玩家可见专名,术语定义见 [术语表](../gdd/术语表.md)「封印」条;动作面见《排雷玩法》)——封印动作落在格上的印记称封印印记;封印色见 §4.1(现行封印黑),格内优先级栈位见 §1.2。封印特效(施印瞬间 + 印记常显形态)资产归清单 §4 演出类;封印印记纹理已落位(现行件 = `seal_mark.png`,32×32 可选件),数字字形占位已不存在。验收:目检〈封印印记呈现与封印黑一致,不侵入数字字形占位区〉 ←原一;P2
- **可挖域接触带微光(交互反馈层)**:挖掘期,英雄格周界与可挖域(概念见《排雷玩法》动作合法性节;呈现义务见《HUD框架》可挖域指示)的接触面画呼吸微光——正交邻墙 = 该侧整条边,斜向邻墙 = 共享角点 + 向两侧各延伸半条边;光只画英雄格(空间)一侧、不进墙格(与装饰变体零冲突,数字砖心不涉);微光 = 空间的灯光边界(画法规则)。色相 = 蓝族浅一档(§4.2 蓝条;与主角蓝框的分层由样张测试定,§1.4);呼吸 = §2.1 挖掘期唯一在动元素,减动效停格。角点为斜墙与正交墙共享、不逐格归因,逐格指认 = 悬停金框(呈现交互归《HUD框架》悬停指示域)。验收:目检〈微光只画英雄格一侧接触面:正交邻墙整条边、斜向邻墙角点 + 半边〉 ←§2.1;§4.2;P1
- **墙体**:圆角砌块堆叠,禁尖刺装饰——尖刺专属危险语义,装饰禁止伪装逻辑差异。验收:目检〈墙体无尖刺装饰〉 ←原三
- **高墙瓦片体系|术语与定义**:未挖格 = **一整块 32×32 自含凸起岩体**;素材语义名——未开格素材 = **墙瓦片**,已开格素材 = **地面瓦片**。瓦片以自含岩体为单位,Tiny Dungeon 拼块套不进自含岩体定义,不作瓦片参考。验收:目检〈未开格为整块凸起岩体,无拼块接缝感〉 ←原三
- **高墙瓦片体系|边界墨线纪律**:墙瓦片与相邻格分界一律 1px 深墨线、直线贴格边、零锯齿;墨线职责 = 分隔,不追求与地面融合。验收:目检〈分界为 1px 深墨直线贴格边、零锯齿〉
- **高墙瓦片体系|光照纪律(单一北受光)**:北缘 = 墨线内侧 1px 微亮棱(就地取色提亮,禁宽亮带);西/东缘 = 墨线后直进纹理、不加修饰;南缘 = 墨线 + 立面窄暗条(约 1/3 格高,横层理叠石、禁竖条纹栅栏感)。验收:目检〈北缘亮棱、南缘窄暗条;无宽亮带、无竖条纹栅栏感〉
- **高墙瓦片体系|凹角收头**:角点单像素落线;南立面侧凹角加 1px 立面收头——带身与转角竖线同列收尾,禁悬空、禁突出。验收:目检〈凹角收头与转角竖线同列,无悬空突出〉
- **高墙瓦片体系|现行定稿**:`game/assets/art/board/terrain_*`(16 唯一墙瓦片 + 5 地面瓦片变体;词表与查表逻辑落码于 `board_world.gd`)。验收:机检〈terrain_* 素材 16 墙瓦片 + 5 地面变体在库,查表逻辑与词表一致〉
- **高墙瓦片体系|高墙感 D 投影档与整板悬崖**:南临空墙格 = 立面口沿高光 2px `Color8(196,172,132)` + 双段暗带(0.16/0.25 格边,黑罩 α0.60/0.82);北邻墙地面格 = 三段渐隐投影(顶带 0.22 格边,α0.28 起);整板悬崖 = 板底/右侧 `DEPTH_PX 8px` 暗面 + 列分隔竖缝 + 岩层横纹 + 口沿高光 + 接触 AO + 底线收边 + 悬浮影(游戏 `board_world.gd` 同款结构件,样张脚本与游戏渲染常量同源)。验收:目检〈南临空墙格呈口沿高光+双段暗带;北邻墙地面格呈三段渐隐投影;整板悬崖结构件齐备〉
- **变异格形状通道**(双通道之"形",与色无关):底砖圆角外轮廓不变,砖面内缘绘一圈定宽专属几何纹带(纹带形全作不与他类共用),砖心留白予数字与旗;验收标准 = **灰度截图下仍可分类**。种类清单 = 地形变异六类型已定稿(地形变异 GDD;荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘):**纹带形按轴只分两种**——伤害轴(荆棘丛/岩浆/落石)= 刺/缺排斥形,信息轴(藤蔓缠绕/烬覆/矿尘)= 遮蔽/缠绕形;同轴跨图类型只做色板变体(§4.3 `variant_tint` 槽),不做新纹形。岩浆纹带橙红须与危险红互辨(变异格不是雷,色相邻近有混淆风险);信息轴遮蔽呈现禁止侵入数字字形占位区(§1.4 验收 1 延伸)。验收:目检〈六类型纹带灰度下仍可分类,同轴不新增纹形;岩浆纹带与危险红互辨〉 ←原一(纹理位次第 4,让位于数字/小人/旗);原三
- **雷图棋盘呈现基准**:战棋地块质感 × 正俯视角——取 ITB 参考图(`references/itb_1.jpg`)的地表质感骨架:地块纹理多样、起伏体块感、岛缘/砖缘厚度、格上装饰小立牌;但相机一律正上方垂直向下,格线屏轴对齐,**禁等距/斜视角与菱形格**;形状语言仍守总纲圆角方形。砖缘厚度与岛缘立体面属形体塑造,不属状态/情绪表达,不受 §2 情绪明暗禁令约束(2026-09-29 v3.0:本例外上升为 §2 明度分层允许域通则;顶面格底仍守 §4.1 参数域);装饰小立牌属 ITB 参考的观感元素,本作不采用——装饰变体走氛围层纪律(纹理级/物件级形状家族,见下方装饰变体条),零信息纪律不变。←原二;原三;§2
- **地砖**:一切环境体由同一圆角方形衍生:未开=完整凸砖,已开=同形缺角碎裂,雷=碎裂加巢穴纹(危险格玩家说法见《排雷玩法》);只以"完整度"叙事,不为状态新造形。←原三;P4
- **装饰变体(未开格氛围层)**:覆尘完整砖之上叠加裂纹/碎石斑/物件级点缀等装饰;2026-09-29 v3.0 物件级解禁——物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**(与信息元素形状语言隔离、灰度下不与任何逻辑元素同形),数量配额按图类型定(候选 3–5 种,样张 v3 冻结),一律残缺、内凹、低对比而自然后退(§3.4);零信息纪律(分布随机、与雷/安/类型零相关、灰度下禁止制造新分类特征、分布每板一次跨轮不复掷)以原则三为准,此处不赘。←原三
- **结果整格表达(已开格)**:已开格按当格结果整格表达(结果清单见原则三);画法——产资源格的家族物件纹样用 §3.1 道具点剪影语言(直线几何体)角嵌于砖缘,砖心与数字字形占位区留白(已开格仍承载数字);危险格巢穴纹较已开格更凶一档,形状+颜色双通道不变。←原三;P3
- **未开格预读通道(微光单通道)**:类型预读 = 类型微光单通道(色相+明度双码,色带 §4.2);色弱类型主通道 = 微光明度梯度(组粒度——组间互辨;§2 微光明度例外);格内零角嵌/箭头/加号类附加形(2026-09-28 用户裁「微光格只留微光」,退役史见 git 提交史)。←原一;原三
- **封印印记**:封印动作的叙事呈现 = **封印**(玩家可见专名,术语定义见 [术语表](../gdd/术语表.md)「封印」条;动作面见《排雷玩法》)——封印动作落在格上的印记称封印印记;封印色(§4.1,现行封印黑)与格内优先级栈位(§1.2)不变。封印特效(施印瞬间 + 印记常显形态)资产归清单 §4 演出类;封印印记纹理已落位(现行件=`seal_mark.png`,32×32 可选件)、字形占位退役(迭代 9 卡⑬已实现;原待办清单债务条目已完成移除)。←原一;P2;术语批 2026-09-25;封印紫批 2026-09-26;封印黑批 2026-09-28
- **可挖域接触带微光(交互反馈层,2026-09-25 用户拍板)**:挖掘期,英雄格周界与可挖域(概念见《排雷玩法》动作合法性节;呈现义务见《HUD框架》可挖域指示)的接触面画呼吸微光——正交邻墙=该侧整条边,斜向邻墙=共享角点+向两侧各延伸半条边;光只画英雄格(空间)一侧、不进墙格(与装饰变体零冲突,数字砖心不涉);微光=空间的灯光边界(画法口径)。色相=蓝族浅一档(§4.2 蓝条;与主角蓝框的分层归样张批);呼吸=§2.1 挖掘期唯一在动元素,减动效停格。角点为斜墙与正交墙共享、不逐格归因,逐格指认=悬停金框(呈现交互归《HUD框架》悬停指示域)。←§2.1;§4.2;P1
- **墙体**:圆角砌块堆叠,禁尖刺装饰——尖刺专属危险语义,装饰禁止伪装逻辑差异。←原三
- **高墙瓦片体系(2026-09-26 定稿,样张批追认)**:未挖格=**一整块 32×32 自含凸起岩体**(方案甲核心定义);素材语义名——未开格素材=**墙瓦片**,已开格素材=**地面瓦片**(术语 2026-09-26 用户立)。**边界=墨线纪律**:墙瓦片与相邻格分界一律 1px 深墨线、直线贴格边、零锯齿;墨线职责=分隔,不追求与地面融合(Tiny Dungeon 样图实证调研后否决出局——TD 拼块套不进方案甲自含岩体定义,2026-09-25 终局维持方案甲;记录=git 提交 70d2320)。**光照=单一北受光**:北缘=墨线内侧 1px 微亮棱(就地取色提亮,禁宽亮带);西/东缘=墨线后直进纹理、不加修饰;南缘=墨线+立面窄暗条(约 1/3 格高,横层理叠石、禁竖条纹栅栏感)。**凹角**:角点单像素落线;南立面侧凹角加 1px 立面收头——带身与转角竖线同列收尾,禁悬空、禁突出(v9.1/v9.2 两版返工实证)。现行定稿=`game/assets/art/board/terrain_*`(16 唯一墙瓦片+5 地面瓦片变体;词表与查表逻辑见 `高墙瓦片词表(已落码 board_world.gd 查表,文件已退役)`)。**高墙感 D 投影档+整板悬崖(样张 v2 定稿 2026-09-28,用户 2026-09-27 四变体挑档)**:南临空墙格=立面口沿高光 2px `Color8(196,172,132)`+双段暗带(0.16/0.25 格边,黑罩 α0.60/0.82);北邻墙地面格=三段渐隐投影(顶带 0.22 格边,α0.28 起);整板悬崖=板底/右侧 `DEPTH_PX 8px` 暗面+列分隔竖缝+岩层横纹+口沿高光+接触 AO+底线收边+悬浮影(游戏 `board_world.gd` 同款结构件,样张脚本与游戏渲染常量同源——机检 F7 承载)。←原三;状态记录 164;样张 v2 批 2026-09-28
- **变异格形状通道**(双通道之"形",与色无关):底砖圆角外轮廓不变,砖面内缘绘一圈定宽专属几何纹带(纹带形全作不与他类共用),砖心留白予数字与旗;验收标准=**灰度截图下仍可分类**。种类清单 = 地形变异六类型已定稿(地形变异 GDD;荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘):**纹带形按轴只分两种**——伤害轴(荆棘丛/岩浆/落石)= 刺/缺排斥形,信息轴(藤蔓缠绕/烬覆/矿尘)= 遮蔽/缠绕形;同轴跨图类型只做色板变体(§4.3 `variant_tint` 槽),不做新纹形。样张批增查:岩浆纹带橙红须与危险红互辨(变异格不是雷,色相邻近混淆风险);信息轴遮蔽呈现禁止侵入数字字形占位区(§1.4 验收 1 延伸)。←原一(纹理位次第 4,让位于数字/小人/旗);原三
### 3.3 UI 形状语法(方向指导,细则见 §7.1)
### 3.3 UI 形状语法(方向指导,细则迁 §7.1)
**方向指导**:半拟物——容器穿戏服、数据素面;圆角 = 可触摸的物件,直角 = 须精确的信息(总纲「圆角为世,直线为信」的 UI 侧落法)。画法细则(面板/按钮/蜡封/数字色牌/参考分界)成文于 **§7.1**。验收:方向性 ←原二;§2.4
**方向指导**:半拟物——容器穿戏服、数据素面;圆角=可触摸的物件,直角=须精确的信息(总纲「圆角为世,直线为信」的 UI 侧落法)。画法细则(面板/按钮/蜡封/数字色牌/参考分界)成文于 **§7.1**。←原二;§2.4
### 3.4 主角形状与对比预算
显著度 = 完整度:**每格至多一个"完整凸形"**(数字/小人/旗,依 1.2 优先级让位;装饰变体贴砖缘分布、不构成第二完整凸形——§4.6;数字色牌随数字一体,不作独立焦点;类型微光为底效泛光,不作焦点、不抢完整凸形预算),底砖与装饰一律残缺、内凹、低对比而自然后退;危险用刺与缺(排斥形),收益用满与圆(吸引形),同表预算、此消彼长。焦点分配双机制:**完整度**(形状,本条上文)+ **明度带**(§2 明度分层——信息面层恒亮于世界层,焦点天然落信息面);明暗只做层间分离,层内禁明暗竞争(不靠明暗抢焦)。验收:目检〈任一格至多一个完整凸形;信息面层恒亮于世界层〉 ←原二;P1;P3
显著度=完整度:**每格至多一个"完整凸形"**(数字/小人/旗,依 1.2 优先级让位;装饰变体贴砖缘分布、不构成第二完整凸形——§4.6;数字色牌随数字一体,不作独立焦点;类型微光为底效泛光,不作焦点、不抢完整凸形预算),底砖与装饰一律残缺、内凹、低对比而自然后退;危险用刺与缺(排斥形),收益用满与圆(吸引形),同表预算、此消彼长。焦点分配双机制(2026-09-29 v3.0):**完整度**(形状,本条上文)+**明度带**(§2 明度分层——信息面层恒亮于世界层,焦点天然落信息面);明暗只做层间分离,层内禁明暗竞争(不靠明暗抢焦纪律不变)。←原二;P1;P3
---
## 4. 色彩系统(Color System)
> 尾注「←」同第 3 节:P1–P4=支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2「锚定」注);原一/二/三=1.2 支撑原则;§x=对应节。本节只定用色;凡涉具体变异、状态种类之处一律「待 GDD 定夺」,只规定"若有,则如何用色"。
> 尾注「←」同第 3 节:P1–P4=支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2「锚定」注);原一/二/三=1.2 支撑原则;§x=对应节。本节只定用色;凡涉具体变异、状态种类之处一律「待 GDD 定夺」,只规定"若有,则如何用色"。
**总纲|彩为世,墨为信**:逻辑信息(数字、UI 数据、物件轮廓)一律用**近黑暖墨族**;世界与物件(砖、封印面、金、氛围)用彩。墨管可读,彩管气质。验收:目检〈逻辑信息用近黑暖墨族,世界与物件用彩〉 ←原一/原二;§3.3
**总纲|彩为世,墨为信**:逻辑信息(数字、UI 数据、物件轮廓)一律用**近黑暖墨族**;世界与物件(砖、封印面、金、氛围)用彩。墨管可读,彩管气质。←原一/原二;§3.3
### 4.1 主色板
- **掩尘 `#C9AF83`**|未开格底色,覆尘完整砖:全作的"未知",画面最大沉默面。验收:机检〈未开格底色 = #C9AF83〉 ←原二;§2.1
- **裸土 `#B39A78`**|已开格底色,碎裂裸露:更收更退;明度分层——已开格(挖出的空间)走**世界底层档**,明度低于未开格墙体一档(与 §8.5 图底分离同轴),L 0.08–0.14 带(更高明度底实测达不到 4.5:1,故取本带;样张已冻结,§1.4)。验收:机检〈已开格底 L 落在 0.08–0.14,对亮墨数字 ≥4.5:1〉 ←原三
- **数字墨双色制**:1–8 **不做八色分色**,按密度分三档色温(冷 = 稀疏,暖 = 密集);**字形为唯一数值载体**,色温只作密度氛围。验收:目检〈1–8 数字按密度呈三档色温而非八色〉 ←原一/原二;P3
- **棋盘数字呈现 = 按板自适应三态**:暗纹理板(dungeon 系)= **亮族**三档 1–3 冷亮青白 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8 直画(判据 = 族最弱成员对 p90 ≥4.5:1 且对均值 ≥2.5:1);双峰纹理板(underdark/deep_underdark 系)= **色牌垫底**(素牌 DIGIT_PLATE + 深墨 #33261B 12:1,纹理免疫——双峰纹理板上任何墨族直画都不可读,渲染帧取证;判据 = 亮深两族皆不过门,深族均值门 3.0);缺盘平色回退态(#B39A78 中明度底)默认深墨直画。选模式管线落码单点 `board_view._digit_ink_mode`(三态:亮直/深直/落牌),机检面 = `terrain_tile_sets_test` 三关呈现模式断言;目视验证面 = board_shot_probe 渲染帧取证(机检≠看得清)。验收:机检〈terrain_tile_sets_test 三关呈现模式断言全绿,board_shot_probe 渲染帧目视复核〉
- **垫牌与 UI 数据墨 = 深墨族 `#33261B` 系**(L≤0.027,§7.2)。验收:机检〈垫牌与 UI 数据墨取深墨族,L≤0.027〉
- **八色弃用**:八色必与红/金/蓝/紫赤语义冲突,故弃。验收:方向性
- **封印黑 `#000000`**|封印宣告色,无色相:玩家的宣告"此格封印"。封印现行 = 无色相黑——紫色与微光蓝族(魔法微光 256° 等)色相冲突,黑色无色相天然避开 300°–345° 禁入带与全部微光色带;未开格墙顶明度 ≈113 对纯黑对比 ≥4.4:1,旗杆米白保可读。旗朱与封印紫已不用于棋盘封印旗,红族棋盘侧只剩危险红;人设背包旗 = 旗朱红矩形旗,与棋盘封印色脱钩(人设侧见 character-design 背包旗条)。验收:机检〈封印宣告色 = #000000,未开格墙顶对其对比 ≥4.4:1〉 ←原一;P2
- **危险红 `#A8321F`**|危险色:雷 = 被惊动的巢穴,兑现档红,更深更沉。验收:机检〈危险语义色 = #A8321F〉 ←原三;P2
- **奖励金 `#F0A828`**|已兑现的收益:GP/宝箱/蜡封,全作唯一亮金。验收:机检〈收益语义色 = #F0A828〉 ←P2;§2.3/2.4
- **小人蓝 `#2E86DE`**|小人主色:棋盘唯一"己方",与光标准线同族。验收:机检〈己方语义色 = #2E86DE〉 ←P1;§2.1
- **数字色牌 `#F5EAD3`**|数字底具,**后备手段**:素色暖纸白直角小牌,仅复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)垫于数字正下方;素面无纹理、不随图类型/装饰变体变。验收:机检〈色牌 L≥0.70,仅复杂格底垫用〉 ←原二
- **收益金亮档 `#E0B860`(GOLD_BRIGHT)**|UI 徽标可读性亮档 token:敌顺位徽标①②③与重甲徽标对舞台底 6.47:1(GOLD_DEEP 3.26:1 不达 §7.2 带,故另立亮档);金族亮档,与奖励金 #F0A828 同族不同岗——奖励金 = 已兑现收益语义(§4.2 金条),本 token 只承载徽标可读性、不占收益语义。GOLD_DEEP/HERO_BLUE 常量本尊不动(standee 意图判据等语义岗现行使用)。验收:机检〈GOLD_BRIGHT 对舞台底 ≥4.5:1〉
- **小人蓝亮档 `#7EBAF0`(HERO_BLUE_BRIGHT)**|魔法护甲徽标对舞台底 5.88:1(HERO_BLUE 3.23:1 不达带,故另立亮档);蓝族亮档,与小人蓝 #2E86DE 同族不同岗——本 token 只承载徽标可读性,不占用「蓝只说己方」的语义。验收:机检〈HERO_BLUE_BRIGHT 对舞台底 ≥4.5:1〉
- **图标槽柔和档 `#806A4E`(SLOT_BACKDROP_SOFT)**|BCard 图标槽底:对米色卡面 PANEL_DATA 3.69:1(深档 SLOT_BACKDROP #3E362C 8.53:1 过强、像硬贴补丁,故槽底另立柔和档),槽内白图标 5.14:1,满足 ≥3:1 图形参考线硬约束;仅 BCard 图标槽换档,B 区状态槽仍用深档 SLOT_BACKDROP。验收:机检〈槽内白图标对槽底 ≥3:1〉
- **掩尘 `#C9AF83`**|未开格底色,覆尘完整砖:全作的"未知",画面最大沉默面。←原二;§2.1
- **裸土 `#B39A78`**|已开格底色,碎裂裸露:更收更退;2026-09-29 v3.0 明度分层——已开格(挖出的空间)走**世界底层档**,明度低于未开格墙体一档(与 §8.5 图底分离同轴),候选 L 0.08–0.14 带(样张 v3 实测修正:L0.20+ 底配纯白也仅 3.4:1,数学不可达 4.5:1),样张 v3 冻结。←原三
- **数字墨双色制(2026-09-29 v3.0)**|1–8 **不做八色分色**,按密度分三档色温(冷=稀疏,暖=密集);**字形为唯一数值载体**,色温只作密度氛围。①**棋盘数字呈现=按板自适应三态**(2026-10-01 v3.4 修订——v3.3 深墨族方案经渲染帧取证证伪:underdark 系地板近黑斑与近白筋并存〔双峰纹理〕,深墨直画融黑斑同样隐形,任何墨族直画不可救):暗纹理板(dungeon 系)=**亮族**三档 1–3 冷亮青白 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8 直画(样张 v3 冻结原样;判据=族最弱成员对 p90 ≥4.5:1 且对均值 ≥2.5:1);双峰纹理板(underdark/deep_underdark 系)=**色牌垫底**(素牌 DIGIT_PLATE + 深墨 #33261B 12:1,纹理免疫——v2 色牌形制在双峰板复役;判据=亮深两族皆不过门,深族均值门 3.0)。选模式管线落码单点 `board_view._digit_ink_mode`(三态:亮直/深直/落牌),机检面=`terrain_tile_sets_test` 三关呈现模式断言;**目视验证面=board_shot_probe 渲染帧取证**(机检≠看得清,2026-10-01 用户教训);缺盘平色回退态(#B39A78 中明度底)默认深墨直画;②**垫牌与 UI 数据墨=深墨族 `#33261B` 系**(L≤0.027 不变,§7.2)。八色必与红金蓝紫赤语义冲突,故弃。←原一/原二;P3
- **封印黑 `#000000`**|封印(插旗)宣告色,无色相:玩家的宣告"此格封印"(色史:旗朱 `#C63D22` →封印紫 `#7C3AA8` 2026-09-26→**封印黑 2026-09-28 用户拍板**——紫色与微光蓝族〔魔法微光 256° 等〕色相冲突,黑色无色相天然避开 300°–345° 禁入带与全部微光色带;未开格墙顶明度 ≈113 对纯黑对比 ≥4.4:1,旗杆米白保可读)。旗朱随封印紫批退役〔棋盘侧〕,红族棋盘侧自此只剩危险红(2026-09-28 起人设背包旗=旗朱红矩形旗——旗色脱钩拍板,人设侧详见 character-design 背包旗条)。←原一;P2;封印紫批 2026-09-26;封印黑批 2026-09-28
- **危险红 `#A8321F`**|危险色:雷=被惊动的巢穴,兑现档红,更深更沉(色值不变)。←原三;P2
- **奖励金 `#F0A828`**|已兑现的收益:GP/宝箱/蜡封,全作唯一亮金。←P2;§2.3/2.4
- **小人蓝 `#2E86DE`**|小人主色:棋盘唯一"己方",与光标准线同族(样张 v2 定稿 2026-09-28)。←P1;§2.1
- **数字色牌 `#F5EAD3`**|数字底具,2026-09-29 v3.0 降级为**后备手段**:素色暖纸白直角小牌,仅复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)垫于数字正下方;素面无纹理、不随图类型/装饰变体变。←原二
- **收益金亮档 `#E0B860`(GOLD_BRIGHT)**|UI 徽标可读性亮档 token(2026-10-14 迭代 26 反哺③登记转正):敌顺位徽标①②③与重甲徽标对舞台底 6.47:1(旧 GOLD_DEEP 3.26:1 越 §7.2 带);金族亮档,与奖励金 #F0A828 同族不同岗——奖励金=已兑现收益语义(§4.2 金条),本 token 只承载徽标可读性、不占收益语义。语义岗冲撞已裁:GOLD_DEEP/HERO_BLUE 常量本尊不动(standee 意图判据等语义岗在役)
- **小人蓝亮档 `#7EBAF0`(HERO_BLUE_BRIGHT)**|魔法护甲徽标对舞台底 5.88:1(旧 HERO_BLUE 3.23:1 越带);蓝族亮档,与小人蓝 #2E86DE 同族不同岗——本 token 只承载徽标可读性,「蓝只说己方」语义不变
- **图标槽柔和档 `#806A4E`(SLOT_BACKDROP_SOFT)**|BCard 图标槽底(2026-10-14 迭代 26 T5):对米色卡面 PANEL_DATA 3.69:1(旧深档 SLOT_BACKDROP #3E362C 8.53:1——过强像硬贴补丁),槽内白图标 5.14:1 ≥3:1 图形参考线硬约束;仅 BCard 图标槽换档,B 区状态槽仍用深档 SLOT_BACKDROP
**对比硬指标(可测)**:数字可读性由**格底明度分档**承载——棋盘数字亮墨 **L≥0.75** 对**已开格底 L 0.08–0.14** ⇒ 直画对比 **≥4.5:1 恒成立**(WCAG AA,样张逐组合抽测冻结);**色牌后备**:色牌素色恒定 **L≥0.70**、牌上数字深墨 **L≤0.027** ⇒ 任意牌墨组合 ≥4.5:1(实测样例:#33261B/#F5EAD3 = 12.3:1)。未开格底槽(含全部图类型)相对亮度 **L≥0.30**(墙顶面受光档)、HSV 饱和 **≤0.55**、避 Boss 禁入色相带;已开格底走世界底层档(见上)。物件识别不依赖格底(§5.1 轮廓通道),旗面、金色不作识别依赖。验收:机检〈亮墨对已开格底 ≥4.5:1;未开格底 L≥0.30、饱和 ≤0.55;任意牌墨组合 ≥4.5:1〉 ←原二;地形变异 AC-9
**对比硬指标(可测;2026-09-29 v3.0 改写)**:数字可读性由**格底明度分档**承载——棋盘数字亮墨 **L≥0.55**(候选)对**已开格底 L 0.20–0.32**(候选)⇒ 直画对比 **≥4.5:1 恒成立**(WCAG AA,样张 v3 逐组合抽测冻结);**色牌后备**:色牌素色恒定 **L≥0.70**、牌上数字深墨 **L≤0.027** ⇒ 任意牌墨组合 ≥4.5:1(实测样例:#33261B/#F5EAD3 = 12.3:1)。未开格底槽(含全部图类型)相对亮度 **L≥0.30**(墙顶面受光档)、HSV 饱和 **≤0.55**、避 Boss 禁入色相带;已开格底走世界底层档(上见)。物件识别不依赖格底(§5.1 轮廓通道),旗面、金色不作识别依赖。←原二;地形变异 AC-9
**色板增量纪律(数值状态 = 待定现用)**:全作色板硬上限 **48 色**(防色板无限膨胀的封口条款;额度余量以色板检查器实报为准);新色入板须同时过两道门:①§4.2 语义检查(不占语义色段/禁入带);②**与既有任一色的 RGB 三通道差 ≥8/255**——反例实证:#3A2E22 ↔ #362B20 三通道差均 <5,肉眼不可辨,同板共存只增噪。不过门即触发「换色合并」(把意图色并进最近既有色),**禁加色绕行**;加色义务同时登记于本表修订。**「待定现用」态释义**:数值**现行即执法**(色板检查器按此拦,§8.3)——非候选(不存在"等冻结"空窗)、非死规(后续需求引入时可变);调整走本节修订并登记,改值 = 修订不是违约。验收:机检〈色板总数 ≤48;新色入板过两道门〉
**色板增量纪律(2026-10-06 P2 对账批新增;数值状态 = **待定现用**)**:全作色板硬上限 **48 色**(现行 ≈45,余量 3——2026-10-14 迭代 26 新入 GOLD_BRIGHT/HERO_BLUE_BRIGHT/SLOT_BACKDROP_SOFT 三枚 UI 亮档/柔和档 token,均已过 §4.2 语义检查〔不占语义色段〕与 RGB 三通道差 ≥8/255 两道门;审计"色板逼近失控线"的封口条款);新色入板须同时过两道门:①§4.2 语义检查(不占语义色段/禁入带);②**与既有任一色的 RGB 三通道差 ≥8/255**——反例即审计实证:#3A2E22 ↔ #362B20 三通道差均 <5,肉眼不可辨,同板共存只增噪。不过门即触发「换色合并」(把意图色并进最近既有色),**禁加色绕行**;加色义务顺带登记于本表修订。**「待定现用」态释义(2026-10-06 用户立第三态)**:数值**现行即执法**(色板检查器按此拦,§8.3)——非候选(不存在"等冻结"空窗)、非死规(后续需求引入时可变);调整走本节修订并登记,改值 = 修订不是违约。
- **色板分层**:色板分两层管理——① **语义/UI token 板**:全作共享,硬上限 **48 色**,管 UI 语义色、墨色、材质色,跨屏恒定;② **场景环境子板**:**每屏一套,≤32 色,允许跨屏不共享**(环境色 = 天空、地面、植被、石材、火焰等随场景走的颜色)。新增环境色仍须过两道门(§4.2 语义检查 + 与既有任一色 RGB 三通道差 ≥8/255),但**不占用 48 token 额度**;不过门照旧走「换色合并」,**禁加色绕行**。单张 PNG 的像素域色数上限 **≤32 色**另由 `standee_asset_check` 执法(按单图像素域取值方式计数,与本表 token 计数互不干扰)。额度不足时向作者报告,由作者决定压缩或合并既有色。验收:机检〈token 板 ≤48;每屏环境子板 ≤32〉
- **色板分层(2026-10-22 作者裁)**:色板分两层管理——① **语义/UI token 板**:全作共享,硬上限 **48 色**,管 UI 语义色、墨色、材质色,跨屏恒定;② **场景环境子板**:**每屏一套,≤32 色,允许跨屏不共享**(环境色=天空、地面、植被、石材、火焰等随场景走的颜色)。新增环境色仍须过两道门(§4.2 语义检查 + 与既有任一色 RGB 三通道差 ≥8/255),但**不占用 48 token 额度**;不过门照旧走「换色合并」,**禁加色绕行**。单张 PNG 的像素域色数上限 **≤32 色**另由 `standee_asset_check` 执法(像素域口径,与本表 token 口径互不干扰)。额度不足时呈报作者,由作者决定压缩或合并既有色。
### 4.2 语义色彩规则
> **适用域**:本节「X 只说 Y」约束**信息层**(格内信息/UI 数据/预告/语义标识)与**语义物件**(GP=收益金、血条=危险红等);**纯环境氛围**(背景/装饰变体/远景/非语义物件)不受色相独占约束,改受 §3.4 焦点预算与低饱和后退约束(不抢读即可用色)。禁入带 300°–345°:信息层与语义物件禁入不变;环境层可入但限低饱和(高饱和紫赤仍 Boss 独占,§2.6)。验收:目检〈信息层与语义物件不落禁入带、不占语义独占色相;环境层用色低饱和〉
> **适用域(2026-09-29 v3.0 立)**:本节「X 只说 Y」约束**信息层**(格内信息/UI 数据/预告/语义标识)与**语义物件**(GP=收益金、血条=危险红等);**纯环境氛围**(背景/装饰变体/远景/非语义物件)不受色相独占约束,改受 §3.4 焦点预算与低饱和后退约束(不抢读即可用色)。禁入带 300°–345°:信息层与语义物件禁入不变;环境层可入但限低饱和(高饱和紫赤仍 Boss 独占,§2.6)。
- **红只说危险**:危险红 = 兑现档,红族单档。禁(信息层域):红禁止用于奖励、装饰性标识与常规 UI(微光群伤组除外——见「类型微光色带」条,其色相刻意错开语义红)。验收:目检〈信息层域红色仅出现在危险语义与微光群伤组〉 ←原三;P2;§2.1
- **金只说收益与兑现**(奖励、结算、胜利)。禁(信息层与语义物件域):金禁止作危险障碍色、不上格底、不作日常 UI 强调(防通胀;微光通用增益组的芥末黄属「类型微光色带」条色带,非收益语义金)。验收:目检〈信息层与语义物件域金色仅承载收益与兑现〉 ←P2;P3;§2.2–2.4
- **蓝只说己方与冷静信息**(小人、光标、工具信息——含可挖域接触带呼吸微光,蓝族浅一档 `#D6ECFF`)。禁(信息层域):蓝禁止表示敌方危险;暮蓝 `#5A6C8C` 为失败独占(§2.5),只作全屏氛围,不作物件色。验收:目检〈信息层域蓝色仅承载己方与冷静信息;敌方危险无蓝色〉 ←P1;§2.5
- **紫赤只说 Boss 与精英** `#8F2F6A`(色相 323°):郑重来自色相独占,不来自压暗。禁:**HSV 色相 300°–345° 为信息层与语义物件禁入带**——UI、粒子、类型微光、语义物件及图类型色板信息面禁止落入;环境层(背景/装饰)可入但限低饱和,高饱和紫赤仍 Boss 独占。验收:机检〈信息层与语义物件色值色相不落 300°–345°;高饱和紫赤仅 Boss 战〉 ←§2.6;原三
- **绿只说自然生机**(环境植被;恢复类状态若有则用绿——待增益状态定夺)。**例外:类型微光治疗组**(见下条「类型微光色带」)。禁(信息层域):绿禁止承载逻辑状态(微光色带除外),禁止与红构成唯一区分对。验收:目检〈信息层域绿色仅微光治疗组;绿红不构成唯一区分对〉 ←原三
- **类型微光色带 = 格面类型先验专用逻辑色带**(未开格类型预读的色彩通道;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节):未开格类型微光的信息色彩按以下五组执行——**通用增益组**(attack/defense/weaken/action/gp 五族)芥末黄 `#D6C13A`(≈52°)、**治疗组**(heal)黄绿 `#5CB83A`(≈104°)、**穿刺组**(pierce)青 `#2E9E9E`(≈180°)、**魔法组**(magic)紫罗兰 `#7B5BD6`(≈256°)、**群伤组**(blast)绯红 `#FF2D55`(≈349°,刻意避开危险红色相段与 300°–345° 禁入带)。验收:机检〈类型微光五组色值与本表一致〉
- **微光呈现参数**:内缘带状泛光 α170、带宽 cell÷8(亮度不压格底明度档,§2 微光例外);组间互辨以抽检核对。验收:机检〈微光泛光 α=170、带宽=cell÷8〉
- **微光分域纪律与双码**:微光色相只承载类型先验,不迁移复用为物件/阵营/UI 语义色;微光 = **色相+明度双码**——明度梯度承载分组(组间五色相、组内不细分:通用增益组五族同色相、不承诺组内分型;§2 微光明度例外:梯度禁止压破格底明度档与三态分类);色弱玩家的类型主通道 = 微光双码(组粒度:组间五色相 + 各组一档明度互辨)。验收:目检〈五组微光两两互辨,明度梯度不压破格底明度档与三态分类〉 ←§4.6.3
- **金/红兼作极性色相槽(§4.6)**:极性(概念定义与符号矩阵见《地图战斗资源》极性提案)的视觉取色只在这两族间——有利兑付 = 金族,有害兑付 = 红族,不新增语义色。验收:目检〈极性视觉取色仅金/红两族〉 ←§4.6;原二
- **红只说危险**:危险红=兑现档(旗朱预告档随封印紫批退役,红族单档)。禁(信息层域):红禁止用于奖励、装饰性标识与常规 UI(微光群伤组除外——见「类型微光色带」条,其色相刻意错开语义红)。←原三;P2;§2.1;封印紫批 2026-09-26
- **金只说收益与兑现**(奖励、结算、胜利)。禁(信息层与语义物件域):金禁止作危险障碍色、不上格底、不作日常 UI 强调(防通胀;微光通用增益组的芥末黄属「类型微光色带」条色带,非收益语义金)。←P2;P3;§2.2–2.4
- **蓝只说己方与冷静信息**(小人、光标、工具信息——含可挖域接触带呼吸微光,蓝族浅一档 `#D6ECFF`〔样张 v2 定稿 2026-09-28〕,2026-09-25 拍板)。禁(信息层域):蓝禁止表示敌方危险;暮蓝 `#5A6C8C` 为失败独占(§2.5),只作全屏氛围,不作物件色。←P1;§2.5
- **紫赤只说 Boss 与精英** `#8F2F6A`(色相 323°):郑重来自色相独占,不来自压暗。禁:**HSV 色相 300°–345° 为信息层与语义物件禁入带**——UI、粒子、类型微光、语义物件及图类型色板信息面禁止落入;环境层(背景/装饰)可入但限低饱和,高饱和紫赤仍 Boss 独占(2026-09-29 v3.0 域限定)。←§2.6;原三
- **绿只说自然生机**(环境植被;恢复类状态若有则用绿——待增益状态定夺)。**例外:类型微光治疗组**(见下条「类型微光色带」)。禁(信息层域):绿禁止承载逻辑状态(微光色带除外),禁止与红构成唯一区分对。←原三;T1 裁决 2026-09-22
- **类型微光色带 = 格面类型先验专用逻辑色带**(未开格类型预读的色彩通道;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节):未开格类型微光的信息色彩按以下五组执行——**通用增益组**(attack/defense/weaken/action/gp 五族)芥末黄 `#D6C13A`(≈52°)、**治疗组**(heal)黄绿 `#5CB83A`(≈104°)、**穿刺组**(pierce)青 `#2E9E9E`(≈180°)、**魔法组**(magic)紫罗兰 `#7B5BD6`(≈256°)、**群伤组**(blast)绯红 `#FF2D55`(≈349°,刻意避开危险红色相段与 300°–345° 禁入带)——**样张 v2 定稿(2026-09-28)**,组间互辨抽检随机检报告;v2 呈现参数=内缘带状泛光 α170/带宽 cell÷8(亮度不压格底明度档,§2 微光例外)。分域纪律:微光色相只承载类型先验,不迁移复用为物件/阵营/UI 语义色;微光 = **色相+明度双码**(2026-09-28 角嵌退役批起明度梯度承载分组(组间五色相、组内不细分——通用增益组五族同色相、不再承诺组内分型;§2 微光明度例外:梯度禁止压破格底明度档/三态分类);色弱玩家的类型主通道 = 微光双码(组粒度:组间五色相 + 各组一档明度互辨)。←T1 裁决 2026-09-22;角嵌退役批 2026-09-28;§4.6.3
- **金/红兼作极性色相槽(§4.6)**:极性(概念定义与符号矩阵见《地图战斗资源》极性提案)的视觉取色只在这两族间——有利兑付 = 金族,有害兑付 = 红族,不新增语义色。←§4.6;原二
### 4.3 图类型色板换皮规则(board_type)
**换皮只换四槽,永不变信**。模板按棋盘生成产物 `board_type` 标签直接消费(键名与词表事实源 = 棋盘生成 GDD 与 `board_gen.tres`:dungeon / underdark / deep_underdark);清单与词表待 关卡节点地图/地形变异/地图战斗资源 定稿后归其所有,本节只定模板。验收:机检〈换皮仅动下列四槽,其余槽位与不变槽恒定〉
**换皮只换四槽,永不变信**。模板按棋盘生成产物 `board_type` 标签直接消费(键名与词表事实源 = 棋盘生成 GDD 与 `board_gen.tres`:dungeon / underdark / deep_underdark);清单与词表 owner 待 关卡节点地图/地形变异/地图战斗资源,本节只定模板:
- `tile_closed` 未开底:L 与饱和同 §4.1 硬指标,避禁入色相带。验收:机检〈各图类型 tile_closed 底色 L 与饱和落 §4.1 未开格底带〉
- `tile_open` 已开底:世界底层档(L 带见 §4.1)、饱和 ≤0.55,避禁入色相带。验收:机检〈各图类型 tile_open 底色 L 落 §4.1 已开格底带〉
- `wall_ambient` 墙雾氛围 tint:可带主题色相,L≥0.30。验收:机检〈wall_ambient tint L≥0.30〉
- `variant_tint` 变异纹带染槽:只改色相不改纹形(纹带形按轴两分,见 §3.2 变异格形状通道;种类 = 地形变异六类型已定稿)。验收:目检〈同纹带形跨图类型仅色相不同〉
- `tile_closed` 未开底:L 与饱和同 4.1 硬指标(墙顶面受光档 L≥0.30、饱和 ≤0.55),避禁入色相带
- `tile_open` 已开底:世界底层档(2026-09-29 v3.0,候选 L 0.08–0.14,样张 v3 冻结)、饱和 ≤0.55,避禁入色相带
- `wall_ambient` 墙雾氛围 tint:可带主题色相,L≥0.30
- `variant_tint` 变异纹带染槽:只改色相不改纹形(纹带形按轴两分,见 §3.2 变异格形状通道;种类 = 地形变异六类型已定稿)
**不变槽**:棋盘数字墨亮族三档、数字色牌(后备件)、封印黑、危险红、奖励金、小人蓝、紫赤、绿、类型微光五组——**信息与语义层全图类型恒定**;换挡动色相饱和,格底两档明度关系(墙亮/空间暗)全图类型恒定(§2 明度分层)。新增色板须重跑 §1.4 格子样张(灰度可辨+对比抽测)。验收:机检〈换皮前后不变槽色值逐项一致〉 ←原三/原二;地形变异 AC-9
**不变槽**:棋盘数字墨亮族三档、数字色牌(后备件)、封印黑、危险红、奖励金、小人蓝、紫赤、绿、类型微光五组——**信息与语义层全图类型恒定**;换挡动色相饱和,格底两档明度关系(墙亮/空间暗)全图类型恒定(§2 明度分层)。新增色板须重跑 §1.4 格子样张(灰度可辨+对比抽测)。←原三/原二;地形变异 AC-9
### 4.4 UI 色板(方向指导,细则迁 §7.2)
### 4.4 UI 色板(方向指导,细则迁 §7.2)
**方向指导**:数据素面的墨来自世界的墨——UI 数字/进度/准线一律主墨 `#33261B` 系;容器按场景穿材质色(营地全作最暖、战斗中性石);UI 不新增语义色,强调沿用 §4.2。色板细则成文于 **§7.2**。验收:方向性 ←原一;§2.8
**方向指导**:数据素面的墨来自世界的墨——UI 数字/进度/准线一律主墨 `#33261B` 系;容器按场景穿材质色(营地全作最暖、战斗中性石);UI 不新增语义色,强调沿用 §4.2。色板细则成文于 **§7.2**。←原一;§2.8
### 4.5 色弱安全清单
依赖色相的语义对逐对点名(冗余通道:形/图标/位置/运动):
依赖色相的语义对逐对点名(冗余通道:形/图标/位置/运动):
1. 已开 vs 未开:§3.2 完整/碎裂形承载,无需另加。验收:目检〈色弱模拟下两态以完整度可辨〉
2. 已开 vs 变异格:纹带形状通道,灰度截图可辨,无需另加。验收:目检〈灰度截图下变异格可辨〉
3. 封印印记 vs 巢穴(危险格):印记形物件对砖面巢穴纹,无需另加。验收:目检〈色弱模拟下印记与巢穴纹可辨〉
4. 奖励金 vs 危险红(色弱下皆偏褐):§3.1 道具点两类剪影 + §3.4 满圆对刺缺 + 金粒运动,无需另加。验收:目检〈色弱模拟下奖励金与危险红可辨〉
5. Boss 紫赤 vs 其他敌:§5.1 形状独占,无需另加。验收:目检〈色弱模拟下 Boss 可辨〉
6. 小人蓝 vs 青墨数字:明度悬殊 + 小人轮廓与底盘,无需另加。验收:目检〈色弱模拟下小人与数字可辨〉
7. **UI 红/金强调对:纹带不管面板——需补图标+文案冗余,且破坏性确认禁止与常规确认同位(位置通道)**。验收:目检〈UI 红/金强调位有图标+文案冗余,破坏性确认与常规确认不同位〉 ←原三
8. 失败暮蓝 vs 小人蓝:状态互斥,不承载区分,无需冗余。验收:方向性
9. 图标键族(§4.6):图标身份全靠纹样形状通道(一键一纹样,色相不承载身份);未开格类型预读 = **类型微光单通道**(五组色带,§4.2;色相+明度双码——明度档间互辨承载色弱场景);极性金/红对由框形满/缺冗余——色相全失效时图标仍可逐一辨认与定极性。验收:目检〈色相全失效时图标仍可逐一辨认与定极性〉 ←原三;增益状态×4②
1. 已开 vs 未开:§3.2 完整/碎裂形承载,无需另加
2. 已开 vs 变异格:纹带形状通道,灰度截图可辨,无需另加
3. 封印印记 vs 巢穴(危险格):印记形物件对砖面巢穴纹,无需另加
4. 奖励金 vs 危险红(色弱下皆偏褐):§3.1 道具点两类剪影 + §3.4 满圆对刺缺 + 金粒运动,无需另加
5. Boss 紫赤 vs 其他敌:§5.1 形状独占,无需另加
6. 小人蓝 vs 青墨数字:明度悬殊 + 小人轮廓与底盘,无需另加
7. **UI 红/金强调对:纹带不管面板——需补图标+文案冗余,且破坏性确认禁止与常规确认同位(位置通道)** ←原三
8. 失败暮蓝 vs 小人蓝:状态互斥,不承载区分,无需冗余
9. 图标键族(§4.6):图标身份全靠纹样形状通道(一键一纹样,色相不承载身份);未开格类型预读 = **类型微光单通道**(五组色带,§4.2;2026-09-28 角嵌退役批起色相+明度双码——明度档间互辨承载色弱场景);极性金/红对由框形满/缺冗余——色相全失效时图标仍可逐一辨认与定极性。←原三;增益状态×4②;T1 裁决 2026-09-22
### 4.6 图标键契约(Icon Key Contract,键与通道分配已冻结)
### 4.6 图标键契约(Icon Key Contract,2026-09-13 冻结)
> **白话**:图标键 = "哪个数据行配哪枚图标"的固定对照表——效果、奖励、格面类型、敌种各自的数据表主键(effect_id / reward_id / cell_type / enemy_id)直接作图标键,一枚键配一枚专属纹样。**本节冻结的是键与通道的分配,不冻结终稿美术**:纹样造型、具体色值、动画帧归格子样张(§1.4)与资产规格文档定稿。先行冻结是为了 HUD框架/战斗表现开工不必等美术终稿;映射一经冻结**全局恒定、跨 run 禁重掷**(enemy_id / reward_id 键恒定同款纪律),增键走本节修订,禁运行时换图。验收:机检〈各键族图标与数据表主键一一对应、跨 run 不换图〉
> **白话**:图标键 = "哪个数据行配哪枚图标"的固定对照表——效果、奖励、格面类型、敌种各自的数据表主键(effect_id / reward_id / cell_type / enemy_id)直接作图标键,一枚键配一枚专属纹样。**本节冻结的是键与通道的分配,不冻结终稿美术**:纹样造型、具体色值、动画帧归格子样张(§1.4)与 asset-spec 批。先行冻结是为了 HUD框架/战斗表现开工不必等美术终稿;映射一经冻结**全局恒定、跨 run 禁重掷**(enemy_id / reward_id 键恒定同款纪律),增键走本节修订,禁运行时换图。
>
> 承接义务:增益状态×4②(effect_id→icon 映射键契约)· 地图战斗资源×5①(奖励身份恒定可读)· 敌人系统×4①②(敌种图像线索清单成文 + 全局恒定)· 极性提案 §3(类型图标/极性视觉语言)。引用方:HUD框架、战斗表现、教学引导、地图战斗资源库存呈现。
> 承接义务:增益状态×4②(effect_id→icon 映射键契约)· 地图战斗资源×5①(奖励身份恒定可读)· 敌人系统×4①②(敌种图像线索清单成文 + 全局恒定)· 极性提案 §3(类型图标/极性视觉语言)。引用方:HUD框架、战斗表现、教学引导、地图战斗资源库存呈现。
**通道分配(全键族通用,双通道之"形+色")**:
**通道分配(全键族通用,双通道之"形+色")**:
- **身份 = 形状纹样键**:一键一纹样,全作不复用;**色相不承载身份**(七效果不七色——防色相爆炸,亦防色弱失效)。验收:目检〈同族键共图、异族键纹样可辨〉
- **极性 = 色相槽 + 框形**:有利兑付 = 金族(§4.2"金只说收益"),有害兑付 = 红族("红只说危险")——零新增语义色;框形冗余:满框对缺框(§3.4"收益用满与圆,危险用刺与缺")。极性判定的数据源 = 战斗结算 SettlementTable 效果语义(非 magnitude 符号——增益状态×4③),本契约只锁视觉通道、不锁数据映射。验收:目检〈正负极性以金满框/红缺框可辨〉
- **格面身份预读 = 类型微光;徽章 = 附属小图标**:格面的类型身份预读画在格上——类型走类型微光(§3.2/§4.2,底效泛光不入遮挡栈),禁止构成格内第二焦点(§3.4);徽章小图标族用于库存条与结算明细。角嵌位已不存在。验收:目检〈格面类型预读仅微光且非第二焦点;徽章仅出现于库存条与结算明细〉
- **未开格预读 = 类型微光单通道**(色相+明度双码):微光五组色带见 §4.2「类型微光色带」。只表达类型、不呈现极性、不泄雷/安(信息定义见《HUD框架》「未开格类型微光」节;约束出处见《地图战斗资源》极性提案 R3);兑付面与库存徽章可用语义族色,终稿归格子样张(§1.4)。验收:目检〈未开格类型预读仅微光,无极性与雷/安信息〉
- **身份 = 形状纹样键**:一键一纹样,全作不复用;**色相不承载身份**(七效果不七色——防色相爆炸,亦防色弱失效)。
- **极性 = 色相槽 + 框形**:有利兑付 = 金族(§4.2"金只说收益"),有害兑付 = 红族("红只说危险")——零新增语义色;框形冗余:满框对缺框(§3.4"收益用满与圆,危险用刺与缺")。极性判定的数据源 = 战斗结算 SettlementTable 效果语义(非 magnitude 符号——增益状态×4③),本契约只锁视觉通道、不锁数据映射。
- **格面身份预读 = 类型微光;徽章 = 附属小图标**:格面的类型身份预读画在格上——类型走类型微光(§3.2/§4.2,底效泛光不入遮挡栈),禁止构成格内第二焦点(§3.4);徽章小图标族用于库存条与结算明细。〔角嵌位随 2026-09-28 退役批消失〕
- **未开格预读 = 类型微光单通道**(色相+明度双码;2026-09-22 T1 裁决立双通道→2026-09-28 角嵌退役批改单通道):微光五组色带见 §4.2「类型微光色带」。只表达类型、不呈现极性、不泄雷/安(信息口径见《HUD框架》「未开格类型微光」节;约束出处见《地图战斗资源》极性提案 R3);兑付面与库存徽章可用语义族色,终稿归样张批。
#### 4.6.1 效果图标键(九行 = 增益状态现行实态;键集事实源归增益状态 GDD 与 `status_effects.tres`,本表键名随其修订自动跟随)
#### 4.6.1 效果图标键(九行 = 增益状态现行实态,2026-09-24 纹样系统定稿;键集事实源归增益状态 GDD 与 `status_effects.tres`,本表键名随其修订自动跟随)
| 键(effect_id) | display_name | 纹样描述 | 极性 |
| 键(effect_id) | display_name | 纹样描述(2026-09-24 用户定稿) | 极性 |
|---|---|---|---|
| attack_up | 增伤 | 白色长剑为主体(大,斜向、剑尖朝右上),右下角一枚小绿色向上箭头 | 有利 |
| ac_up | 盾牌加护 | 正面白色盾牌为主体(大),右下角一枚小绿色向上箭头 | 有利 |
@@ -280,24 +274,24 @@
| empower | 蓄力 | 同 attack_up 白剑主体,右下角两枚小绿色向上箭头(叠放:攻击提升两档) | 有害 |
| fortify | 硬化 | 与 ac_up 完全同图(同为防御提升族:防御提升一档) | 有害 |
- **纹样系统**:主体符号表达属性轴——白剑=攻击轴、白盾=防御轴、白眼=警觉特例;主体右下角的小箭头表达方向与幅度——绿↑=提升一档、红↓=削弱一档、双绿↑=提升两档(蓄力)。同族键共用同图:attack_up=enrage、weaken=sapped、ac_up=fortify;图标不区分归属方,主角/敌方由 HUD 状态槽挂载位置表达。极性列保留为数据语义。验收:目检〈九行纹样按表落图——同族键同图、异族键可辨、箭头数表幅度〉
- **纹样系统(2026-09-24 用户定稿)**:主体符号表达属性轴——白剑=攻击轴、白盾=防御轴、白眼=警觉特例;主体右下角的小箭头表达方向与幅度——绿↑=提升一档、红↓=削弱一档、双绿↑=提升两档(蓄力)。同族键共用同图:attack_up=enrage、weaken=sapped、ac_up=fortify;图标不区分归属方,主角/敌方由 HUD 状态槽挂载位置表达。极性列保留为数据语义;原九行「语义锚」文字描述(向上的力/护盾壁垒/戒备甲亮等)随本批退役。
- 呈现位 = HUD 状态槽(增益状态×4①);堆叠层数呈现归 HUD框架/战斗表现,不入本契约。验收:目检〈效果图标呈现于 HUD 状态槽〉
- 呈现位 = HUD 状态槽(增益状态×4①);堆叠层数呈现归 HUD框架/战斗表现,不入本契约。
#### 4.6.2 奖励徽章键(九型字典正负 18 行 + 清图槽 1 行 = 地图战斗资源现行实态;键集事实源归地图战斗资源 GDD 与 `reward.tres`,本表键名随其修订自动跟随)
#### 4.6.2 奖励徽章键(九型字典正负 18 行 + 清图槽 1 行 = 地图战斗资源现行实态,2026-09-22 核;键集事实源归地图战斗资源 GDD 与 `reward.tres`,本表键名随其修订自动跟随)
| 键(reward_id) | 型/极性 | 徽章语义锚 | 极性视觉 |
|---|---|---|---|
| attack_up | attack 正向 | 增伤增益(与 §4.6.1 同语义域共族、键分立) | 金族满框 |
| attack_up | attack 正向 | 增伤增益(与 §4.6.1 同语义域共族、键分立) | 金族满框 |
| enrage | attack 负向 | 敌方增伤 | 红族缺框 |
| ac_up | defense 正向 | 盾牌加护 | 金族满框 |
| off_guard | defense 负向 | 主角失衡(与 §4.6.1 共族、键分立) | 红族缺框 |
| off_guard | defense 负向 | 主角失衡(与 §4.6.1 共族、键分立) | 红族缺框 |
| heal_minor | heal 正向 | 小额回复 | 金族满框 |
| heal_backlash | heal 负向 | 恢复倒扣(直伤) | 红族缺框 |
| heal_backlash | heal 负向 | 恢复倒扣(直伤) | 红族缺框 |
| spark_aoe | blast 正向 | 爆破群伤 | 金族满框 |
| enemy_alert | blast 负向 | 警觉(引用 alert 状态行,与 §4.6.1 alert 共族) | 红族缺框 |
| weaken | weaken 正向 | 削弱(与 §4.6.1 共族) | 金族满框 |
| sapped | weaken 负向 | 乏力(与 §4.6.1 共族) | 红族缺框 |
| enemy_alert | blast 负向 | 警觉(引用 alert 状态行,与 §4.6.1 alert 共族) | 红族缺框 |
| weaken | weaken 正向 | 削弱(与 §4.6.1 共族) | 金族满框 |
| sapped | weaken 负向 | 乏力(与 §4.6.1 共族) | 红族缺框 |
| pierce_strike | pierce 正向 | 穿刺弹——锐锥 | 金族满框 |
| pierce_snare | pierce 负向 | 穿刺陷阱 | 红族缺框 |
| magic_bolt | magic 正向 | 魔法矢——秘法/星辰 | 金族满框 |
@@ -308,12 +302,13 @@
| gp_lose | gp 负向 | 破财 | 红族缺框 |
| clear_blast | 清图槽 | 清图群伤——blast 族、整图作用域 | 按正向视觉 |
- 负向徽标用于踩雷反噬的结算呈现;权重与摇号语义见《地图战斗资源》。验收:目检〈踩雷反噬结算以红族缺框徽标呈现〉
- 库存常读条义务与兑付/库存语义见《地图战斗资源》。验收:方向性
- 格面预读按**家族类型**微光呈现(§4.6.3;呈现粒度决策见《地图战斗资源》极性提案 R1)。验收:目检〈格面预读仅到家族类型粒度〉
- **资产契约**:19 枚 = `ui/icons/reward_<reward_id>.png`(键 = 本表 reward_id,正向/负向/清图槽同模式;状态图标 §4.6.1 同款「键→文件名→尺寸」模式),源 40×40、显示 20px(格面小记号级)/40px(明细面)双档,尺寸为候选,样张验收时冻结;极性视觉按本表「极性视觉」列(金族满框/红族缺框 = 形状+色彩双通道纪律)。本族现无终稿图,以程序化占位承载;出图工单归 art-assets-checklist/PRD,交付后同步登记 art-status-checklist ✅。验收:机检〈reward_*.png 文件名键与本表 reward_id 一致,源 40×40〉
- 负向徽标用于踩雷反噬的结算呈现;权重与摇号语义见《地图战斗资源》。
- 库存常读条义务与兑付/库存语义见《地图战斗资源》。
- 格面预读按**家族类型**微光呈现(§4.6.3;呈现粒度决策见《地图战斗资源》极性提案 R1)。
- **2026-09-22 勘误**:修订指导底稿曾记 gp 组键名为 gp_gain/gp_loss,以 `reward.tres` 实际键 **gp_coin/gp_lose** 为准(配置是事实源)。
- **资产契约(2026-10-06 P1-2 批——审计「19 枚格面徽章 0 张」缺口完成;状态图标 §4.6.1 模式复刻「键→文件名→尺寸」;尺寸候选,样张批冻结)**:19 枚 = `ui/icons/reward_<reward_id>.png`(键 = 本表 reward_id,正向/负向/清图槽同模式),源 40×40、显示 20px(格面小记号级)/40px(明细面)双档候选;极性视觉按本表「极性视觉」列(金族满框/红族缺框 = 形状+色彩双通道纪律)。**契约立前此族 0 张为已知缺口**(审计 P1-2),程序化占位在役;出图工单归 art-assets-checklist/PRD,交付后 art-status-checklist 顺带 ✅。
#### 4.6.3 格面类型图标键(九类 = 地图战斗资源现行实态;键契约已冻结,终稿归格子样张 §1.4)
#### 4.6.3 格面类型图标键(九类——地图战斗资源现行实态;键契约冻结,终稿归样张批)
| 键(cell_type) | 类型 | 纹样语义锚 |
|---|---|---|
@@ -327,229 +322,229 @@
| action | 行动族 | 行动点/疾步 |
| gp | GP 族 | 钱币/财货 |
- **呈现约束**:未开格呈现**类型微光单通道**(五组色带见 §4.2「类型微光色带」,色相+明度双码;可见范围与时机见《HUD框架》「未开格类型微光」节);不呈现极性、不呈现雷/安(信息定义见该权威定义节);微光组间互辨抽测含明度档互辨(误读率指标出处见《地图战斗资源》极性提案)。验收:目检〈未开格仅微光呈现,无极性与雷/安信息;五组互辨含明度档〉
- **状态声明**:键集九值封闭(attack/defense/heal/blast/weaken/pierce/magic/action/gp,与 RewardTable 现行 cell_type 词表一致),扩键走本节修订。验收:机检〈cell_type 键集九值与 RewardTable 词表一致〉
- 与 §4.6.1 的关系:同语义域的键(attack↔attack_up 等)可共族设计,键分立、纹样须可区分(格面小记号级对槽内图标级)。验收:目检〈同语义域两键纹样同级可辨〉
- **资产契约**:纹带族 = `board/band_<variant_id>.png`(键 = 变异类型 id,键集事实源 = [地形变异](../gdd/systems/排雷循环/地形变异.md)「变异框架与类型清单」region 六行〔荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘〕+ 地形变异文档的池门槛域;通配 global 型〔贫矿脉〕无格子布置域、**不在纹带族**),源 64×64 四边无缝(叠于已开格底)、显示 1× = 格边长,尺寸为候选,样张验收时冻结。呈现锚 = 「变异格未开时无预告呈现(遭遇式)」——纹带是**已开变异格**的标识层(地形变异 GDD 视觉标识条款的素材承载),半透明叠加、禁止侵入数字字形占位区(§4.1 砖心留白同理)。现以程序化占位承载(◐);出图工单归 PRD,交付后同步登记 art-status-checklist ✅。验收:机检〈band_*.png 文件名键与六类型 id 一致,源 64×64〉
- **呈现约束(2026-09-28 角嵌退役批更新)**:未开格呈现**类型微光单通道**(五组色带,§4.2「类型微光色带」,色相+明度双码;样张 v2 定稿 2026-09-28;可见范围与时机见《HUD框架》「未开格类型微光」节);不呈现极性、不呈现雷/安(信息口径见该权威定义节);样张批微光组间互辨抽测含明度档互辨(误读率指标出处见《地图战斗资源》极性提案)。
- **状态声明**:键集九值封闭(attack/defense/heal/blast/weaken/pierce/magic/action/gp,与 RewardTable 现行 cell_type 词表一致),扩键走本节修订。
- 与 §4.6.1 的关系:同语义域的键(attack↔attack_up 等)可共族设计,键分立、纹样须可区分(格面小记号级对槽内图标级)。
- **资产契约(2026-10-06 P1-2 批——审计「6 类地形变异纹带 0 张」缺口完成;尺寸候选,样张批冻结)**:纹带族 = `board/band_<variant_id>.png`(键 = 变异类型 id,键集事实源 = [地形变异](../gdd/systems/排雷循环/地形变异.md)「变异框架与类型清单」region 六行〔荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘〕+ 地形变异文档的池门槛域;通配 global 型〔贫矿脉〕无格子布置域、**不在纹带族**),源 64×64 四边无缝(叠于已开格底)、显示 1× = 格边长。呈现锚 = 「变异格未开时无预告呈现(遭遇式)」——纹带是**已开变异格**的标识层(地形变异 GDD「视觉标识归表现层升级批」的素材承载),半透明叠加、禁止侵入数字字形占位区(§4.1 砖心留白同理)。程序化纹带在役 = 占位(◐);出图工单归 PRD,交付后 art-status-checklist 顺带 ✅。
#### 4.6.4 敌种线索键(映射键 = enemy_id;键集事实源归敌人系统 GDD 与 `data/tables/enemy.tres`)
#### 4.6.4 敌种线索键(映射键 = enemy_id;键集事实源归敌人系统 GDD 与 `data/tables/enemy.tres`,2026-09-22 核)
| 线索通道 | 冻结内容 |
|---|---|
| 意图姿态 | **动画三档**:① 普通攻击 = 常态待机(零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器);③ 重击/硬化类 = 蓄力动作或防御姿态。不显示文字与技能名(信息定义见《敌人系统》意图线索节);三档在灰度/剪影层可辨(双通道纪律) |
| 强度档位 | 体量通道:§5.1 archetype 体量/垂直延伸("大个子多半有重击");护甲类型(light/ward/heavy)视觉区分方案已落 §5.3(候选,样张验收时冻结) |
| 预告时序 | 预告语义见《敌人系统》意图线索节,呈现义务(挖掘期全程可见)见《HUD框架》;本表只锁「结算不另设独立意图播片」的动画形态 |
| 恒定义务 | enemy_id 键映射跨 run 恒定、禁重掷;图像不报数——精确数值的明细承载见《战斗结算》。验收:机检〈enemy_id 键映射跨 run 恒定、禁重掷〉 |
| 意图姿态 | **动画三档**(2026-09-18 用户拍板):① 普通攻击 = 常态待机(零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器);③ 重击/硬化类 = 蓄力动作或防御姿态。不显示文字与技能名(信息口径见《敌人系统》意图线索节);三档在灰度/剪影层可辨(双通道纪律) |
| 强度档位 | 体量通道:§5.1 archetype 体量/垂直延伸("大个子多半有重击");护甲类型(light/ward/heavy)视觉区分方案已落 §5.3(候选,样张批冻结) |
| 预告时序 | 预告语义见《敌人系统》意图线索节,呈现义务(挖掘期全程可见)见《HUD框架》;本表只锁「结算不另设独立意图播片」的动画形态 |
| 恒定义务 | enemy_id 键映射跨 run 恒定、禁重掷;图像不报数——精确数值的明细承载见《战斗结算》 |
- **敌种键集 = 八敌种**:goblin 地精(light)/ bugbear 熊地精(light;**L2 熊地精 = bugbear 复用**,第二关三行显示名熊地精系,同剪影换色板,非独立键)/ ogre_chief 食人魔酋长(light,第一关关底 Boss)/ giant_spider 巨蜘蛛(light,群行 [2,3])/ drider 蛛化卓尔精灵(ward,第二关 Boss)/ duergar 灰矮人(heavy)/ hook_horror 钩爪恐魔(light,群行)/ duergar_king 灰矮人石君王(heavy,第三关 Boss)。键数据与甲档实态见《敌人系统》与 `enemy.tres`。验收:机检〈八敌种键集与 enemy.tres 一致〉
- **资产需求下限三类**(敌人系统登记,呈现归 HUD框架/战斗表现):受击反馈(可共用一组)/ 死亡表现(**灰化+碎裂退场,禁止直接消失**)/ 顺位标记(敌多时可读)。验收:目检〈三类呈现齐备,死亡为灰化+碎裂退场非直接消失〉
- Boss 表现约束:§2.6 紫赤色相独占 + §5.1 Boss 形状独占双通道;键集扩容时随内容修订入本表。验收:目检〈Boss 呈现同时满足色相与形状双独占〉
- 剪影通用性:剪影禁止借特定格底色成立(§5.1)——棋盘/结算/任一战斗舞台同一套。验收:目检〈同一剪影在棋盘与结算两舞台均成立〉
- **敌种键集 = 八敌种**(registry enemy mvp_content,2026-09-25 核):goblin 地精(light)/ bugbear 熊地精(light;**L2 熊地精 = bugbear 复用**,第二关三行显示名熊地精系,同剪影换色板,非独立键)/ ogre_chief 食人魔酋长(light,第一关关底 Boss)/ giant_spider 巨蜘蛛(light,群行 [2,3])/ drider 蛛化卓尔精灵(ward,第二关 Boss)/ duergar 灰矮人(heavy)/ hook_horror 钩爪恐魔(light,群行)/ duergar_king 灰矮人石君王(heavy,第三关 Boss)。键数据与甲档实态见《敌人系统》与 `enemy.tres`。
- **资产需求下限三类**(敌人系统登记,呈现归 HUD框架/战斗表现):受击反馈(可共用一组)/ 死亡表现(**灰化+碎裂退场,禁止直接消失**)/ 顺位标记(敌多时可读)。
- Boss 表现约束:§2.6 紫赤色相独占 + §5.1 Boss 形状独占双通道;键集扩容随内容批入本表。
- 剪影通用性:剪影禁止借特定格底色成立(§5.1)——棋盘/结算/任一战斗舞台同一套。
---
## 5. 角色设计(Character Design)
> **本节内容已整体迁至独立文档 [`character-design.md`](character-design.md)**(小节号 §5.1–§5.6 在该文档中沿用不变)。迁移动机:人设高频改动,独立成档免动圣经;本存根保证既有 "art-bible §5.x" 式引用(§4.1/§4.5/§4.6.4、art-assets-checklist、战斗表现)经此仍可达,新引用直指该文档。
> **本节内容已整体迁至独立文档 [`character-design.md`](character-design.md)**(2026-09-23 v1.7 抽离批;小节号 §5.1–§5.6 在该文档中沿用不变)。迁移动机:人设高频改动,独立成档免动圣经;本存根保证既有 "art-bible §5.x" 式引用(§4.1/§4.5/§4.6.4、art-assets-checklist、战斗表现)经此仍可达,新引用直指该文档。
内容速览:§5.1 角色剪影与轮廓通道 · §5.2 头身比与体量档位 · §5.3 护甲类型视觉区分(候选,样张冻结终稿) · §5.4 主角三态规格(形象锚 = 蓝兜帽披肩的暖装小冒险者:圆头挖掘铲 + 冒险背包插朱红矩形旗(与棋盘封印色脱钩) + 护目镜腰灯) · §5.5 八敌种逐卡 · §5.6 意图准备动画三档。
内容速览:§5.1 角色剪影与轮廓通道 · §5.2 头身比与体量档位 · §5.3 护甲类型视觉区分(候选,样张批冻结) · §5.4 主角三态规格(形象锚 = 蓝兜帽披肩的暖装小冒险者:**圆头挖掘铲 + 冒险背包插旗朱红矩形旗(2026-09-28 与棋盘封印色脱钩) + 护目镜腰灯**,2026-09-23 特征批〔旗色史:旗朱→封印紫→封印黑 2026-09-28〕) · §5.5 八敌种逐卡 · §5.6 意图准备动画三档。
---
## 6. 环境与场景(Environment & Scenes)
> 本节定格面之外的场景容器怎么画:战斗舞台背景、营地场景、三关图类型色板方向、关卡间下探过渡、棋盘视野迷雾遮蔽(§6.5)。格内画法归 §3.2,情绪与能量归 §2,换色模板归 §4.3;本节不新增信息通道、不承载玩法语义。屏级分区布局(三区 B/C/D)的裁决权归 battle.md,本节只定环境资产的视觉规格。
> 本节定格面之外的场景容器怎么画:战斗舞台背景、营地场景、三关图类型色板方向、关卡间下探过渡、棋盘视野迷雾遮蔽(§6.5)。格内画法归 §3.2,情绪与能量归 §2,换色模板归 §4.3;本节不新增信息通道、不承载玩法语义。屏级分区布局(三区 B/C/D)的裁决权归 battle.md,本节只定环境资产的视觉规格。
### 6.1 战斗舞台背景(B 区环境)
### 6.1 战斗舞台背景(B 区环境)
- **双舞台常驻下的 B 区环境**:战斗画面区(站桩立绘所在)的背景 = 明亮地窟,与 §1 风格基调同源——暖倾向、无压抑;背景整体**低对比自然后退**(§3.4 焦点预算延伸):背景禁止构成完整凸形、禁止抢 B 区立绘与 D 区数字的焦点,识别层级恒为 立绘 > 背景。验收:目检〈背景无完整凸形、立绘最抢眼〉
- **舞台正面目标**:B 区 = **地窟看台**——立绘脚下地台/落影承载(世界中层),背景环境层低饱和后退、明度走世界底层档,允许光源氛围(洞顶光柱/暖光晕候选);角色禁止直接贴平色底。规格与终值归整屏样张闸(§1.4)冻结。验收:目检〈立绘脚下有地台/落影,非贴平色底〉
- **明度分层适用**:背景氛围的情绪表达仍禁止以压暗/渐晕实现(§2 禁令);空间纵深与洞穴感走明度分层允许域——世界底层档、光照氛围、色相饱和与形体承载(§3.2 同款逻辑)。验收:方向性
- **图类型联动**:背景 tint 走 §4.3 `wall_ambient` 槽,随三关换挡(方向候选见 §6.3);紫赤仅 Boss 战允许短暂进入背景氛围纹样(§2.6 色相独占的舞台侧延伸,高饱和独占不变),非 Boss 战高饱和紫赤禁入。验收:机检〈背景吸色无非 Boss 高饱和紫赤〉
- **资产渠道**:✦ 插画级(演出类场景贴图,清单 §4 联动登记);战斗一屏样张(§1.4 清单 1.2)含背景出样。**资产契约(尺寸候选,样张冻结终稿)**:基准一张 = `stage/stage_bg.png`(源 1280×270,B 区全高含明细带底;三关换挡走 §4.3 `wall_ambient` tint 槽,不按板出三张——换皮成本复用棋盘同款逻辑),显示 1×;呈现约束按本节各条(低对比后退/明度世界底层档/光柱氛围保留)。程序化平色+光柱现行使用 = 占位;插画工单归 PRD,交付后 art-status-checklist 同步 ✅。
- **双舞台常驻下的 B 区环境**:战斗画面区(站桩立绘所在)的背景 = 明亮地窟,与 §1 风格基调同源——暖倾向、无压抑;背景整体**低对比自然后退**(§3.4 焦点预算延伸):背景禁止构成完整凸形、禁止抢 B 区立绘与 D 区数字的焦点,识别层级恒为 立绘 > 背景。
- **舞台正面目标(2026-09-29 v3.0 新增——原条只有负面约束,病根 6)**:B 区 = **地窟看台**——立绘脚下地台/落影承载(世界中层),背景环境层低饱和后退、明度走世界底层档,允许光源氛围(洞顶光柱/暖光晕候选);角色禁止直接贴平色底。规格与终值归整屏样张闸(§1.4)冻结。
- **明度分层适用(2026-09-29 v3.0 改条)**:背景氛围的情绪表达仍禁止以压暗/渐晕实现(§2 禁令);空间纵深与洞穴感走明度分层允许域——世界底层档、光照氛围、色相饱和与形体承载(§3.2 同款逻辑)。
- **图类型联动**:背景 tint 走 §4.3 `wall_ambient` 槽,随三关换挡(方向候选见 §6.3);紫赤仅 Boss 战允许短暂进入背景氛围纹样(§2.6 色相独占的舞台侧延伸,高饱和独占不变),非 Boss 战高饱和紫赤禁入。
- **资产渠道**:✦ 插画级(演出类场景贴图,清单 §4 联动登记);战斗一屏样张(§1.4 清单 1.2)含背景出样。**资产契约(2026-10-06 P1-2 批——审计「B 区舞台背景插画 0 张」缺口完成;尺寸候选,样张批冻结)**:基准一张 = `stage/stage_bg.png`(源 1280×270,B 区全高含明细带底;三关换挡走 §4.3 `wall_ambient` tint 槽,不按板出三张——换皮成本复用棋盘同款逻辑),显示 1×;呈现约束按本节各条(低对比后退/明度世界底层档/光柱氛围保留)。程序化平色+光柱在役 = 占位(◐);插画工单归 PRD,交付后 art-status-checklist 顺带 ✅。
### 6.2 营地场景
- **情绪与色域**:承载 §2.8——全作最暖:烛光黄 `#F6C667` × 木色 `#8A5A34`(§7.2),中等饱和,无棋盘式高对比。
- **场景构成**:一张可点击的营地场景图(1280×800,与工程档同幅面)。远景=天空与远山;中景=木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与**半开的营地大门**(门洞内一条通向远处的小路);近景=中央篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板。**中景带自底部往上约 2/3 处起**;**近景物件可覆盖围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**。七个可点物件的功能绑定归 `camp.md`,本节只定画法。
- **交付形态**:**背景 1 层 + 七个物件各 1 层**(**帐篷**为承载件、不可点),共 8 张透明 PNG + 一份坐标表(贴图矩形/命中矩形/z 序)。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。
- **色彩与氛围依据**:营地场景的冷暖分工(冷的大面积后退、暖的小面积前进)、空气透视三档与大面积用色规则 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`。本节不复述色值;新增环境色按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门)。
- **交互四态(零新素材,全部程序化)**:悬停=**轮廓亮边 + 浮动**(浮动须整像素吸附);按下=位移 1px;**禁用/未解锁=降明度 + 去饱和**(§2 已缩窄至情绪域,状态表达不受限),**仅锻炉(锻造工坊)与宝箱(秘宝匣)两件会有此态**;已解锁=常态。
- **钥石余额**:常驻画面顶部的一行小 HUD 栏(高 48px,**浮层覆盖,不挤压画面**)。
- **事实源边界**:解锁条件/价格/存档写入归元进度 GDD 与 `MetaProgression`;素材命名与路径归 `asset-path-map`;清单登记归 `art-assets-checklist` §3.5。
- **待裁(本节不填数,由作者定)**:① **营地环境子板色值**——色彩脚本只给定色方向,落地色值属 §4.1 单点,须过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门;② **门扇与门洞的高宽比例**——中景带自底部往上约 2/3 处起之后高度有限,门扇高度远超围墙带,两者关系待作者重定;③ **帐篷的落地尺寸**——帐篷体量大于被替换的旧承载件,尺寸待作者重定;坐标草案的现行出口见 `camp.md` 的指路句。
- **情绪与色域**:承载 §2.8——全作最暖:烛光黄 `#F6C667` × 木色 `#8A5A34`(§7.2),中等饱和,无棋盘式高对比。
- **场景构成**:一张可点击的营地场景图(1280×800,与工程档同幅面)。远景=天空与远山;中景=木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与**半开的营地大门**(门洞内一条通向远处的小路);近景=中央篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板。**中景带自底部往上约 2/3 处起**;**近景物件可覆盖围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**。七个可点物件的功能绑定归 `camp.md`,本节只定画法。
- **交付形态**:**背景 1 层 + 七个物件各 1 层**(**帐篷**为承载件、不可点),共 8 张透明 PNG + 一份坐标表(贴图矩形/命中矩形/z 序)。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。
- **色彩与氛围依据**:营地场景的冷暖分工(冷的大面积后退、暖的小面积前进)、空气透视三档与大面积用色口径 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`。本节不复述色值;新增环境色按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门)。
- **交互四态(零新素材,全部程序化)**:悬停=**轮廓亮边 + 浮动**(浮动须整像素吸附);按下=位移 1px;**禁用/未解锁=降明度 + 去饱和**(§2 已缩窄至情绪域,状态表达不受限),**仅锻炉(锻造工坊)与宝箱(秘宝匣)两件会有此态**;已解锁=常态。
- **钥石余额**:常驻画面顶部的一行小 HUD 栏(高 48px,**浮层覆盖,不挤压画面**)。
- **事实源边界**:解锁条件/价格/存档写入归元进度 GDD 与 `MetaProgression`;素材命名与路径归 `asset-path-map`;清单登记归 `art-assets-checklist` §3.5。
- **待裁(本节不填数,由作者定)**:① **营地环境子板色值**——色彩脚本只给定色方向,落地色值属 §4.1 单点,须过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门;② **门扇与门洞的高宽比例**——中景带自底部往上约 2/3 处起之后高度有限,门扇高度远超围墙带,两者关系待作者重定;③ **帐篷的落地尺寸**——帐篷体量大于被替换的旧承载件,尺寸待作者重定;坐标草案的现行出口见 `camp.md` 的指路句。
### 6.3 三关图类型色板候选(board_type 换挡方向)
### 6.3 三关图类型色板候选(board_type 换挡方向)
- **模板与不变槽**:换皮只换 §4.3 四槽(`tile_closed`/`tile_open`/`wall_ambient`/`variant_tint`),信息与语义层全图类型恒定——本节只给三关**方向性候选**,终值按 §4.3 现行规则重跑 §1.4 格子样张冻结。
- **深度轴叙事**(§2.7 下探过渡):色相层进预告「更深更险」,明度档不动——
- **dungeon 地精洞窟**(第一关):现行掩尘系暖石棕(`#C9AF83` 基)即基准档,`wall_ambient` 暖棕;
- **underdark 幽暗地域**(第二关;方向:**冷调幽暗**):主调**灰白蓝**候选(低饱和蓝灰域,`tile_closed`),少量紫黑倾向点缀——紫系取紫罗兰域(≈256°)候选,禁入带 300°–345° 不变;`wall_ambient` 冷蓝灰;
- **deep_underdark 灰矮人矿城**(第三关;方向:**灰黑主调 + 矿脉金属色点缀**候选,守 §4.1 明度地板 L≥0.30):矿脉点缀走金属质感族(冷银/铜灰候选)——「金属色」以材质感承载,奖励金亮金仍禁上格底(§4.2),不占用收益语义金;`wall_ambient` 铁灰。
- **共同纪律**:三套候选色相均须与五组类型微光色带(§4.2)抽测互辨(格底与微光同在未开格面,邻近色相有吞没风险);避 300°–345° 禁入带;L≥0.30、饱和 ≤0.55(§4.1 硬指标)。
- **模板与不变槽**:换皮只换 §4.3 四槽(`tile_closed`/`tile_open`/`wall_ambient`/`variant_tint`),信息与语义层全图类型恒定——本节只给三关**方向性候选**,终值按 §4.3 现行规则重跑 §1.4 格子样张冻结。
- **深度轴叙事**(§2.7 下探过渡):色相层进预告「更深更险」,明度档不动——
- **dungeon 地精洞窟**(第一关):现行掩尘系暖石棕(`#C9AF83` 基)即基准档,`wall_ambient` 暖棕;
- **underdark 幽暗地域**(第二关;2026-09-23 用户裁方向:**冷调幽暗**):主调**灰白蓝**候选(低饱和蓝灰域,`tile_closed`),少量紫黑倾向点缀——紫系取紫罗兰域(≈256°)候选,禁入带 300°–345° 不变;`wall_ambient` 冷蓝灰;
- **deep_underdark 灰矮人矿城**(第三关;2026-09-23 用户裁方向):**灰黑主调 + 矿脉金属色点缀**候选(守 §4.1 明度地板 L≥0.30);矿脉点缀走金属质感族(冷银/铜灰候选)——「金属色」以材质感承载,奖励金亮金仍禁上格底(§4.2),不占用收益语义金;`wall_ambient` 铁灰。
- **共同纪律**:三套候选色相均须与五组类型微光色带(§4.2)抽测互辨(格底与微光同在未开格面,邻近色相有吞没风险);避 300°–345° 禁入带;L≥0.30、饱和 ≤0.55(§4.1 硬指标)。
### 6.4 关卡间下探过渡
- **视觉承载**(§2.7 情绪的画法面):竖井下移镜头,两侧砖层色板渐变,预演下一层主色——渐变取色 = 下一关 `tile_closed`/`wall_ambient` 槽候选值入画,只动色相/饱和;明度带随深度微移候选归 §2.7(空间纵深表达,非情绪)。
- **资产与排期**:程序化色板渐变(⚙ 脚本管线),无独立插画需求(候选);演出编排与时序归冒险结构/战斗表现 GDD,本节只定渐变取色与明度纪律。
- **视觉承载**(§2.7 情绪的画法面):竖井下移镜头,两侧砖层色板渐变,预演下一层主色——渐变取色 = 下一关 `tile_closed`/`wall_ambient` 槽候选值入画,只动色相/饱和;明度带随深度微移候选归 §2.7(空间纵深表达,非情绪)。
- **资产与排期**:程序化色板渐变(⚙ 脚本管线),无独立插画需求(候选);演出编排与时序归冒险结构/战斗表现 GDD,本节只定渐变取色与明度纪律。
### 6.5 视野迷雾与黑暗区域渲染(棋盘灯光区)
### 6.5 视野迷雾与黑暗区域渲染(棋盘灯光区,2026-10-03 立节)
> **白话**:战斗棋盘不再整板可见——只有以角色为中心的菱形灯光区看得清,区外没开过的格子沉入全黑,开过的格子留下灰阶的「记忆」。像在洞穴里举灯探索。机制规则(可见性/生命周期/纯呈现纪律)的现行事实源 =《HUD框架》「视野迷雾:菱形灯光区与黑暗边界」节(全项目唯一事实源,引用只指路),该节点名「黑暗区域渲染规格(暗淡态明度、迷雾过渡)归 art-bible」,本节即其承载——只管"怎么画",不复述机制。纯呈现层:格态、数字真值、清图判定零影响;零素材新增(全部程序化遮罩/调制);类型微光语义零改动。
> **白话**:战斗棋盘不再整板可见——只有以角色为中心的菱形灯光区看得清,区外没开过的格子沉入全黑,开过的格子留下灰阶的「记忆」。像在洞穴里举灯探索。机制口径(可见性/生命周期/纯呈现纪律)的现行事实源 =《HUD框架》「视野迷雾:菱形灯光区与黑暗边界」节(全项目唯一事实源,引用只指路),该节点名「黑暗区域渲染规格(暗淡态明度、迷雾过渡)归 art-bible」,本节即其承载——只管"怎么画",不复述机制。纯呈现层:格态、数字真值、清图判定零影响;零素材新增(全部程序化遮罩/调制);类型微光语义零改动。
- **菱形灯光区(遮罩几何)**:灯光区 = 以角色所在格为中心、**曼哈顿距离 ≤ LIGHT_RADIUS** 的格集,区内照常渲染。LIGHT_RADIUS 走表值/常量参数化,**基准 8**——现行事实源「灯光区起步半径=微光环 8 格」的落码取值(半径数值沿微光「周围 8 格」的 8 起步,实测后可调)。**两圈分立注**:类型微光作用圈 = 英雄贴身 8 邻格(与可挖域同圈,现行事实源《HUD框架》「未开格类型微光」节),**不随灯光区扩**(权威文档配套裁决)——灯光区只裁可见性,微光锚仍是可挖域,两圈几何分立、半径参数互不读写。提灯插值位:FOG_LANTERN_BONUS = **+2**(flat 整数档,权威文档配套裁决;落码常量名 `board_view.gd FOG_LANTERN_BONUS`),**已接效果入库**(载体=秘宝·同调位 fog_lantern「魔法提灯」行,组合根每战装配链显式重算接线;呈现画法零改动——本节只管"怎么画",机制现行事实源仍归《HUD框架》)。边界随角色格位逐格重算,无半径插值动画。←现行事实源;§2 光照氛围允许域
- **区外未开格:全黑不渲染(不透明档,照抄现行事实源)**:现行事实源原文「区外**未开格=全黑不渲染**」——不透明纯黑(L=0),**非高遮蔽半透档**。理由注记(为何不用高遮蔽档):①三档信息阶梯干净——未开格不可见=零信息、记忆格=弱信息、活格=全信息,叠读不打架;②高遮蔽档下砖面纹理隐约残留,与「类型微光=未开格唯一信息通道」(§4.2/§4.6.3)冲突——纹理残影泄格面观感;③现行事实源验收线「区外未开格不可见」须二值态才能逐格断言。←现行事实源;§4.2
- **区外已开格:暗淡记忆态(灰显可读档)**:画法 = **地板纹理原样 + 灰罩压顶**——纹理直画后 `FOG_MEMORY_PLATE #3A3A3A` α0.66 灰罩盖顶,素材零改动(原「程序化素色记忆板平涂」已弃用);格内元素灰阶随罩隐现:**记忆墨 #C4C4C4(中性灰 L≈0.55)**。双峰板色牌格在记忆态**退牌归板**——牌面并入记忆态、数字以记忆墨直画。**旗不在记忆态面**:旗只插在未开格,区外未开格全黑——旗印记随格隐没、灯光区移回复显(纯呈现,旗数据零影响,现行事实源的显式推论)。←HUD框架 可访问性基线;§4.1;§3.2
- **记忆态可读性判据**:数字对合成底**归一后(真实渲染面)三关 ≥4.5:1**(WCAG 硬指标;合成底 L1 #4C4038/L2 #3F4147/L3 #3E4048;行值与素材随动,测点=ui_contrast_probe 迷雾段)。**归一注**:装载亮度归一是现行生产路径(`board_view._normalize_floor_luma`),原图均值≠渲染面,复算须落归一后底。可辨性自查(三通道,随样张/实机自检):①对全黑未开格——合成底 #3E3731(L≈0.036)对 L0,实机目检常光档可分(暗房档一并抽检);②对灯光区内活态已开格——主判据=数字墨灰度差(记忆墨 L0.55 对亮墨三档 L≥0.75 / 色牌 #F5EAD3 L≈0.83,灰度 ΔL≥0.20)+ 色相通道(记忆态近中性灰,对活态暖底暖墨);③格态三形在记忆态保持灰度可辨——形通道经纹理+灰罩形态不变,沿 §1.4 验收 2 同源判据。验收:机检〈ui_contrast_probe 迷雾段记忆墨对比 ≥4.5:1〉←§1.4
- **迷雾过渡边:硬边(逐格二值)**:灯光区边界 = **整格二值切换**(区内全渲染 / 区外全黑或记忆态),**不做渐变过渡带、不做半透明晕边**。理由:①边界是信息边界不是氛围晕——渐变中间态 =「看得见但看不清」,破数字可读硬指标与记忆态明度档;②现行事实源验收断言(边界格呈态、两态逐位一致)须格级二值可判;③与砖面整格表达(§3.2 高墙瓦片体系)、ITB 格内信息分层气质同轴。无动画,减动效无涉。←现行事实源;§1.2 原则一;§3.2
- **与结算期去饱和的叠加共存**:结算期去饱和偏移(settlement_fx_view,同族先例)与迷雾调制叠加规则 = **记忆态为终态档**——区外已开格在结算期保持记忆态档、**不再叠加**结算去饱和(灰显已含去饱和语义,二次调制无信息增量且明度不可控);灯光区内格照常吃结算去饱和;全黑格两态无差。叠加态随实施批共存校验 + 探针断言(provisional,实机目检可代拍)。验收:机检〈叠加态下记忆态灰显不被二次调制〉
- **§2 明度禁令自查**:迷雾遮蔽与记忆态灰显属 §2 明度分层**光照氛围允许域**(洞穴举灯的空间光照呈现,与 Moonlighter 取列「亮地板/暗深渊/发光体」光照层次同族),非情绪压暗(§9.2 Darkest 反面条款不触——那禁的是情绪表达)、非状态明暗编码(格态语义仍由形+色相承载,灰度下三态可辨不受扰)。←§2;§9.1;§9.2
- **菱形灯光区(遮罩几何)**:灯光区 = 以角色所在格为中心、**曼哈顿距离 ≤ LIGHT_RADIUS** 的格集,区内照常渲染。LIGHT_RADIUS 走表值/常量参数化,**基准 8**——现行事实源「灯光区起步半径=微光环 8 格」的落码口径(半径数值沿微光「周围 8 格」的 8 起步,实测后可调)。**两圈分立注**:类型微光作用圈 = 英雄贴身 8 邻格(与可挖域同圈,现行事实源《HUD框架》「未开格类型微光」节),**不随灯光区扩**(权威文档配套拍板④)——灯光区只裁可见性,微光锚仍是可挖域,两圈几何分立、半径参数互不读写。提灯插值位:FOG_LANTERN_BONUS = **+2**(flat 整数档,权威文档配套拍板;落码常量名 `board_view.gd FOG_LANTERN_BONUS`——本条旧记 LIGHT_RADIUS_LANTERN_BONUS 系文档侧拟名,随迭代 19 卡④入库对齐实名),**已接效果入库**(2026-10-08 迭代 19 卡④:载体=秘宝·同调位 fog_lantern「魔法提灯」行,组合根每战装配链显式重算接线;呈现画法零改动——本节只管"怎么画",机制现行事实源仍归《HUD框架》)。边界随角色格位逐格重算,无半径插值动画。←现行事实源;§2 光照氛围允许域
- **区外未开格:全黑不渲染(不透明档,照抄现行事实源)**:现行事实源原文「区外**未开格=全黑不渲染**」——不透明纯黑(L=0),**非高遮蔽半透档**。理由注记(为何不用高遮蔽档):①三档信息阶梯干净——未开格不可见=零信息、记忆格=弱信息、活格=全信息,叠读不打架;②高遮蔽档下砖面纹理隐约残留,与「类型微光=未开格唯一信息通道」(§4.2/§4.6.3)冲突——纹理残影泄格面观感;③现行事实源验收线「区外未开格不可见」须二值态才能逐格断言。←现行事实源;§4.2
- **区外已开格:暗淡记忆态(灰显可读档,2026-10-06 UI 设计对齐批 P10 乙换装)**:画法 = **地板纹理原样 + 灰罩压顶**(demo 口径换装——原「程序化素色记忆板平涂」退役;纹理直画后 `FOG_MEMORY_PLATE #3A3A3A` α0.66 灰罩盖顶,素材零改动)+ 格内元素灰阶随罩隐现:**记忆墨 #C4C4C4(中性灰 L≈0.55)**;数字对合成底实测 **归一后(真实渲染面)三关 5.71–5.93:1 ≥4.5:1**(WCAG 硬指标;合成底 L1 #4C4038/L2 #3F4147/L3 #3E4048;行值与素材随动,测点=ui_contrast_probe 迷雾段)。**归一口径注**:装载亮度归一是现行生产路径(`board_view._normalize_floor_luma`),原图均值≠渲染面,复算须落归一后底。双峰板色牌格在记忆态**退牌归板**——牌面并入记忆态、数字以记忆墨直画。可辨性自查判据(三通道,落码后随样张/实机自检):①对全黑未开格——合成底 #3E3731(L≈0.036)对 L0,实机目检常光档可分(暗房档一并抽检);②对灯光区内活态已开格——主判据=数字墨灰度差(记忆墨 L0.55 对亮墨三档 L≥0.75 / 色牌 #F5EAD3 L≈0.83,灰度 ΔL≥0.20)+ 色相通道(记忆态近中性灰,对活态暖底暖墨);格底明度差为辅助通道;③格态三形在记忆态保持灰度可辨——形通道经纹理+灰罩形态不变,沿 §1.4 验收 2 同源判据。**旗不在记忆态面**:旗只插在未开格,区外未开格全黑——旗印记随格隐没、灯光区移回复显(纯呈现,旗数据零影响,现行事实源口径的显式推论)。←HUD框架 可访问性基线;§4.1;§1.4;§3.2
- **迷雾过渡边:硬边(逐格二值)**:灯光区边界 = **整格二值切换**(区内全渲染 / 区外全黑或记忆态),**不做渐变过渡带、不做半透明晕边**。理由:①边界是信息边界不是氛围晕——渐变中间态 =「看得见但看不清」,破数字可读硬指标与记忆态明度档;②现行事实源验收断言(边界格呈态、两态逐位一致)须格级二值可判;③与砖面整格表达(§3.2 高墙瓦片体系)、ITB 格内信息分层气质同轴。无动画,减动效无涉。←现行事实源;§1.2 原则一;§3.2
- **与结算期去饱和的叠加共存**:结算期去饱和偏移(settlement_fx_view,同族先例)与迷雾调制叠加口径 = **记忆态为终态档**——区外已开格在结算期保持记忆态档、**不再叠加**结算去饱和(灰显已含去饱和语义,二次调制无信息增量且明度不可控);灯光区内格照常吃结算去饱和;全黑格两态无差。叠加态随实施批共存校验 + 探针断言(provisional,实机目检可代拍)。←需求卡片 T3/T4
- **§2 明度禁令自查**:迷雾遮蔽与记忆态灰显属 §2 明度分层**光照氛围允许域**(洞穴举灯的空间光照呈现,与 Moonlighter 取列「亮地板/暗深渊/发光体」光照层次同族),非情绪压暗(§9.2 Darkest 反面条款不触——那禁的是情绪表达)、非状态明暗编码(格态语义仍由形+色相承载,灰度下三态可辨不受扰)。←§2;§9.1;§9.2
---
## 7. UI 设计(UI Design)
> **白话**:这一章管 UI 长什么样式,UI 的具体选择都在本章定义。布局落在哪里、占多少像素,屏级权威在 battle.md、系统契约在 HUD框架;本章节内容——形状语法与色板细则(自 §3.3/§4.4 迁入)、布局栅格、字体字号、营地界面画法、引擎原生控件与按钮状态规格。
> **白话**:这一章管 UI 长什么样式,UI 的具体选择都在本章定义。布局落在哪里、占多少像素,屏级权威在 battle.md、系统契约在 HUD框架;本章节内容——形状语法与色板细则(自 §3.3/§4.4 迁入)、布局栅格、字体字号、营地界面画法、引擎原生控件与按钮状态规格。
### 7.1 形状语法细则(自 §3.3 迁入)
### 7.1 形状语法细则(自 §3.3 迁入)
**半拟物:容器穿戏服,数据素面。**面板=石板圆角矩形(与地砖同圆角率,全 UI 恒定),按钮=凸圆角的"可按的砖"(按压态=下沉一档,运动手段 §2),仪式性确认(胜利等)=圆形蜡封(呼应 §2.4);数字框、准线、进度条一律直角细线、无纹理;**数字色牌 = 直角小素牌**(后备件)——宽随数字位、无纹理无装饰,仅复杂格底兜底时垫于数字正下方一层(色值 §4.1),属"须精确的信息"侧、走直角律。判据:圆角=可触摸的物件,直角=须精确的信息。与参考分界:取 ITB 的信息分层,弃其冷硬斜切 HUD;取 Moonlighter 的木石亲和,不做全拟物。验收:目检〈面板圆角/按钮凸圆角/数据直角三态可辨〉
**半拟物:容器穿戏服,数据素面。**面板=石板圆角矩形(与地砖同圆角率,全 UI 恒定),按钮=凸圆角的"可按的砖"(按压态=下沉一档,运动手段 §2),仪式性确认(胜利等)=圆形蜡封(呼应 §2.4);数字框、准线、进度条一律直角细线、无纹理;**数字色牌 = 直角小素牌**(2026-09-29 v3.0 后备件)——宽随数字位、无纹理无装饰,仅复杂格底兜底时垫于数字正下方一层(色值 §4.1),属"须精确的信息"侧、走直角律。判据:圆角=可触摸的物件,直角=须精确的信息。与参考分界:取 ITB 的信息分层,弃其冷硬斜切 HUD;取 Moonlighter 的木石亲和,不做全拟物。
- **极性强调冗余**(§4.5⑦):UI 红/金强调对必须补图标+文案冗余;破坏性确认与常规确认禁止同位(位置通道)。验收:目检〈红/金强调位有图标+文案,破坏性确认独立位〉
- **圆角率单点注记**:容器/按钮圆角值收敛为全站唯一 `RADIUS_OBJECT = 5`(ui_kit 单点;面板与按钮同圆角率即满足本节「全 UI 恒定圆角率」);数据框/数字牌/AP 点阵/准线/血条维持 0 直角;**例外三件保留**=标题屏底座椭圆 17/结算金印 200(圆形蜡封)/状态脉冲环 100(圆形)。验收:机检〈ui_kit 圆角取值仅 RADIUS_OBJECT 与例外三件〉
- **九宫格切片注记**:panel 石皮 `PANEL_SLICE = 16`(button 维持 8、仪式框维持 12——素材 panel_stone/button_stone 均 48×48 不动);`panel_skinned` 工厂内 **content margin clamp**=低于切片的分量自动抬到切片(石框纹理不吃内容;工厂单点,调用点零改)。验收:机检〈九宫格面板切片 16、边角无错位〉
- **资产渠道**:♻ 九宫格面板/按钮改色过复用闸优先(清单 §0),⚙ 兜底;样式终稿随 §1.4 样张闸后定稿。验收:方向性
- **占位完成条款**:程序化占位 = **临时态**,任何屏的终稿闸 = §1.4 整屏样张过闸;占位态禁止跨里程碑。验收:目检〈过闸屏无程序化占位残留〉
- **实现侧边界**:样式收敛 ui_kit;ui_kit 的工程契约归《HUD框架》,本节不重述。
- **极性强调冗余**(§4.5⑦):UI 红/金强调对必须补图标+文案冗余;破坏性确认与常规确认禁止同位(位置通道)。
- **圆角率单点注记(2026-10-06 UI 设计对齐批 P5 甲)**:容器/按钮圆角值收敛为全站唯一 `RADIUS_OBJECT = 5`(ui_kit 单点;与主/危按钮原 5px 同值,面板与按钮同圆角率即满足本节「全 UI 恒定圆角率」);数据框/数字牌/AP 点阵/准线/血条维持 0 直角;**例外三件保留**=标题屏底座椭圆 17/结算金印 200(圆形蜡封)/状态脉冲环 100(圆形)。收敛前实测 0/2/3/4/5/6/8/17/100/200 十档并存。
- **九宫格切片注记(2026-10-06 UI 设计对齐批 P4 乙+Q1 丙)**:panel 石皮 `PANEL_SLICE = 16`(原 8→16,与原型 .plate9 slice16 对齐;button 维持 8、仪式框维持 12——素材 panel_stone/button_stone 均 48×48 不动);`panel_skinned` 工厂内 **content margin clamp**=低于切片的分量自动抬到切片(石框纹理不吃内容;工厂单点,调用点零改)。本节原无切片条款,此为新增注记。
- **资产渠道**:♻ 九宫格面板/按钮改色过复用闸优先(清单 §0),⚙ 兜底;样式终稿随 §1.4 样张闸后定稿。
- **占位完成条款(2026-09-29 v3.0 新增,病根 8)**:程序化占位 = **临时态**,任何屏的终稿闸 = §1.4 整屏样张过闸;占位态禁止跨里程碑。
- **实现侧边界**:样式收敛 ui_kit;ui_kit 的工程契约归《HUD框架》,本节不重述。
### 7.2 UI 色板细则(自 §4.4 迁入)
### 7.2 UI 色板细则(自 §4.4 迁入)
**全局文字可读性硬指标**:**一切文字**(含菜单/说明/tooltip/按钮/底栏,无区域例外)对其直接背景 **≥4.5:1**(WCAG AA);大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;**文字禁直接压纹理底**——垫容器或罩层。核验闸 = §1.4 整屏样张。验收:机检〈ui_contrast_probe 全文字位 ≥4.5:1〉
**全局文字可读性硬指标(2026-09-29 v3.0 新增,病根 7)**:**一切文字**(含菜单/说明/tooltip/按钮/底栏,无区域例外)对其直接背景 **≥4.5:1**(WCAG AA);大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;**文字禁直接压纹理底**——垫容器或罩层(现行菜单说明 2.58:1、C 区属性行 1.31:1 式违例自此有条款可依)。核验闸 = §1.4 整屏样张。
**数据素面的墨来自世界的墨**:UI 数字、进度、准线一律主墨 `#33261B` 系——棋盘与面板读同一种墨;容器穿木石材质色,数据只穿墨。**营地与棋盘**:菜单容器背景用全作最暖的烛光黄 `#F6C667` 与木色 `#8A5A34`(§2.8;场景构成 §6.2);战斗容器转中性石色,防暖色渗入棋盘分走数字对比预算——氛围色向可读色让路。UI 不新增语义色,一切强调沿用 §4.2。**暗底呈现态双色墨制**(样张冻结):暗底系(已过/未来/候选/Boss 方块、暗数据框)图标与读数一律暖白 `#F0E2C8`(WARM)、亮底(当前金 `#F0A828`)转深墨 `#181410`(INK_ROUTE)——各态对比 ≥4.5:1;已过节点暗金冻结 `GOLD_DONE #68562C`(专用呈现态值;旧 GOLD_DIM 对双色墨不达标、已退出该呈现位);Boss 描边 = 紫赤 2px + 外缘 1px 深墨环(灰度冗余通道——§4.2 色相独占本色不变,§4.5 灰度可分类承载)。验收:机检〈暗底/亮底各态文字对比 ≥4.5:1〉
**数据素面的墨来自世界的墨**:UI 数字、进度、准线一律主墨 `#33261B` 系——棋盘与面板读同一种墨;容器穿木石材质色,数据只穿墨。**营地与棋盘**:菜单容器背景用全作最暖的烛光黄 `#F6C667` 与木色 `#8A5A34`(§2.8;场景构成 §6.2);战斗容器转中性石色,防暖色渗入棋盘分走数字对比预算——氛围色向可读色让路。UI 不新增语义色,一切强调沿用 §4.2。**暗底呈现态双色墨制**(关卡画面样张 v1 冻结 2026-09-28):暗底系(已过/未来/候选/Boss 方块、暗数据框)图标与读数一律暖白 `#F0E2C8`(WARM)、亮底(当前金 `#F0A828`)转深墨 `#181410`(INK_ROUTE)——各态对比 ≥4.5:1;已过节点暗金冻结 `GOLD_DONE #68562C`(专用呈现态值,旧 GOLD_DIM `#8C743C` 对双色墨均不达标退役出该呈现位);Boss 描边 = 紫赤 2px + 外缘 1px 深墨环(灰度冗余通道——§4.2 色相独占本色不变,§4.5 灰度可分类承载)。
- **琥珀双岗注记**:主操作色与收益金分轨——主按钮/候选节点/钥石徽章底用烛光黄 `#F6C667`(CANDLE,独立 token),奖励数字/钥石读数/地图「当前位置」仍独占 §4.1 奖励金 `#F0A828`。烛光黄族身兼「主操作 + 收益邻近氛围」双岗系主动取舍,非疏忽,防回改。底色三件套:BG_ROOT `#3A2E22`/BG_OVERLAY `#362B20`/BG_BRIGHT `#453723`(配色总纲「提暖提亮禁近黑」);暖米族 WARM_SOFT `#E8D5A8`/WARM_FAINT `#CDB88C`(「米色去灰」)。验收:方向性
- **色板五件对齐注记(来源 = 原型 demo)**:数据板 `PANEL_DATA #E9D9B8`/板线 `PANEL_BORDER #786E58`/状态槽底 `SLOT_BACKDROP #3E362C`/血条深底 `HP_BAR_BACK #282220`/营地头带 `CAMP_HEADER_BAND #6E4526` 五 token 取值向原型 v2.1 对齐(Δ≤6/255 目视不可辨)。色板唯一来源 = ui_kit 单点不变;原型文件只读不改(artwork 红线)。验收:机检〈ui_kit 五 token 与本表一致〉
- **琥珀双岗注记(2026-10-04 UI 落码批 M0)**:主操作色与收益金分轨——主按钮/候选节点/钥石徽章底用烛光黄 `#F6C667`(CANDLE,独立 token),奖励数字/钥石读数/地图「当前位置」仍独占 §4.1 奖励金 `#F0A828`。烛光黄族身兼「主操作 + 收益邻近氛围」双岗系主动取舍(原型评审 C 组留痕),非疏忽,防回改。底色三件套同步换代:BG_ROOT `#3A2E22`/BG_OVERLAY `#362B20`/新增 BG_BRIGHT `#453723`(配色总纲「提暖提亮禁近黑」);暖米族 WARM_SOFT `#E8D5A8`/WARM_FAINT `#CDB88C`(「米色去灰」)。
- **色板五件对齐注记(2026-10-06 UI 设计对齐批 P7 乙——demo=唯一来源)**:数据板 `PANEL_DATA #E9D9B8`/板线 `PANEL_BORDER #786E58`/状态槽底 `SLOT_BACKDROP #3E362C`/血条深底 `HP_BAR_BACK #282220`/营地头带 `CAMP_HEADER_BAND #6E4526` 五 token 取值换代向原型 v2.1 对齐(Δ≤6/255 目视不可辨;代码与文档同批双侧写入文档)。色板唯一来源 = ui_kit 单点不变;原型文件只读不改(artwork 红线)。
### 7.3 布局栅格
- **结构**:双舞台三区(B 战斗画面/C 数据框/D 棋盘;字母保持不重编)为结构冻结项,分区尺寸与像素级裁决归 battle.md(HUD框架承载 1280×800 基座锚点值,如扫雷图区 1036×504、结算明细带展开 84px)。验收:机检〈三区结构常驻〉
- **栅格规则(候选)**:面板间距/内边距按 4px 步进档(8/12/16/24 候选),全 UI 恒定;数据区直角细线不占圆角预算。终值归样张。验收:目检〈间距落在步进档〉
- **结构**:双舞台三区(B 战斗画面/C 数据框/D 棋盘;字母保持不重编)为结构冻结项,分区尺寸与像素级裁决归 battle.md(HUD框架承载 1280×800 基座锚点值,如扫雷图区 1036×504、结算明细带展开 84px)。
- **栅格口径(候选)**:面板间距/内边距按 4px 步进档(8/12/16/24 候选),全 UI 恒定;数据区直角细线不占圆角预算。终值归样张批。
### 7.4 字体与字号
- **字体**:全局一套像素字,单套即终稿——缝合像素字体 Fusion Pixel(OFL,已接入 `game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf`),拉丁字符与数字沿用其自带拉丁字形,不另设拉丁专件(现状即终稿;旧拉丁候选 Kenney Fonts 已弃;生产工单=清单 §3.6);墨色走主墨系(§7.2);换字体 = 全局决策。验收:机检〈主题字体指向 Fusion Pixel 件〉
- **字号**:像素字体**整数倍缩放,禁非整数缩放**(像素模糊);下限(1280×800 基座):棋盘数字 ≥18px(公式 = `max(18, ceil(格边长 × 0.5))`不变)、**正文/明细 ≥16px、tooltip ≥14px**(1280×800 低于 1080p 基准,同字号实际更小)。验收:机检〈静态文字位点字号 ≥ 档位表值〉
- **字号档位表(六档)**:全站 UI 文字字号只经 `UiKit.FS_*` 单点取值,裸数字字号弃用——`FS_CAPTION 12`/`FS_HINT 14`/`FS_BODY 16`/`FS_SUB 18`/`FS_TITLE 24`/`FS_H1 34`。旧实测档按就近归档收编(10/11/12→CAPTION、13/14→HINT、15/16→BODY、17/18/20→SUB、21/22/24/26→TITLE、32/40→H1;静态位点全归档,数据驱动动态字号位点除外);棋盘数字公式 `maxi(18, ceil(格边长×0.5))` 与站桩血数 11px(条带内读数豁免)不入表。注:六档相邻比 12→14→16→18 三段 <1.25×,系裁决值优先于字重纪律的档差参考线,该参考线对新层级设计仍有效。验收:机检〈全站无裸数字字号,位点走 FS_*〉
- **小字号豁免条款(合并单条不开两口子)**:下款两类**豁免于本节下限**——①**条带内读数**:压在窄条带(血条/进度条等 ≤12px 高带)内居中的数字,受条带高度物理约束,维持 11px 下限,且必须按 §7.2 垫素色牌后计对比(实装=B 区站桩血数 11px 压 HP_BAR_BACK 牌 14.3:1);②**装饰性 overline/caption**:不承载须精确读取的信息、仅作分区标题或氛围小字者,可至 **12px**。**豁免逐处清单(撤除史归 git 提交史,余清单如下)**:条带内读数=B 区站桩血数一处;装饰 caption 档(FS_CAPTION)在册面=棋盘底操作提示条/营地柜台说明文与结构位预留注记/图例 RAP 词条说明/设置警示文两处/关卡地图底栏组标(生命/属性/特性)与分隔系/站桩消散提示/B 区状态槽微计数(×N●○)/bcard 脚注/RAP 反馈行/刷新余量徽标——清单外 11-13px 文字一律按正文/tooltip 下限提档,新增 caption 位须同批增补本清单。验收:机检〈豁免文字位均可对上本清单,清单外无 <14px〉
- **字重纪律(字重两档不启用)**:层级手段为两件——**字号档差(相邻层级 ≥1.25×)+ 墨色深浅**;全局单字重,不上第二个字重(重开钩子:战斗一屏样张设计轮若实证标题层级不足,字重议题随该轮重开,不另立条目);数字禁描边/阴影伪加粗不变(字形为唯一数值载体,§4.1 同源纪律)。验收:机检〈主题仅注册单字重〉
- **字体**:全局一套像素字,单套即终稿——缝合像素字体 Fusion Pixel(OFL,已接入 `game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf`),拉丁字符与数字沿用其自带拉丁字形,不另设拉丁专件(2026-10-24 用户裁:现状即终稿,旧拉丁候选 Kenney Fonts 退役不再接入;生产工单=清单 §3.6);墨色走主墨系(§7.2);换字体 = 全局决策。
- **字号**:像素字体**整数倍缩放,禁非整数缩放**(像素模糊);下限(1280×800 基座;2026-09-29 v3.0 对齐 ux-designer PC 规范):棋盘数字 ≥18px(公式 = max(18, ceil(格边长 × 0.5))不变)、**正文/明细 ≥16px、tooltip ≥14px**(旧 14/13px 下限退役——1280×800 低于 1080p 基准,同字号实际更小)。
- **字号档位表(2026-10-06 UI 设计对齐批 P3 甲——WB G1 六档)**:全站 UI 文字字号只经 `UiKit.FS_*` 单点取值,裸数字字号退役——`FS_CAPTION 12`/`FS_HINT 14`/`FS_BODY 16`/`FS_SUB 18`/`FS_TITLE 24`/`FS_H1 34`。旧 15 实测档按就近归档收编(10/11/12→CAPTION、13/14→HINT、15/16→BODY、17/18/20→SUB、21/22/24/26→TITLE、32/40→H1;T0 普查 107 静态位点全归档,数据驱动动态字号位点除外);棋盘数字公式 `maxi(18, ceil(格边长×0.5))` 与站桩血数 11px(条带内读数豁免)不入表。注:六档相邻比 12→14→16→18 三段 <1.25×,系 P3 裁决值优先于字重纪律的档差参考线,档差参考线对新层级设计仍有效。
- **小字号豁免条款(2026-10-06 UI 设计对齐批 P11+Q5 甲,合并单条不开两口子)**:下款两类**豁免于本节下限**——①**条带内读数**:压在窄条带(血条/进度条等 ≤12px 高带)内居中的数字,受条带高度物理约束,维持 11px 下限,且必须按 §7.2 垫素色牌后计对比(实装=B 区站桩血数 11px 压 HP_BAR_BACK 牌 14.3:1);②**装饰性 overline/caption**:不承载须精确读取的信息、仅作分区标题或氛围小字者,可至 **12px**。**豁免逐处清单(2026-10-06 T6 判定落定,2026-10-22 随迭代 33 卡② 主菜单三入口化撤除「主菜单分组 overline×2」两处——组帽随旧钮组退役,余清单不动)**:条带内读数=B 区站桩血数一处;装饰 caption 档(FS_CAPTION)在册面=棋盘底操作提示条/营地柜台说明文与结构位预留注记/图例 RAP 词条说明/设置警示文两处/关卡地图底栏组标(生命/属性/特性)与分隔系/站桩消散提示/B 区状态槽微计数(×N●○)/bcard 脚注/RAP 反馈行/刷新余量徽标——清单外 11-13px 文字一律按正文/tooltip 下限提档,新增 caption 位须同批增补本清单。
- **字重纪律(2026-09-29 v3.0 修订;2026-10-24 用户裁:字重两档不启用)**:层级手段为两件——**字号档差(相邻层级 ≥1.25×)+ 墨色深浅**;全局单字重,不上第二个字重(重开钩子:战斗一屏样张批设计轮若实证标题层级不足,字重议题随该批设计轮重开,不另立条目);数字禁描边/阴影伪加粗不变(字形为唯一数值载体,§4.1 同源纪律)。
### 7.5 营地界面
- 营地界面=**一张可点击的场景图 + 七物件点选**:点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。场景构成、七个物件清单、画法、分层与交互四态的唯一出处 = §6.2,本节不复述;色域走 §7.2 最暖一档;钥石余额常驻顶部一行小 HUD 栏。界面视觉终稿以表现层样张过审为前置(§1.4 整屏样张闸)。验收:目检〈七物件可点、门洞留空〉
- 营地界面=**一张可点击的场景图 + 七物件点选**:点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。七个物件=半开的**大门门扇**、挂在木围墙上的**布告板**、**锻炉·铁砧**、**木桩**、**篝火**、**宝箱**、**帐篷旁的笔记本**(**帐篷**为承载件、不可点;它的体量与落地尺寸待作者定)。画面纵深=远景天空与远山/中景木围墙与营地大门/近景篝火与左右物件;**近景物件允许压住中景围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**(围墙形态与分带起点的现行口径见 §6.2)。场景图的画法、分层与四态见 §6.2;色域走 §7.2 最暖一档;钥石余额常驻顶部一行小 HUD 栏。界面视觉终稿以表现层样张过审为前置(§1.4 整屏样张闸)。
### 7.6 引擎原生控件与按钮状态规格
### 7.6 引擎原生控件与按钮状态规格(2026-10-14 迭代 25/26 反哺写入——原无条款,两批实现=临时权威,本批按呈报转正)
> **来源与边界**:本节由迭代实现批按呈报写入(引擎原生控件皮肤、按钮五态矩阵与全局纹理过滤),现为现行规格。**定值单点纪律(§8 头注)遵守**:本节只锁形态/尺寸档/对比度底线等规则,色值一律指路——主题色值事实源 = `game/assets/theme/README.md` 溯源表(与 ui_kit 常量双源,见本节末注),本节不复述色值。
> **来源与边界**:迭代 25(UI 全局主题批)立引擎原生控件皮肤、迭代 26(UI 交互与观感批)立按钮五态矩阵与全局纹理过滤——此前 §7 无相应条款,两批实现系临时权威,用户 2026-10-14 裁「全部按呈报写入文档」转正为本节。**定值单点纪律(§8 头注)遵守**:本节只锁形态/尺寸档/对比度底线等规则,色值一律指路——主题色值事实源 = `game/assets/theme/README.md` 溯源表(与 ui_kit 常量双源,见本节末注),本节不复述色值。
**(一)按钮五态矩阵(冒烟五态断言在闸)**
**(一)按钮五态矩阵(迭代 26 T1;冒烟 V1/V2 35 断言在闸)**
- **五态齐全**:全站按钮(普通/主/危险三厂)必须五态非空——常态 / 悬停 / 按下 / 禁用 / 键盘焦点;任一态缺档即违本条。验收:机检〈冒烟五态断言全绿〉
- **悬停 = 常态图 + modulate 提亮**:提亮档 `Color(1.06,1.06,1.06)`,零素材;主钮承 `CANDLE.lightened(0.06)` 同纪律。验收:机检〈悬停态样式非空且非常态图直贴〉
- **按下 = 本族下沉**:石纹钮 `button_stone_pressed` 九宫格;危险钮补本族 pressed 深档(深红 `darkened(0.18)`)。验收:机检〈三厂 pressed 样式各归本族〉
- **禁用 = 本族底 + α0.45 幽灵化**:flat 两厂 bg+border 同 α 全幽灵化;文字禁用色同档。(注:本条管 **UI 按钮**;营地场景图里的**物件**禁用态另按 §6.2——降明度 + 去饱和,不走 α 幽灵化。)验收:机检〈禁用态非按压图、α0.45〉
- **键盘焦点 = INK 2px 内缩 1px 环**:负 expand margin 实现内缩;引擎 focus 样式盒叠加绘制、不遮钮底;逐实例 override、**主题层零新增词条**(冒烟双红线在闸)。验收:机检〈焦点样式在位、主题词条数不增〉
- 新增按钮位须同批过五态清单;验收 = §1.4 样张 + 冒烟五态断言。验收:机检〈新增按钮位入冒烟五态清单〉
- **五态齐全**:全站按钮(普通/主/危险三厂)必须五态非空——常态 / 悬停 / 按下 / 禁用 / 键盘焦点;任一态缺档即违本条(旧态:悬停填常态图=无反馈、禁用错配按压图、焦点全站空白)。
- **悬停 = 常态图 + modulate 提亮**(乙案):提亮档 `Color(1.06,1.06,1.06)`,零素材;主钮承 `CANDLE.lightened(0.06)` 同纪律。
- **按下 = 本族下沉**:石纹钮 `button_stone_pressed` 九宫格;危险钮补本族 pressed 深档(深红 `darkened(0.18)`——旧承石纹按压图错配退役)。
- **禁用 = 本族底 + α0.45 幽灵化**(甲案):flat 两厂 bg+border 同 α 全幽灵化,取代旧「disabled=按压图」错配;文字禁用色同档。 (注:本条管 **UI 按钮**;营地场景图里的**物件**禁用态另按 §6.2——降明度 + 去饱和,不走 α 幽灵化。)
- **键盘焦点 = INK 2px 内缩 1px 环**(甲案):负 expand margin 实现内缩;引擎 focus 样式盒叠加绘制、不遮钮底(「三类钮底可辨性实比」设计语言成立);逐实例 override、**主题层零新增词条**(冒烟双红线在闸)。
- 新增按钮位须同批过五态清单;验收 = §1.4 样张 + 冒烟五态断言。
**(二)引擎原生控件组件规格(15 类型词条全覆盖,预盖件零实役)**
**(二)引擎原生控件组件规格(迭代 25 T0;15 类型词条全覆盖,预盖件零实役)**
- **滚动条**:V/HScrollBar 的 scroll 槽(直角)+ grabber 三态;**引擎事实 = grabber 走 StyleBox 非 Icon**(4.7.2 源码实证),滚动条无位图、无箭头按钮(increment/decrement 默认主题未定义);粗细 = content margin 4(8px)、柄最小 8px。验收:目检〈滚动条非引擎默认灰〉
- **滑轨**:HSlider/VSlider 的 slider(槽轨)+ grabber_area(填充段)两段必须**一眼可辨**(§7.2 可读性硬条款的滑轨侧落法——判据 = 拖到中点两段可辨,机检对 ≥4.5:1 文字位不覆盖该面,归样张目检);抓子三态位图;H/V 边距为旋转换位。验收:目检〈拖到中点两段可辨〉
- **悬停气泡(Tooltip)**:TooltipPanel/TooltipLabel 深底 + 边 + 暖字,标题屏观感承旧值原样迁入;字号走 §7.4 既有档(tooltip 14)。验收:目检
- **输入件**:LineEdit(数据框惯例:牌色底 + 板线 + `RADIUS_OBJECT` 圆角 + 占位色)、SpinBox(updown 合并箭头位图 + 箭头底 hover/pressed 覆盖)、H/VSeparator(单线 1px,默认灰 #808080 实证在位须接管)。验收:目检
- **预盖件**(零实役,备案待启用时复查):CheckBox/CheckButton 共用一套 `checked`/`unchecked`(+_disabled) 四态图标(**引擎事实 = 4.7 无 on/off 词条,两类同名同套——否则预盖静默空转**)、OptionButton(button_stone 九宫格 + 箭头位图)、PopupMenu(panel + hover 高亮 + 字色字号)。验收:方向性
- **字体防御**:主题 `default_font` 显式指向全局像素字(与 project.godot `theme/custom_font` 同文件),冒烟双断言(主题非空 + Label 实取非空);`default_font_size` 不设——全站字号走 §7.4 `UiKit.FS_*` 逐实例 override。验收:机检〈冒烟字体双断言全绿〉
- **组件新增位**:新引擎控件入 UI 须同批在本节登记规格(尺寸档/圆角/对比度底线),禁裸用引擎默认灰圆角现代风。验收:目检〈新控件无默认灰样式〉
- **滚动条**:V/HScrollBar 的 scroll 槽(直角)+ grabber 三态;**引擎事实 = grabber 走 StyleBox 非 Icon**(4.7.2 源码实证),滚动条无位图、无箭头按钮(increment/decrement 默认主题未定义);粗细 = content margin 4(8px)、柄最小 8px。
- **滑轨**:HSlider/VSlider 的 slider(槽轨)+ grabber_area(填充段)两段必须**一眼可辨**(§7.2 可读性硬条款的滑轨侧落法——判据 = 拖到中点两段可辨,机检对 ≥4.5:1 文字位不覆盖该面,归样张目检);抓子三态位图;H/V 边距为旋转换位。
- **悬停气泡(Tooltip)**:TooltipPanel/TooltipLabel 深底 + 边 + 暖字,标题屏观感承旧值原样迁入;字号走 §7.4 既有档(tooltip 14)。
- **输入件**:LineEdit(数据框惯例:牌色底 + 板线 + `RADIUS_OBJECT` 圆角 + 占位色)、SpinBox(updown 合并箭头位图 + 箭头底 hover/pressed 覆盖)、H/VSeparator(单线 1px,默认灰 #808080 实证在位须接管)。
- **预盖件**(零实役,备案待启用时复查):CheckBox/CheckButton 共用一套 `checked`/`unchecked`(+_disabled) 四态图标(**引擎事实 = 4.7 无 on/off 词条,两类同名同套——否则预盖静默空转**)、OptionButton(button_stone 九宫格 + 箭头位图)、PopupMenu(panel + hover 高亮 + 字色字号)。
- **字体防御**:主题 `default_font` 显式指向全局像素字(与 project.godot `theme/custom_font` 同文件),冒烟双断言(主题非空 + Label 实取非空);`default_font_size` 不设——全站字号走 §7.4 `UiKit.FS_*` 逐实例 override。
- **组件新增位**:新引擎控件入 UI 须同批在本节登记规格(尺寸档/圆角/对比度底线),禁裸用引擎默认灰圆角现代风(迭代 25 全屏巡检即此判据)。
**(三)氛围层平滑件与抖动派并存**
**(三)氛围层平滑件与抖动派并存(迭代 26 反哺④——Q5 已裁保平滑)**
- vignette / gradient_base / beams 三件程序化平滑渐变件**保平滑连续 alpha**(已裁),与卡顶类型色带 DitherBand 的 Bayer 有序抖动派**并存不一致**——两派统一裁决留**整屏样张**(§1.4;战斗一屏样张未开工,待办清单在册),本节只登记并存事实与裁决去向,不提前统一。验收:方向性
- vignette / gradient_base / beams 三件程序化平滑渐变件**保平滑连续 alpha**(2026-10-13 grilling Q5 裁),与卡顶类型色带 DitherBand 的 Bayer 有序抖动派**并存不一致**——两派统一裁决留**整屏样张批**(§1.4;战斗一屏样张 P0 未开工,待办清单在册),本节只登记并存事实与裁决去向,不提前统一。
> **主题色值双源注记**:主题色值现存两处——`main_theme.tres` 字面量与 ui_kit 常量(README 溯源表缓解)。长期收敛候选 = 程序化 Theme 生成回代码单点,属重构面、未排期;新增色值仍按现状双落并同批登 README 溯源表。
> **主题色值双源注记(迭代 25 反哺④)**:主题色值现存两处——`main_theme.tres` 字面量与 ui_kit 常量(卡片立项时已知代价,README 溯源表缓解)。长期收敛候选 = 程序化 Theme 生成回代码单点,属重构面、未排期;新增色值仍按现状双落并同批登 README 溯源表。
---
## 8. 资产与技术规范(Asset & Technical Specs)
> **白话**:这一章是给所有出图者(人与脚本)的技术底线——多大的画布、什么格式、怎么命名。屏级格边长定值的裁决权在 battle.md;本节锁资产侧:分辨率档位、像素基准与缩放纪律、导出与命名、帧数规格。生产工单视图 = art-assets-checklist。
> **白话**:这一章是给所有出图者(人与脚本)的技术底线——多大的画布、什么格式、怎么命名。屏级格边长定值的裁决权在 battle.md;本节锁资产侧:分辨率档位、像素基准与缩放纪律、导出与命名、帧数规格。生产工单视图 = art-assets-checklist。
> **定值单点纪律**:全文数字定值只落在**两张表**——**§4.1 主色板**(全部色值+明度/饱和边界)与 **§8.2**(画布/体量/立绘源档/圆角率/缩放);其余章节一律**指路、禁复述数字**(修订改值只动单点处);character-design §5.5 为档位**语义**表(普通/精英/Boss + 例外),数值权威 = §8.2。
> **定值单点纪律(2026-10-06 审计 §07-1 落规)**:全文数字定值只落在**两张表**——**§4.1 主色板**(全部色值+明度/饱和边界)与 **§8.2**(画布/体量/立绘源档/圆角率/缩放);其余章节一律**指路、禁复述数字**(修订改值只动单点处);character-design §5.5 为档位**语义**表(普通/精英/Boss + 例外),数值权威 = §8.2;存量双写复述随整备批(待办清单挂账)逐步收编。
### 8.1 分辨率档位与格边长
### 8.1 分辨率档位与格边长口径
- **两档定案**:1280×800(基座)+ 1920×1080,窗口不可调;后续调整走修订。验收:机检〈工程分辨率配置与两档一致〉
- **格边长取值**:按档位与板形计算——固定档位下格子量增加则格边长相应变小;公式、单位与现行算例的唯一出处 = [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,本节不复述数值。资产侧锁两条比例:**瓦片整格铺满**(32×32 源随格边长缩放,允许非整数倍——瓦片不适用 §8.2 甲类整数倍纪律)、**棋盘小人显示占位 ≤ 0.75× 格边长**。(1920 档已裁决全局等比缩放)验收:机检〈瓦片显示尺寸随格边长、小人占位 ≤0.75×〉
- **两档定案**:1280×800(基座)+ 1920×1080,窗口不可调;后续调整走修订。
- **格边长口径**:按档位与板形计算——固定档位下格子量增加则格边长相应变小;公式、单位与现行算例的唯一出处 = [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,本节不复述数值。资产侧锁两条比例:**瓦片整格铺满**(32×32 源随格边长缩放,允许非整数倍——瓦片不适用 §8.2 甲类整数倍纪律)、**棋盘小人显示占位 ≤ 0.75× 格边长**。(1920 档已裁决全局等比缩放——迭代 9 卡⑮落码)
### 8.2 像素基准与画布
- **缩放纪律(硬规则,按资产两类分级;不做个案豁免)**:
- **甲类·文本与语义件**(数字、图标、旗、格面纹样):维持严格整数倍缩放硬规则——可读性与语义精确性优先,**禁非整数缩放不变**。砖面 tile 不属本类,随格边长整格铺满(见 §8.1)。验收:机检〈甲类件显示尺寸为源整数倍〉
- **全局纹理过滤与平滑渐变件例外**:`project.godot` `default_texture_filter=0`(Nearest)全局——像素风 UI 皮(九宫格/主题石纹样式盒/滑轨抓子/勾选/箭头图标)翻 Nearest=意图面;**显式 LINEAR 豁免清单(有意例外)= vignette / gradient_base / beams 三件程序化平滑渐变件**——保平滑连续 alpha 的程序化氛围件(§7.6 三已裁);新增平滑件须同批入册本清单,禁静默挂 LINEAR。翻转面枚举与冒烟断言(6 条)在闸。验收:机检〈过滤面枚举与 LINEAR 清单逐项一致〉
- **显示档登记例外(64 源 0.5×,待素材批消除)**:柜台四枚 `icon_forge/workshop/atlas/keystone`(64×64 源)显示档统一 **32px = 0.5×**;状态九枚(32×32 源)mini(站桩)档 **16px = 0.5×**。两处均系「显示尺寸侧唯一可调、素材一字节禁改」红线下的人工检整数半倍档,登记为**待消除例外**——素材设计出 32px 源后回归 1×。常规档显示一律 1×(状态九枚 32px、手绘十枚 20px/40px 双档、BCard 图标槽 40px=2×)。验收:机检〈显示档与登记表一致〉
- **乙类·角色与单位精灵**(有机形体、非文字):允许运行时非整数倍缩放(NEAREST 过滤不变),容忍轻度像素宽窄不均;判据 = 实机目检,可感不适即回整数档。乙类免逐案豁免。验收:目检〈立绘无可感拉花〉
- **站桩立绘槽位=高定宽随**:立绘/精灵槽位只定「高度的整数倍档」,宽度随源图宽高比自适应——**禁止把全部立绘塞进统一方框强拉**(纵横倍率必须恒等)。反面实例:统一方框把窄条生物源(细长本体裁掉透明边)按主角比例横拉压扁发花。乙类的非整数豁免只覆盖**等比**缩放,不含纵横异倍变形。验收:机检〈立绘纵横倍率恒等〉
- **1920×1080 档策略**:全局等比(stretch=canvas_items+aspect **keep**)——16:10 设计在 16:9 窗口=等比 1.35×+左右黑边各 ~96px,构图与 1280×800 基准档完全一致;甲类 1.35 非整数缩放例外注记:轻度宽窄不均系本档位接受代价(像素游戏 shipped 常态),实机目检可感不适→回乙案(1920 独立 2× 基准)重议;乙类同前款。验收:目检〈1920 档构图与 1280 档一致〉
- **画布基准(暂定值)**:
- **主角人设原图 = 48×72**(1× 基准,暂定;与站姿降采样可读性验证一致);
- **棋盘小人 = 32×32 二头身派生版(暂定,允许更小)**——独立资产,自人设定稿压缩派生(character-design §5.4),不吞数字位、占位 ≤ 0.75× 格边长;板越小占比摩擦越大(13×13 / 14×14 更小,算例见 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」),32px 源档沿「独立小源档」路线在样张定终值;
- **站桩立绘源档 = 八敌逐键定档**:主角 48 不变;八敌源档高 = goblin 36 / giant_spider 38 / duergar 46 / hook_horror 58 / bugbear 62 / ogre_chief 74 / drider 90 / duergar_king 94,显示一律 **×2 整数档(NEAREST)** = 72–188。定档依据 = 生物体型三约束(钩爪恐魔>灰矮人、灰矮人<熊地精、食人魔>熊地精——体量=物种体型,非强度线性映射,见 character-design §5.2);可读性实测门槛 = 源高 ≥32(24 糊/32 可读/48 清楚/64 细节完整),全阶梯 36–94 达标;派生管线 = pixel-1.png 紧裁 → 等比缩至档高 → dom_block_resize 块内众数降采样(tools/pixelize_candidates.py `STANDEE_TIER_H`,`SIZES` 降格为管线上游中间分辨率表);体量存在感经整屏对比校验(B 区 270/立绘显示上限 188)。验收:机检〈standee_asset_check 逐键档高校验〉
- **站桩立绘排版**:① 区高 B 区 270(≈1/3)/C 区 530(≈2/3),**扫雷盘 438×438 原尺寸不缩、格子不变**(区高事实源 battle.md 区表;压缩由 C 区面板空档吸收);② 站桩内部重排——立绘双脚落石台上缘(弃盒内居中)、**血条+状态/名字行落底排**(立绘→血条→状态 顺序,全场血条对齐一线;血条嵌石板位、台缘线留作地面线、落影保留接地感),状态空时该行退化为名字行;③ 大体档(显示 >150px 级:drider/duergar_king)**允许向上溢出盒顶 N px**(N 由实装批按实测尺寸算定,**与背景/光柱无关**——现行溢出区恰为临时光柱氛围层〔§6.1 方向稿四柱,x 基准 110/900/1055/1210@1280,`battle_stage_view.gd:55-63`〕,正式舞台背景插画 `stage/stage_bg.png` 到位后该层由背景绘制吸收,**溢出判定不随之变更**;横向禁止压到顶部中央轮次·阶段指示,溢出上限由实装批用真图复算 x 区间定),血条仍落底(落底排的第二个收益=头部探顶不带出血条);意图徽标悬浮头顶上方(探顶档压胸);④ 「血条悬浮头顶+立绘盒内居中」排式已弃——盒顶固定 y 与盒内居中在高盒下拉开血条-立绘空洞。验收:目检〈血条一线对齐、大体档溢出不压轮次指示〉
- **缩放纪律(硬规则,2026-09-30 v3.1 起按资产两类分级——2026-09-26 用户裁「更改规则,不做个案豁免」)**:
- **甲类·文本与语义件**(数字、图标、旗、格面纹样):维持严格整数倍缩放硬规则——可读性与语义精确性优先,**禁非整数缩放不变**。砖面 tile 不属本类,随格边长整格铺满(见 §8.1)。
- **全局纹理过滤与平滑渐变件例外(2026-10-14 迭代 26 反哺②写入文档)**:`project.godot` `default_texture_filter=0`(Nearest)全局——像素风 UI 皮(九宫格/主题石纹样式盒/滑轨抓子/勾选/箭头图标)翻 Nearest=意图面;**显式 LINEAR 豁免清单(有意例外,与状态图标抗锯齿例外同族先例)= vignette / gradient_base / beams 三件程序化平滑渐变件**——保平滑连续 alpha 的程序化氛围件,Q5 已裁(§7.6 三);新增平滑件须同批入册本清单,禁静默挂 LINEAR。翻转面枚举与冒烟断言(6 条)在闸。
- **显示档登记例外(2026-10-06 UI 设计对齐批 P6 甲——64 源 0.5×,待素材批消除)**:柜台四枚 `icon_forge/workshop/atlas/keystone`(64×64 源)显示档统一 **32px = 0.5×**;状态九枚(32×32 源)mini(站桩)档 **16px = 0.5×**。两处均系「显示尺寸侧唯一可调、素材一字节禁改」红线下的人工检整数半倍档,登记为**待消除例外**——素材批出 32px 源后回归 1×。常规档显示一律 1×(状态九枚 32px、手绘十枚 20px/40px 双档、BCard 图标槽 40px=2×)。
- **乙类·角色与单位精灵**(有机形体、非文字):允许运行时非整数倍缩放(NEAREST 过滤不变),容忍轻度像素宽窄不均;判据 = 实机目检,可感不适即回整数档。乙类今后免逐案豁免(豁免蔓延断根)。
- **站桩立绘槽位=高定宽随(2026-10-04 原型审计教训落规)**:立绘/精灵槽位只定「高度的整数倍档」,宽度随源图宽高比自适应——**禁止把全部立绘塞进统一方框强拉**(纵横倍率必须恒等)。实例=UI 原型 88×92 方框把 23×48 地精源横拉 3.83×/纵拉 1.92×、26×48 熊地精 3.38×/1.92× 压扁发花,46×48 主角近方恰等比故独活——方框是按主角比例定的,窄条生物(细长本体裁掉透明边)进主角的框必变形。乙类的非整数豁免只覆盖**等比**缩放,不含纵横异倍变形。
- **1920×1080 档策略(2026-09-30 三裁决案甲落定——三案呈报原件已随中间文档退役,删除批见 commit 8e02dcf0)**:全局等比(stretch=canvas_items+aspect **keep**)——16:10 设计在 16:9 窗口=等比 1.35×+左右黑边各 ~96px,构图与 1280×800 基准档完全一致(逻辑布局 1920/1280 逐键一致实测);甲类 1.35 非整数缩放例外注记:轻度宽窄不均系本档位接受代价(像素游戏 shipped 常态),实机目检可感不适→回乙案(1920 独立 2× 基准)重议;乙类同前款。
- **画布基准(2026-09-23 用户暂定值)**:
- **主角人设原图 = 48×72**(1× 基准,暂定;与站姿降采样可读性验证一致);
- **主角战斗状态图(含 B 区站桩侧向)= 48×72**(同上暂定;v1.9 待决注记「主角立绘档空缺」由此完成);
- **棋盘小人 = 32×32 二头身派生版(暂定,允许更小)**——独立资产,自人设定稿压缩派生(人物设计档 §5.4),不吞数字位、占位 ≤ 0.75× 格边长;板越小占比摩擦越大(13×13 / 14×14 更小,算例见 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」),32px 源档的去留(v1.9 待决注记①)沿「独立小源档」路线在样张批定终值;
- **站桩立绘源档 = 八敌逐键定档(2026-10-06 定档批,用户确认定档总图;原三档 48/64/96 候选与 v3.0 档位上调方向候选一并完成)**:主角 48 不变;八敌源档高 = goblin 36 / giant_spider 38 / duergar 46 / hook_horror 58 / bugbear 62 / ogre_chief 74 / drider 90 / duergar_king 94,显示一律 **×2 整数档(NEAREST)** = 72–188。定档依据 = 生物体型三约束(用户拍板:钩爪恐魔>灰矮人、灰矮人<熊地精、食人魔>熊地精——体量=物种体型,非强度线性映射,人物设计档 §5.2 顺带修订);可读性实测门槛 = 源高 ≥32(24 糊/32 可读/48 清楚/64 细节完整),全阶梯 36–94 达标;派生管线 = pixel-1.png 紧裁 → 等比缩至档高 → dom_block_resize 块内众数降采样(tools/pixelize_candidates.py `STANDEE_TIER_H`,`SIZES` 降格为管线上游中间分辨率表);存在感问题(v3.0 病根 6)经方案二整屏对比(B 区 270/立绘显示上限 188)解决。
- **站桩立绘排版 = 方案二(2026-10-06 用户拍板)**:① 区高 B 区 270(≈1/3)/C 区 530(≈2/3),**扫雷盘 438×438 原尺寸不缩、格子不变**(区高事实源 battle.md 区表;压缩由 C 区面板空档吸收);② 站桩内部重排——立绘双脚落石台上缘(弃盒内居中)、**血条+状态/名字行落底排**(立绘→血条→状态 顺序,全场血条对齐一线;血条嵌石板位、台缘线留作地面线、落影保留接地感),状态空时该行退化为名字行;③ 大体档(显示 >150px 级:drider/duergar_king)**允许向上溢出盒顶 N px**(2026-10-07 用户裁甲案;N 由实装批 C0 按实测尺寸算定,**与背景/光柱无关**——现行溢出区恰为临时光柱氛围层〔§6.1 方向稿四柱,x 基准 110/900/1055/1210@1280,`battle_stage_view.gd:55-63`〕,正式舞台背景插画 `stage/stage_bg.png` 到位后该层退役/吸收进背景绘制,**溢出判定不随之变更**;横向不得压到顶部中央轮次·阶段指示,溢出上限由 C0 用真图复算 x 区间定),血条仍落底(落底排的第二个收益=头部探顶不带出血条);意图徽标悬浮头顶上方(探顶档压胸);④ 原「血条悬浮头顶+立绘盒内居中」排式退役——盒顶固定 y 与盒内居中在高盒下拉开血条-立绘空洞(2026-10-06 用户点名否决)。
### 8.3 导出格式与命名规范
- **归档纪律**:图片素材**不入 git 版本管理**——仓库只保留运行时引用资产(game/assets);生成链路原始产物(AI 原图/中间产物/候选版本)归档至 `archive/` 并择机上对象存储备份;主文档(圣经/GDD/清单)**禁引用 artwork/ 散文件路径**——定稿成图以 game/assets 为唯一事实源,过程稿引用一律用归档包路径或描述性出处。验收:机检〈主文档 grep 无 artwork/ 散文件路径〉
- **入包与对账检查器(挂点已立,实现随 PRD 工具批)**:①**色板检查器**——扫描新素材色板,报新增色/近色告警/超 §4.1 上限(48)拦截,挂素材流入包步(asset-path-map §2 第 3 步);②**路径核对器**——扫描 asset-path-map 声明的全部路径,悬空/未清即报,随大批与审计例行跑;③**standee_asset_check 硬闸升级规格**——按 §8.2 逐键定档校验体量与档高、像素域色数 ≤32、无非整数倍缩放痕迹、描边亮度达标(亮描边明度档);把「高定宽随」教训变成代码闸而非文字。验收:机检〈三检查器在闸可跑〉
- **交付格式**:PNG 透明底;动画 = 序列帧或引擎精灵表(SpriteFrames),帧尺寸恒定、横向等宽排列(候选)。验收:目检〈交付件透明底无杂边〉
- **命名规范(候选草案)**:`<域>_<对象键>_<状态|变体>[_<序号>].png`,全小写 snake_case;**对象键 = 数据表主键**(effect_id/reward_id/cell_type/enemy_id——图标键契约 §4.6 的文件侧延伸:资产文件名键 = 图标键,全局恒定禁运行时换图);图类型变体后缀 = board_type 键(`_dungeon`/`_underdark`/`_deep_underdark`);候选评审件与终稿分目录(`artwork/candidates/` 承载评审件)。验收:机检〈命名与目录检查器零红〉
- **归档纪律(2026-10-06 P0-1 用户口径写入文档)**:图片素材**不入 git 版本管理**——仓库只保留运行时引用资产(game/assets);生成链路原始产物(AI 原图/中间产物/候选版本)归档至 `archive/` 并择机上对象存储备份;主文档(圣经/GDD/清单)**禁引用 artwork/ 散文件路径**——定稿成图以 game/assets 为唯一事实源,过程稿引用一律用归档包路径或描述性出处。
- **入包与对账检查器(2026-10-06 P2 固化挂点——本批只立挂点,实现随 PRD 工具批)**:①**色板检查器**——扫描新素材色板,报新增色/近色告警/超 §4.1 上限(48)拦截,挂素材流入包步(asset-path-map §2 第 3 步);②**路径核对器**——扫描 asset-path-map 声明的全部路径,悬空/退役未清即报,随大批与审计例行跑(2026-10-06 三处悬空即此类失守);③**standee_asset_check 硬闸升级规格**(审计 §07-2/3)——按 §8.2 逐键定档校验体量与档高(现「宽 1–75」虚设区间退役)、像素域色数 ≤32、无非整数倍缩放痕迹、描边亮度达标(亮描边明度档,§8.2 描边层级);把 v3.6「高定宽随」教训变成代码闸而非文字。
- **交付格式**:PNG 透明底;动画 = 序列帧或引擎精灵表(SpriteFrames),帧尺寸恒定、横向等宽排列(候选)。
- **命名规范(候选草案)**:`<域>_<对象键>_<状态|变体>[_<序号>].png`,全小写 snake_case;**对象键 = 数据表主键**(effect_id/reward_id/cell_type/enemy_id——图标键契约 §4.6 的文件侧延伸:资产文件名键 = 图标键,全局恒定禁运行时换图);图类型变体后缀 = board_type 键(`_dungeon`/`_underdark`/`_deep_underdark`);候选评审件与终稿分目录(`artwork/candidates/` 承载评审件)。
### 8.4 帧数规格(样张冻结)
### 8.4 帧数规格(样张 v2 冻结 2026-09-28)
- **帧数规格(样张冻结)**:待机循环 4 帧;攻击/技能准备 6 帧单次;受击 2 帧;死亡 = 灰化+碎裂退场(§4.6.4 语义硬规则),序列帧归 ✦ 缺料清单;帧时长与结算节拍对齐(预告时序见 §4.6.4)。验收:机检〈序列帧数与档位一致〉
- **方向集**:棋盘小人=**4 向**(下/上/侧,侧向左右镜像共用一份画稿);斜向移动/挖掘按**主导轴吸附最近正交向**呈现,不出 45° 斜向帧(斜向信息由位移轨迹与目标格预告承载,ITB 实态同款)。镜像与「旗面恒画右」纪律(character-design.md 冒险背包条)的相容归属:棋盘小人默认镜像豁免、旗右纪律限立绘侧——代拟规则,样张确认。验收:目检〈四向帧齐、侧向镜像无破绽〉
- **帧数规格(样张 v2 冻结,2026-09-28)**:待机循环 4 帧;攻击/技能准备 6 帧单次;受击 2 帧;死亡 = 灰化+碎裂退场(§4.6.4 语义硬规则),序列帧归 ✦ 缺料清单;帧时长与结算节拍对齐(预告时序见 §4.6.4)。
- **方向集(2026-09-24 用户拍板)**:棋盘小人=**4 向**(下/上/侧,侧向左右镜像共用一份画稿);斜向移动/挖掘按**主导轴吸附最近正交向**呈现,不出 45° 斜向帧(斜向信息由位移轨迹与目标格预告承载,ITB 实态同款)。镜像与「旗面恒画右」纪律(character-design.md 冒险背包条)的相容归属:棋盘小人默认镜像豁免、旗右纪律限立绘侧——**代拟口径,样张批追认**。
### 8.5 结构件完整性与参考核验
### 8.5 结构件完整性与参考核验(2026-09-24 用户立规)
> **白话**:格子读感里的立体结构——顶面、缘件、角件、周界轮廓——必须是素材里**画出来的结构件**;素材缺件就停下报缺,拿调色/渐变/滤镜顶替不是变通,是违规。
> **白话**:格子读感里的立体结构——顶面、缘件、角件、周界轮廓——必须是素材里**画出来的结构件**;素材缺件就停下报缺,拿调色/渐变/滤镜顶替不是变通,是违规。
- **结构件完整性**:格面立体感(高墙顶面/坡缘/角件/周界轮廓)只能来自**素材本体绘制的结构件拼装**(样例=Necesse 式缘件:专属底色+手绘细节+暗轮廓线的独立贴图);禁止以调色、渐变、明暗滤镜、跨格投影等生成手段伪造素材中不存在的结构。样稿暴露缺件时,正确动作=停下报告缺口与「换素材/自产缺件」选项,由用户裁决后执行。验收:目检〈立体感逐件来自素材本体,无滤镜伪造〉
- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `artwork/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。验收:目检〈参考结论有截图证据在库〉
- **图底分离**:已开地面的明度须低于未开格墙体,由素材选型承载(选更暗的地板砖),不靠渲染压暗实现。验收:机检〈已开格底明度低于墙顶面〉
- **生产分工**:格面地形类素材=**材质归模型、结构归程序**——AI 生图模型只出无缝填充纹理(无边框/无亮边/均匀光照/方形);一切结构件(缘件/轮廓/立面/角件/收头)一律程序化绘制或手修,因为 autotile 任意拼接的边界像素约束超出 2D 生图模型能力(实证:生图 5×5 瓦片表每枚砖自带啃咬边框与宽亮带,提示词无法根除)。**镜像规则**:东西对称结构件只许水平翻转(flip_h)复用、**禁旋转**——北受光下东西侧缘光照对称,镜像无损真;旋转会把北受光转到东边。验收:目检〈瓦片表无自带边框/亮带;对称件仅 flip_h〉
- **结构件完整性**:格面立体感(高墙顶面/坡缘/角件/周界轮廓)只能来自**素材本体绘制的结构件拼装**(样例=Necesse 式缘件:专属底色+手绘细节+暗轮廓线的独立贴图);禁止以调色、渐变、明暗滤镜、跨格投影等生成手段伪造素材中不存在的结构。样稿暴露缺件时,正确动作=停下呈报缺口与「换素材/自产缺件」选项,由用户裁决后执行。
- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `artwork/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。(2026-10-07 路径核对器 B3 首跑发现更正——原 `production/mockups/` 系废路径,v3.9 批已更正asset-path-map 等四处,本条漏网)
- **图底分离**:已开地面的明度须低于未开格墙体,由素材选型承载(选更暗的地板砖),不靠渲染压暗实现。
- **生产分工(2026-09-26 用户拍板)**:格面地形类素材=**材质归模型、结构归程序**——AI 生图模型只出无缝填充纹理(无边框/无亮边/均匀光照/方形);一切结构件(缘件/轮廓/立面/角件/收头)一律程序化绘制或手修,因为 autotile 任意拼接的边界像素约束超出 2D 生图模型能力(实证:2026-09-25 生图 5×5 瓦片表每枚砖自带啃咬边框与宽亮带,提示词无法根除)。**镜像规则**:东西对称结构件只许水平翻转(flip_h)复用、**禁旋转**——北受光下东西侧缘光照对称,镜像无损真;旋转会把北受光转到东边。
---
## 9. 参考与反参考(References & Anti-References)
> **白话**:正面参考 = 向谁抄什么;反面参考 = 避什么。两者同等效力——方案违反反面条款时,禁止以「参考了某游戏」辩护。参考图库存于 `design/art/references/`(点名池三款×2 张,与头注点名池一一对应)。
> **白话**:正面参考 = 向谁抄什么;反面参考 = 避什么。两者同等效力——方案违反反面条款时,禁止以「参考了某游戏」辩护。参考图库存于 `design/art/references/`(点名池三款×2 张,与头注点名池一一对应)。
### 9.1 正面参考(点名池,用户审阅选定)
### 9.1 正面参考(点名池,用户审阅选定)
| 参考 | 取 | 弃 |
|---|---|---|
| **Into the Breach** | 格内信息分层、攻击预告上格范式、战棋地块质感骨架(§1.2 原则一;§3.2) | 冷暗军事色调、压暗信息板、斜切 HUD(原则二;§3.3) |
| **Moonlighter** | 暖质地砖与砖缝质感、亲和冒险者气质、木石暖色道具(原则二/原则三);**光照层次与明度纵深**(亮地板/暗深渊/发光体三档,§2 明度分层) | 全拟物倾向(§3.3 半拟物边界);Darkest 式情绪压暗(§9.2) |
| **Peglin** | 明快全年龄洞穴色彩、胖体量亲和怪(§5.1 普通怪原型) | 色块依赖——先读轮廓后读颜色(§5.1) |
| **Into the Breach** | 格内信息分层、攻击预告上格范式、战棋地块质感骨架(§1.2 原则一;§3.2) | 冷暗军事色调、压暗信息板、斜切 HUD(原则二;§3.3) |
| **Moonlighter** | 暖质地砖与砖缝质感、亲和冒险者气质、木石暖色道具(原则二/原则三);**光照层次与明度纵深**(2026-09-29 v3.0 起取——亮地板/暗深渊/发光体三档,§2 明度分层) | 全拟物倾向(§3.3 半拟物边界);Darkest 式情绪压暗(§9.2) |
| **Peglin** | 明快全年龄洞穴色彩、胖体量亲和怪(§5.1 普通怪原型) | 色块依赖——先读轮廓后读颜色(§5.1) |
- **反面条款已成文**(§9.2);反面参考**图证**待本作素材生成后增补。验收:方向性
- **反面条款已成文**(§9.2);反面参考**图证**待本作素材生成后另批增补。
### 9.2 反面参考(每条绑定违反条款)
### 9.2 反面参考(每条绑定违反条款)
| 反面参考 | 违反条款 | 立法理由 |
|---|---|---|
| **Darkest Dungeon** 式压暗 | §2 情绪明暗禁令(明度分层下仍禁) | 状态与情绪只走色相/饱和/运动/元素四手段,不以变暗表达情绪(失败重开,世界无光暗记忆) |
| **传统扫雷白牌/贴纸式数字承载** | §1.2 原则二(明度分档为骨) | 数字由格底明度分档承载、直画入世界;色牌仅复杂格底后备——满屏白牌=电子表格观感,非游戏画面 |
| **Enter the Gungeon** 式粒子与色相爆炸 | §3.4 焦点预算;§4.2 色相纪律 | 信息层禁止被特效与杂色吞没,特效入画先过焦点预算 |
| **Hades** 式特效密度 | §2.2 兑现爽感的上限 | 爽感靠逐行因果归因,不靠满屏特效;承载元素以 §2.2 清单为界 |
| **传统扫雷 1–8 八色分色** | §4.1 数字墨三档 | 八色必撞红/金/蓝/紫赤语义色段;数字只走墨族三档色温,字形为唯一数值载体 |
| **Darkest Dungeon** 式压暗 | §2 情绪明暗禁令(明度分层下仍禁) | 状态与情绪只走色相/饱和/运动/元素四手段,不以变暗表达情绪(失败重开,世界无光暗记忆) |
| **传统扫雷白牌/贴纸式数字承载**(2026-09-29 v3.0 新增) | §1.2 原则二(明度分档为骨) | 数字由格底明度分档承载、直画入世界;色牌仅复杂格底后备——满屏白牌=电子表格观感,非游戏画面 |
| **Enter the Gungeon** 式粒子与色相爆炸 | §3.4 焦点预算;§4.2 色相纪律 | 信息层禁止被特效与杂色吞没,特效入画先过焦点预算 |
| **Hades** 式特效密度 | §2.2 兑现爽感的上限 | 爽感靠逐行因果归因,不靠满屏特效;承载元素以 §2.2 清单为界 |
| **传统扫雷 1–8 八色分色** | §4.1 数字墨三档 | 八色必撞红/金/蓝/紫赤语义色段;数字只走墨族三档色温,字形为唯一数值载体 |
---
+5 -5
View File
@@ -2,7 +2,7 @@
> **性质**:美术清单生图通道(✦ 用户自跑,现走线上 API)的执行侧文件——每资产一段**自包含完整提示词**:正向 = 通用底座+主体+动作已拼妥、负向 = 通用底座+增补已并妥,整段复制直接贴进工作流即可,**不需要读其他文件、不需要拼装**。人物规格转写自 [character-design.md](character-design.md)(人物设计 v1.0,自 art-bible §5 迁出,圣经 §5 为存根),风格/色彩条款转写自 [art-bible](art-bible.md)(§4.1 主色板 / §4.2 语义色规则);冲突时人物规格以 character-design 为准、色彩以 art-bible 为准。
> **边界**:本文件只管**角色级**资产的生图通道。图标/格面/样张走脚本管线(⚙ 代理出),规格在 art-bible §4.6 与 [art-assets-checklist](art-assets-checklist.md),不需要提示词。**场景级资产(营地场景图的背景层与八个物件层)另立 [camp-scene-prompts](camp-scene-prompts.md),本文件不覆盖**;两卡的角色/场景分工以「谁的资产谁出卡」为界,同一段提示词不两处维护。意图动画三档是动画资产,等一屏样张锁方案后另立规格卡,本文件不覆盖。
> **状态**:提示词卡成文,出图未开始;按「美术圣经 §5 现行」重生成(正序:B 路线),继承 seed 纪律 / 色相禁入带 / `#F2F2F2` 生成底色;沿革见 git 提交史。
> **前世**:v1.0(2026-09-20,主角三态+六敌种)于 2026-09-22 经裁决移除;本版按「美术圣经 §5 落地后重生成」拍板重写(正序:B 路线),继承 seed 纪律 / 色相禁入带 / `#F2F2F2` 生成底色。**建立**:2026-09-22 · 状态:提示词卡成文,出图未开始
---
@@ -252,14 +252,14 @@ Pixel art style. true pixel art game sprite drawn on a tiny 48x72 pixel canvas,
5. **头身**:2–3 头身之内,与站姿样张同世界观。
6. **同形象**:主角三态并排、L2 熊地精对熊地精、熊地精 A/B 对——兜帽/装备/剪影不串戏。
## 4bis. 地形填充纹理(材质归模型、结构归程序)
## 4bis. 地形填充纹理(2026-09-26 定稿,材质归模型、结构归程序)
> 范围注:本节为地形类资产首张卡(库原定位=角色级,地形类同库登记)。分工纪律=art-bible §8.5「生产分工」:**模型只出无缝填充纹理,结构件(缘件/轮廓/立面/角件)全部程序化**——autotile 拼接约束超出 2D 生图能力,勿让模型画瓦片结构(实证:5×5 瓦片表每枚必带边框亮框)。
**对话式生图控制纪律(实证)**:
**对话式生图控制纪律(2026-09-25/26 实证)**:
1. 同对话续写=对上一张图的**编辑模式**——变体必须各开新对话;同一对话里"same as X but ..."会被当成对最近一张的增量编辑。
2. 英文 prompt 服从度明显高于中文(Gemini/Nano Banana 语料结构使然),提示词一律英文。
3. **地形瓦片出图制式 = 拼表制主选(操作单见 remote-gen-prompts §3)**——一张整图容纳全部瓦片变体,后处理裁切取用:风格统一、一张管住全族。画框风险的缓解 = ①出图贴程序占位样图作参考(模型有具体锚定就不乱画框)②裁切时每格内缩 2–3% 切掉边框③统一降采样(结构归程序)。**分张(一张一跑)降为回退路径**:仅当拼表连续画框失败时改用。〔实证教训保留:裸拼表(无参考图)必诱画框——教训归因于缺参考锚定,非拼表制本身〕
3. **地形瓦片出图制式 = 拼表制主选(2026-10-06 用户拍板,操作单见 remote-gen-prompts §3)**——一张整图容纳全部瓦片变体,后处理裁切取用:风格统一、一张管住全族。画框风险的缓解 = ①出图贴程序占位样图作参考(模型有具体锚定就不乱画框)②裁切时每格内缩 2–3% 切掉边框③统一降采样(结构归程序)。**分张(一张一跑)降为回退路径**:仅当拼表连续画框失败时改用。〔实证教训保留:2026-09-25/26 裸拼表(无参考图)必诱画框——教训归因于缺参考锚定,非拼表制本身〕
4. 差异特征写第一句并给否定约束(如碎石斑变体须写明 `no cracks, no dark lines`);尺寸不写死(模型自定,原图越大裁切余量越足,后处理统一降采样)。
**①墙顶基面(无缝岩石)**:
@@ -282,7 +282,7 @@ Same as the rock surface texture but with a few tiny lighter stone speckles spar
Seamless tileable pixel art texture of dry dirt ground, top-down, for a dungeon board game. Flat 2D texture only — not an object, no perspective. Pattern fills the entire square evenly and wraps perfectly at all four edges. No border, no frame, no edge highlight, no vignette, no cast shadow, uniform directionless lighting. Dark warm brown palette around #B39A78 — clearly darker than tan rock. A few thin cracks and tiny pebble speckles, sparse. Clean retro pixel art look, limited palette, no gradients, no text, square image.
```
后处理(程序侧,生成器脚本归档 zip 内〔archive 备选案-terrain-walls〕):周期对齐无缝裁剪→BOX 降采样 32×32→环绕混合→量化(12 色)→结构件合成;②③亦可由①程序加纹理级裂纹/浅斑替代(实证更稳)。
后处理(程序侧,生成器 `_tools/gen_terrain_walls.py`,归档 zip 内):周期对齐无缝裁剪→BOX 降采样 32×32→环绕混合→量化(12 色)→结构件合成;②③亦可由①程序加纹理级裂纹/浅斑替代(2026-09-26 实证更稳)。
## 5. 明确不在本文件
+7 -7
View File
@@ -2,7 +2,7 @@
> **性质**:纯看板——每条素材只记三件事:**素材名 × 是否完成 × 需求文档指针**。要什么、怎么做、验收标准一概不记:需求规格归 [art-assets-checklist.md](art-assets-checklist.md),风格/规范定义处归各 owner 文档(第三列)。
> **更新纪律**:顺带只改「状态」列;做完不删行,改 ✅。条目编号与 art-assets-checklist.md 的 P0–P3 一一对应。
> **建档**:状态列为建档当日快照,全量对账后与实机在役状态核对更新;数字以表内实况为准。
> **建档**:2026-09-24,状态列为当日快照。**2026-10-06 P1-3 对账批**:按实机在役状态与圣经 v3.7 冻结记录全量复核更新(审计发现看板反超条目已对齐;对账后 = ✅ 4 / ◐ 8 / ⬜ 3,共 15 条)。
**状态图例**:⬜ 未开始 | ◐ 占位在役(程序化占位,够试玩不达标) | ◑ 候选已出待审 | ✅ 已交付
@@ -10,7 +10,7 @@
| 素材 | 状态 | 需求文档 |
|---|---|---|
| 格子样张 | ✅ 样张 v3 冻结(2026-09-30 机检 17/17 全绿,art-bible §1.4 v3.2 记录;v2/v3 两次冻结通过;产物原件已丢失、未入 git) | [art-bible](art-bible.md) §1.4 |
| 格子样张 | ✅ 样张 v3 冻结(2026-09-30 机检 17/17 全绿,art-bible §1.4 v3.2 记录;v2/v3 两次冻结通过;产物 `artwork/mockups/sample-batch-v3-final-2026-09-30.png`) | [art-bible](art-bible.md) §1.4 |
| 战斗一屏样张 | ⬜ | [战斗表现](../gdd/systems/战斗循环/战斗表现.md)(一屏样张验收标准)+ [art-bible](art-bible.md) §5.1 |
## P1 角色级(样张通过后开工)
@@ -18,7 +18,7 @@
| 素材 | 状态 | 需求文档 |
|---|---|---|
| 主角战斗小人(待机/攻击/受击三态) | ✅ B区站桩三态全接(2026-09-29 迭代 6 T3:normal/attack/hit 48px 档入包+挂事件切换+结算回 idle,短驻自回;棋盘 1.5×/脚锚25%/椭圆影在役不变;hit 态源=new-hit.pxo 已程序导出) | [character-design](character-design.md) + [art-prompts](art-prompts.md) §2 |
| 八敌种形象(L2 熊地精=bugbear 复用,不单列) | ✅ B区站桩八敌真图服役(2026-09-29 迭代 6 T1/T2:pixel-1 源→48px 档统一比例/底中锚/众数投票降采样,character_enemy_<键>.png 按常量入位,字形回退退役为缺位兜底;素材不入 git 本地在盘)。八敌源档已定(36–94,art-bible §8.2),旧统一 48 档为过渡形态,新档重产落位归 PRD(tools/pixelize_candidates.py `STANDEE_TIER_H` 已改,产出与定档总图一致) | [art-bible](art-bible.md) §5 + [art-prompts](art-prompts.md) §3 |
| 八敌种形象(L2 熊地精=bugbear 复用,不单列) | ✅ B区站桩八敌真图服役(2026-09-29 迭代 6 T1/T2:pixel-1 源→48px 档统一比例/底中锚/众数投票降采样,character_enemy_<键>.png 按常量入位,字形回退退役为缺位兜底;素材不入 git 本地在盘)。**2026-10-06 v3.7 定档批注记**:八敌源档已定(36–94,art-bible §8.2),旧统一 48 档在役过渡,新档重产落位归 PRD(tools/pixelize_candidates.py `STANDEE_TIER_H` 已改,产出与定档总图一致) | [art-bible](art-bible.md) §5 + [art-prompts](art-prompts.md) §3 |
| 意图准备动画三档 | ◐ | [art-bible](art-bible.md) §4.6.4 |
## P2 图标与格面(可与样张并行)
@@ -28,7 +28,7 @@
| 格面资源徽章 19 枚 | ◐ | [art-bible](art-bible.md) §4.6 |
| 关卡节点图图标组 | ◐ | [art-assets-checklist](art-assets-checklist.md) §3.2(正式 UI 设计未成文) |
| 雷/封印印记/数字砖面全套 | ◐ | [art-bible](art-bible.md) §1.4/§3.2(前置:格子样张) |
| 增益状态效果图标 9 枚 | ✅ 已接入(2026-09-24) | [art-bible](art-bible.md) §4.6.1(候选原稿:状态图标批 2026-09-23 远程出图,归 candidates 档——路径描述性引用,归档纪律见 art-bible §8.3) |
| 增益状态效果图标 9 枚 | ✅ 已接入(2026-09-24 状态记录 156) | [art-bible](art-bible.md) §4.6.1(候选原稿:状态图标批 2026-09-23 远程出图,归 candidates 档——路径描述性引用,归档纪律见 art-bible §8.3) |
| 营地场景图 | ◐ | [art-bible](art-bible.md) §6.2 |
| 字体(全局 UI 像素字,Fusion Pixel 单套单字重) | ✅ 已定稿在役(2026-09-27 接入;2026-10-24 用户裁现状即终稿——不另接拉丁专件、不上第二字重) | [art-bible](art-bible.md) §7.4 |
@@ -36,10 +36,10 @@
| 素材 | 状态 | 需求文档 |
|---|---|---|
| 受击反馈 / 死亡表现 / 顺位标记 | ◐ 程序化演出在役(受击闪白+打击火花、灰化+碎裂退场〔禁瞬消〕、顺位徽标 ①②③ 程序绘制;帧级素材未产,§8.4 帧数规格候选) | [战斗表现](../gdd/systems/战斗循环/战斗表现.md) |
| 受击反馈 / 死亡表现 / 顺位标记 | ◐ 程序化演出在役(受击闪白+打击火花、灰化+碎裂退场〔禁瞬消〕、顺位徽标 ①②③ 程序绘制——2026-09-29 迭代 6 T4/T5;帧级素材未产,§8.4 帧数规格候选) | [战斗表现](../gdd/systems/战斗循环/战斗表现.md) |
| 连锁坍塌显形演出 | ⬜ | [art-assets-checklist](art-assets-checklist.md) §4 |
| 封印特效(动作=封印,两件:施印特效+印记常显) | ⬜ | [art-bible](art-bible.md) §3.2 + [art-assets-checklist](art-assets-checklist.md) §4 |
| 弹道 / 退场等动画密度 | ◐ 程序化弹道在役(play_projectile 0.16s+0.19s 到达拍调度;密度终稿归战斗表现) | [art-assets-checklist](art-assets-checklist.md) §4(战斗表现输入清单) |
| 封印特效(插旗动作=封印,两件:施印特效+印记常显) | ⬜ | [art-bible](art-bible.md) §3.2 + [art-assets-checklist](art-assets-checklist.md) §4 |
| 弹道 / 退场等动画密度 | ◐ 程序化弹道在役(play_projectile 0.16s+0.19s 到达拍调度,2026-09-29 迭代 6;密度终稿归战斗表现批) | [art-assets-checklist](art-assets-checklist.md) §4(战斗表现输入清单) |
---
+8 -8
View File
@@ -1,16 +1,16 @@
# 素材路径分工约定(asset-path-map)
- 定立:2026-09-26(用户立约指令「把哪些路径、哪些位置是干什么的确定好」)
- 定立:2026-09-26(用户立约指令「把哪些路径、哪些位置是干什么的确定好」——状态记录 161)
- 效力:全仓素材批的路径判据单点;新素材批开工前对照,违例=实施缺陷
## 1. 目录分工总表
| 路径 | 角色 | 入 git | 游戏运行时引用 |
|---|---|---|---|
| `artwork/candidates/**` | 候选原图与母版源档(PNG/.pxo/GIF/对比图/验收留底)——**素材的原产地,游戏禁直接引用**;随 `artwork/` 整目录不入 git(`design/art/candidates/` 已弃用,路径列入熵增闸退役名单) | 否(`.gitignore` 三行规则;`*.md` 指引例外) | **禁**(res:// 路径禁指 candidates) |
| `artwork/candidates/**` | 候选原图与母版源档(PNG/.pxo/GIF/对比图/验收留底)——**素材的原产地,游戏禁直接引用**;随 `artwork/` 整目录不入 git(`design/art/candidates/` 已作废,路径列入熵增闸退役名单) | 否(`.gitignore` 三行规则;`*.md` 指引例外) | **禁**(res:// 路径禁指 candidates) |
| `game/assets/art/**` | **游戏运行时素材唯一落地处**(res://assets/art/...)——定稿素材从 `artwork/candidates/` 拷贝入此,改名按圣经 §8.3 | 否(untracked,勿 git add;worktree 间需脚本拷贝同步) | **唯一合法引用源** |
| `game/assets/ui/icons/**` | UI 图标(状态图标批特例——唯一已提交入 git 的素材目录) | **是**(状态图标入库特例) | 是 |
| `artwork/mockups/**` | 混合样张/版式对比图(设计评审产物,非游戏素材;样张、关卡地图样张等实存于此) | 否 | 禁 |
| `game/assets/ui/icons/**` | UI 图标(状态图标批特例——唯一已提交入 git 的素材目录) | **是**(2026-09-24 批特例) | 是 |
| `artwork/mockups/**` | 混合样张/版式对比图(设计评审产物,非游戏素材;样张 v3、关卡地图样张等实存于此)——2026-10-06 对账更正:原写的 `production/mockups/**` 系废路径 | 否 | 禁 |
## 2. 素材流(新素材批标准路径)
@@ -19,7 +19,7 @@
3. **入包**:拷贝 → `game/assets/art/<语义子目录>/`,改名 `§8.3` 域键式(如 `character_hero_portrait.png`)
4. **接线**:代码 `res://assets/art/...` 引用(路径常量单点归 `UiKit.TEX_*`);装载一律 `UiKit.load_tex`(exists 守卫——素材不入 git,缺位树回退),**禁 preload**
5. **提交**:只提交代码/文档;PNG 与 `.import`(全局忽略)均不入 git
6. **整理(脚本链第四段:生成→验证→整理→入包)**:定稿入包后当批清 `artwork/candidates/` 子目录——保留**定稿源 + 最相邻候选 2 版**(默认策略,可按资产调整),其余打包归 `archive/`;非素材残留(.url/.ogg/.ai/.swf 等)剔出;批量执行一律 **dry-run 清单先行**,经用户确认再动文件
6. **整理(2026-10-06 P2-1 固化段——脚本链第四段:生成→验证→整理→入包)**:定稿入包后当批清 `artwork/candidates/` 子目录——保留**定稿源 + 最相邻候选 2 版**(默认策略,可按资产调整),其余打包归 `archive/`;非素材残留(.url/.ogg/.ai/.swf 等)剔出;批量执行一律 **dry-run 清单先行**,经用户确认再动文件(2026-10-06 用户惯例)
## 3. game/assets/art 语义子目录
@@ -27,11 +27,11 @@
|---|---|---|
| `character/` | 角色棋盘帧 + B 区立绘 | character_hero_idle/walk(棋盘 32×32 朝左基准)、character_hero_portrait(B 区立绘 46×48)+ character_enemy_<键> 八敌 |
| `fx/` | 特效帧 | shovel_swing_f0/f1/f2(挥铲三帧,握把锚 (18,12)) |
| `scene/` | **场景图**(背景层 + 可点物件层;营地场景图首批) | `scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件,不可点)——登记超前:营地场景图批落地前盘上未建 |
| `board/` | 砖面/封印印记(三关换皮 + 封印印记件) | terrain_floor_<board_type>_<n>.png(三关地面系)、seal_mark.png |
| `scene/` | **场景图**(背景层 + 可点物件层;2026-10-22 新增,营地场景图首批) | `scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件,不可点) |
| `board/` | 砖面/封印印记(三关换皮 + 封印印记件;2026-10-06 对账更正——旧 `board_floor/wall/pillar_dungeon_*` 命名退役) | terrain_floor_<board_type>_<n>.png(三关地面系)、seal_mark.png |
## 4. 坐标/朝向等工程侧约定备查
- 素材基准朝向:角色/铲帧一律朝**左**入库,右向=运行时 `flip_h`(镜像帧不入包,right 变体留 `artwork/candidates/`)
- 挥铲握把锚:画布 (18,12)(三帧同位——以手为圆心旋转的预烤相位)
- 缩放纪律:圣经 §8.2 整数缩放为一般规则;棋盘小人 1.5× 系用户明示裁决的登记例外(注记见 art-bible §8.2)
- 缩放纪律:圣经 §8.2 整数缩放为一般规则;棋盘小人 1.5× 系 2026-09-26 用户明示裁决(豁免注记归 art-bible 批另落)
+56 -56
View File
@@ -1,82 +1,82 @@
# 人物设计文档(Character Design)— Dungeon Sweeper
# 人物设计文档(Character Design)— Dungeon Sweeper
> **性质**:美术圣经 §5(角色设计)的独立承载文档——**人设高频改动、独立成档免动圣经**;圣经 §5 自此为引用存根指向本文件,**小节号 §5.1–§5.6 沿用不变**(跨文档引用经圣经 §5 存根仍可达,新引用直指本文件)。
> **边界**:本文件只定角色呈现;角色承载的机制语义(护甲克制矩阵、技能与状态)以 GDD 为准(敌人系统/增益状态/战斗结算)。色彩系统、形状语言总纲引用美术圣经 [art-bible](art-bible.md) §4/§3,冲突以圣经为准。提示词执行侧(可直贴串)归 [art-prompts](art-prompts.md),本文件是它的规格上游。
> **性质**:美术圣经 §5(角色设计)的独立承载文档——2026-09-23 抽离批自 art-bible v1.6 §5 迁出成文,动机是**人设高频改动、独立成档免动圣经**;圣经 §5 自此为引用存根指向本文件,**小节号 §5.1–§5.6 沿用不变**(跨文档引用经圣经 §5 存根仍可达,新引用直指本文件)。
> **边界**:本文件只定角色呈现;角色承载的机制语义(护甲克制矩阵、技能与状态)以 GDD 为准(敌人系统/增益状态/战斗结算)。色彩系统、形状语言总纲引用美术圣经 [art-bible](art-bible.md) §4/§3,冲突以圣经为准。提示词执行侧(可直贴串)归 [art-prompts](art-prompts.md),本文件是它的规格上游。
---
## 5.1 角色剪影与轮廓通道(自圣经 §3.1 迁入)
## 5.1 角色剪影与轮廓通道(自圣经 §3.1 迁入)
硬规则:**所有角色自带轮廓通道**——亮色描边(暖白族,禁黑描边)+落影底盘,识别不依赖所在背景。敌人不占格,剪影禁止借格底色成立;同一套剪影因此在棋盘、结算画面与任一战斗舞台通用,不为战斗表现未决的舞台问题付重画成本。←已锁事实
硬规则:**所有角色自带轮廓通道**——亮色描边(暖白族,禁黑描边)+落影底盘,识别不依赖所在背景。敌人不占格,剪影禁止借格底色成立;同一套剪影因此在棋盘、结算画面与任一战斗舞台通用,不为战斗表现未决的舞台问题付重画成本。←已锁事实;「禁黑描边」= 2026-09-20 提示词卡实践顺带固化
**主角描边例外(显式例外,非漂移)**:主角四态硬描边 = **深褐 `(40,19,13)`**,不入暖白族——深描边在浅砖面格底上对比更高、主角更突出;暖白达标率 0–28% 属例外内允许(敌人仍暖白族,实测达标 27–69%,不变)。素材不随本条修改;样张(圣经 §1.4)终审复核例外成立性,复核义务登记待办清单。
**主角描边例外(2026-10-06 待决项 B 用户拍板——显式例外,非漂移)**:主角四态硬描边 = **深褐 `(40,19,13)`**,不入暖白族——深描边在浅砖面格底上对比更高、主角更突出;暖白达标率 0–28% 属例外内允许(敌人仍暖白族,实测达标 27–69%,不变)。素材不随本条修改;样张批(圣经 §1.4)终审复核例外成立性,复核义务登记待办清单。
四类角色 archetype 以高宽比/轮廓凹凸数/锐角数一眼区分(道具点——唯一非生物剪影——留在圣经 §3.1):
四类角色 archetype 以高宽比/轮廓凹凸数/锐角数一眼区分(道具点——唯一非生物剪影——留在圣经 §3.1):
- **小人**:窄、直立、对称,凹凸 ≤2;剪影在 2~3 头身两档下均成立(比例见 §5.2);格内占位最窄,不吞数字位。←原一;圣经 §2.1
- **普通怪**:近圆墩形,宽≥高,零锐角。取 Peglin 的胖体量,弃其色块依赖——先读轮廓,后读颜色。←P4
- **精英**:普通怪形加恰一处垂直延伸(角/冠/棘),底盘不变。←圣经 §2.6 郑重感的低配档
- **Boss**:敌类中唯一允许锐角与不对称者,体量最大,与紫赤色相独占并列"形状独占"。←圣经 §2.6
- **小人**:窄、直立、对称,凹凸 ≤2;剪影在 2~3 头身两档下均成立(比例见 §5.2);格内占位最窄,不吞数字位。←原一;圣经 §2.1
- **普通怪**:近圆墩形,宽≥高,零锐角。取 Peglin 的胖体量,弃其色块依赖——先读轮廓,后读颜色。←P4
- **精英**:普通怪形加恰一处垂直延伸(角/冠/棘),底盘不变。←圣经 §2.6 郑重感的低配档
- **Boss**:敌类中唯一允许锐角与不对称者,体量最大,与紫赤色相独占并列"形状独占"。←圣经 §2.6
## 5.2 头身比与体量档位
- **头身比**:全角色 2~3 头身(chibi)两档——小人剪影须在两档下均成立(§5.1);敌种按 archetype 取档,同一敌种恒定一档(enemy_id 键恒定纪律,圣经 §4.6.4)。←风格基调
- **体量档位 = 生物体型通道(原「强度先验·普通 < 精英 < Boss 单调递增」读法已弃用)**:体量按**物种天性**定,禁按强度档位线性映射——小体型精英与大体型普通怪并存(锁定实例:钩爪恐魔 > 灰矮人、灰矮人 < 熊地精、食人魔酋长 > 熊地精);「大个子多半有重击」的强度先验读法改由意图徽标(§5.6)与甲档(§5.3)承担,体量不再报强度;图像不报数,精确数值只在结算明细。
- 像素画布/精灵尺寸**不在本节**——归圣经 §8 资产规范;本节只锁比例关系与档位次序。
- **头身比**:全角色 2~3 头身(chibi)两档——小人剪影须在两档下均成立(§5.1);敌种按 archetype 取档,同一敌种恒定一档(enemy_id 键恒定纪律,圣经 §4.6.4)。←风格基调 2026-09-10
- **体量档位 = 生物体型通道(2026-10-06 用户三约束修订,原「强度先验·普通 < 精英 < Boss 单调递增」读法退役)**:体量按**物种天性**定,禁按强度档位线性映射——小体型精英与大体型普通怪并存(锁定实例:钩爪恐魔 > 灰矮人、灰矮人 < 熊地精、食人魔酋长 > 熊地精);「大个子多半有重击」的强度先验读法改由意图徽标(§5.6)与甲档(§5.3)承担,体量不再报强度;图像不报数,精确数值只在结算明细。
- 像素画布/精灵尺寸**不在本节**——归圣经 §8 资产规范(D1,另批);本节只锁比例关系与档位次序。
## 5.3 护甲类型视觉区分(候选方案,样张冻结终稿)
## 5.3 护甲类型视觉区分(候选方案,样张批冻结)
> **白话**:敌方有三种护甲类型——轻甲(light)/魔法护甲(ward)/重甲(heavy),克制博弈需要玩家从画面读出"这只怪穿什么甲";本节定三档甲怎么画。键与命名事实源 = `data/schemas/enemy_table.gd` 的 `armor_type` 枚举与 [术语表](../gdd/术语表.md)(轻甲/魔法护甲/重甲)。
> **白话**:敌方有三种护甲类型——轻甲(light)/魔法护甲(ward)/重甲(heavy),克制博弈需要玩家从画面读出"这只怪穿什么甲";本节定三档甲怎么画。键与命名事实源 = `data/schemas/enemy_table.gd` 的 `armor_type` 枚举与 [术语表](../gdd/术语表.md)(轻甲/魔法护甲/重甲,2026-09-15 命名统一批)。
- **通道 = 甲面覆盖率 × 甲材质色**,不触 §5.1 剪影 archetype(底盘与体量轴不动)——「重甲语义走色板不作形状独占」的落法:甲档不新增独占通道(独占 = Boss 双通道专属),装备配件画法不属独占。
- **轻甲 light**:甲面覆盖率≈0——皮条/布带点缀或裸露,体表以物种本色为主。
- **魔法护甲 ward**:甲面覆**符纹饰带**——发光符纹走轮廓描边同族暖白,不占任何语义色(圣经 §4.2),覆盖率居中。
- **重甲 heavy**:**金属甲板**覆盖肩/胸/盾沿,冷灰金属材质色(与全作暖底成材质对比),覆盖率最大。
- **天然甲壳/毛皮属体表,不计甲档**(巨蜘蛛/钩爪恐魔的几丁质是皮肤不是装备)——甲档只读装备甲板/符纹,防群行小体量敌种被误读为重甲。
- **灰度通道**:色弱玩家按**甲面覆盖率**三档可辨(无甲板→符纹带→大面积甲板)——形状+色彩双通道纪律。
- 完整克制读数 = HUD 状态图标(alert/fortify 等,圣经 §4.6.1)+ 本节甲面视觉辅助;克制矩阵数值不在画面上报数。←敌人系统 armor_type
- **通道 = 甲面覆盖率 × 甲材质色**,不触 §5.1 剪影 archetype(底盘与体量轴不动)——「重甲语义走色板不作形状独占」的落法:甲档不新增独占通道(独占 = Boss 双通道专属),装备配件画法不属独占。
- **轻甲 light**:甲面覆盖率≈0——皮条/布带点缀或裸露,体表以物种本色为主。
- **魔法护甲 ward**:甲面覆**符纹饰带**——发光符纹走轮廓描边同族暖白,不占任何语义色(圣经 §4.2),覆盖率居中。
- **重甲 heavy**:**金属甲板**覆盖肩/胸/盾沿,冷灰金属材质色(与全作暖底成材质对比),覆盖率最大。
- **天然甲壳/毛皮属体表,不计甲档**(巨蜘蛛/钩爪恐魔的几丁质是皮肤不是装备)——甲档只读装备甲板/符纹,防群行小体量敌种被误读为重甲。
- **灰度通道**:色弱玩家按**甲面覆盖率**三档可辨(无甲板→符纹带→大面积甲板)——形状+色彩双通道纪律。
- 完整克制读数 = HUD 状态图标(alert/fortify 等,圣经 §4.6.1)+ 本节甲面视觉辅助;克制矩阵数值不在画面上报数。←敌人系统 armor_type;营地扩展前置批
## 5.4 主角三态规格
- **形象锚** = 站姿样张先例改定:**蓝兜帽冒险者(兜帽取下披于颈肩,似短披肩非垂背)、暖米白上衣+深棕工装裤(弃全蓝)、白皙肤色、棕色短发、皮革背带腰带、圆头挖掘铲(待机插地扶柄)、结构式冒险背包插朱红矩形旗(1.5× 体宽)、额前护目镜、腰挂提灯、半指手套与卷裤脚皮护膝**;三态必须同形象(同 seed 起步只换动作短语,提示词卡承载)。
- **兜帽披肩**:兜帽**不戴在头上**,取下披于肩上——露出棕色短发与整脸(表情可读),蓝色兜帽披在肩头仍是最近的"己方"识别点;三态同款披肩。
- **圆头挖掘铲(武器)**:持铲不持剑——**铲 = 本作核心动词"挖安全格"的角色化**,挖掘与攻击共用同一动作语义(攻击表现即挥铲,后续攻击类表现优先围绕铲展开)。画法:圆头铲刃 + 木柄(木柄走木色族 `#8A5A34`,铲刃浅铁灰、不做冷灰重甲质感);**待机态 = 铲插立在地上、一只手扶在铲柄顶端**;攻击/受击态仍为手持。刀光/铲击光效/弹道不进精灵图(归战斗演出另立资产)。
- **冒险背包与背包旗(身份件)**:背负**结构式冒险背包**——翻盖+皮扣带的帆布背包(暖卡其布面,皮革件走木色族),**宽 ≈1.5× 体宽**:双肩背负、两侧各略超出身形一点;**禁圆麻袋化**(措辞弃"鼓胀掳获袋")、**禁特里式 3× 超宽**(反面参照:旷野之息特里;正面参照:合金弹头双主角大背包、超级马里奥奇诺比奥队长)。**旗杆插在背包一侧**(不要求完整可见,**被头部遮挡一部分亦可**),旗子**尺寸大于头部**,短杆**矩形**旗旗面 = **旗朱红 `#C63D22`**(人设背包旗与棋盘封印色**脱钩**——封印色仅作用于棋盘格封印旗,色史归 art-bible §4.1;人设旗形为矩形非三角,以 normal/hit 定稿成图为基准),把"宣告落旗"的角色化焊在人设上,主角即背包旗持有者(**动作呈现名 = 封印**,见 art-bible §3.2;背包旗**不**视作封印印记的携带形态——旗面旗朱红矩形,与棋盘封印黑旗形色均不同,仅保留"主角即背包旗持有者"的身份叙事)。三条纪律:①旗面红=旗朱语义**仅旗非服饰**(负向 `no red clothing` 不拦截正向 `red flag`——红服饰禁令与红旗的边界由正向句显式锁色值;封印黑仍为棋盘格封印旗专属色,圣经 §4.1 不变);②背包+旗 = 主角对 §5.1"对称"的**唯一破例组**(语义配件非装饰),旗面朝向恒定画右、跨素材一致;③轮廓凹凸预算按"背包+旗"整组记账,样张(圣经 §1.4)验证背负后剪影仍窄、仍可辨、不超格宽。
- **护目镜与腰灯**:**护目镜推在额前**(兜帽披肩后直接戴于额上,不戴眼上——眼睛是表情载体;皮革带+圆镜片,防尘挖掘者的职业信号);**提灯挂腰间,定案不手持**(小号铜色提灯,**不点亮**;空间光照属圣经 §2 明度分层允许域,**提灯点亮为候选**,点亮形制(光晕半径/暖色值/与舞台光照关系)归整屏样张重议;头部饰品唯一化,头灯方案已弃)。
- **衣着细节**:**棕色半指手套**(挖土护手,露指保灵巧读感)+ **裤脚卷起、棕色皮护膝**(挖掘跪地的工装感)——小件走皮革木色族,不占剪影凹凸预算,不新增语义色。
- **三态**:① 待机 = 窄、直立、对称(§5.1 小人原型,含背包、旗与提灯记账);② 攻击 = 允许破对称,整体仍窄、不超格宽;③ 受击 = 无血红、无伤害数字(伤害数字归 HUD 层,不进精灵图)。
- **随身件 = 秘宝形象候选池**:铲、旗、背包、提灯四件随身件是未来秘宝设计的**形象候选池**——道具实装可借用主角身上已有的同款(彩蛋闭环:玩家在道具栏认出主角身上的物件);具体道具语义/数值归秘宝 GDD,人设侧义务 = 四件画得出来、认得出。
- **配色(弃全蓝,蓝只在明显处)**:**小人蓝 `#2E86DE` 只保留在兜帽**(披肩状态仍可辨,与棋盘小人/光标识别一致,"全作唯一蓝色"语义不变);上衣改**暖米白 `#EFE3C8`**(数字色牌同族暖纸白)、长裤改**深棕工装 `#6B4A2E`**(木色族深化)——与掩尘棋盘同域协调、靠明度差保持身份识别;皮革件木色族 `#8A5A34`;眼部用数字墨 `#33261B` 族。(色值均候选,样张圣经 §1.4 冻结终稿)←圣经 §4.1
- **语义约束**:蓝只说己方(圣经 §4.2)——主角是全作唯一蓝色角色,敌方与物件主色禁止入蓝族;禁入带 300°–345° 对全角色生效(Boss 独占例外,§5.5)。
- **生成顺序**:先人设定稿(本档 §5.4 + 提示词卡待机态),定稿后再逐状态出图——战斗画面的**侧身面向怪物**形象属状态图,随表现层设计另行生成;状态图需侧/背身时,该卡负向提示词中的 `side view / back view` 条目按卡摘除(人设立绘卡仍保留禁侧背)。
- **棋盘小人**:棋盘格上的主角小人 = **主角人设图压缩至二头身后的简化版像素小人**——随人设定稿派生(同款装备剪影:铲/背包/旗可辨),二头身在 §5.2 两档内;派生工艺(最近邻降采样+手修简化)归整屏样张与脚本管线定。
- **形象锚** = 站姿样张先例(2026-09-16 脚本管线产物)经 2026-09-23 特征批+同日配装修订改定:**蓝兜帽冒险者(兜帽取下披于颈肩,似短披肩非垂背)、暖米白上衣+深棕工装裤(弃全蓝)、白皙肤色、棕色短发、皮革背带腰带、圆头挖掘铲(待机插地扶柄)、结构式冒险背包插旗朱红矩形旗(1.5× 体宽;2026-09-28 旗色脱钩拍板)、额前护目镜、腰挂提灯、半指手套与卷裤脚皮护膝**;三态必须同形象(同 seed 起步只换动作短语,提示词卡承载)。
- **兜帽披肩(2026-09-23 用户拍板)**:兜帽**不戴在头上**,取下披于肩上——露出棕色短发与整脸(表情可读),蓝色兜帽披在肩头仍是最近的"己方"识别点;三态同款披肩。
- **圆头挖掘铲(武器,2026-09-23 用户拍板)**:持铲不持剑——**铲 = 本作核心动词"挖安全格"的角色化**,挖掘与攻击共用同一动作语义(攻击表现即挥铲,后续攻击类表现优先围绕铲展开)。画法:圆头铲刃 + 木柄(木柄走木色族 `#8A5A34`,铲刃浅铁灰、不做冷灰重甲质感);**待机态 = 铲插立在地上、一只手扶在铲柄顶端**(2026-09-23 修订,原"拄于身侧"退役);攻击/受击态仍为手持。刀光/铲击光效/弹道不进精灵图(归战斗演出批另立资产)。
- **冒险背包与插旗(身份件,2026-09-23 用户拍板,同日尺寸修正)**:背负**结构式冒险背包**——翻盖+皮扣带的帆布背包(暖卡其布面,皮革件走木色族),**宽 ≈1.5× 体宽**:双肩背负、两侧各略超出身形一点;**禁圆麻袋化**(措辞弃"鼓胀掳获袋")、**禁特里式 3× 超宽**(反面参照:旷野之息特里;正面参照:合金弹头双主角大背包、超级马里奥奇诺比奥队长)。**旗杆插在背包一侧**(不要求完整可见,**被头部遮挡一部分亦可**),旗子**尺寸大于头部**(2026-09-23 修订,弃"背包顶小旗"旧稿),短杆**矩形**旗旗面 = **旗朱红 `#C63D22`**(2026-09-28 用户拍板:**人设背包旗与棋盘封印色脱钩**——封印色沿革〔旗朱 #C63D22→封印紫 2026-09-26→封印黑 2026-09-28〕**仅作用于棋盘格封印旗**;人设旗维持旗朱红、形为矩形非三角,以 normal/hit 定稿成图为基准),把"插旗宣告"的角色化焊在人设上,主角即插旗者(**插旗动作呈现名 = 封印**,2026-09-25 术语批,见 art-bible §3.2;2026-09-28 脱钩拍板后背包旗**不再**视作封印印记的携带形态——旗面旗朱红矩形,与棋盘封印黑旗形色均不同,仅保留"主角即插旗者"的身份叙事)。三条纪律:①旗面红=旗朱语义**仅旗非服饰**(负向 `no red clothing` 不拦截正向 `red flag`——红服饰禁令与红旗的边界由正向句显式锁色值;封印黑仍为棋盘格封印旗专属色,圣经 §4.1 不变);②背包+旗 = 主角对 §5.1"对称"的**唯一破例组**(语义配件非装饰),旗面朝向恒定画右、跨素材一致;③轮廓凹凸预算按"背包+旗"整组记账,样张批(圣经 §1.4)验证背负后剪影仍窄、仍可辨、不超格宽。
- **护目镜与腰灯(2026-09-23 用户拍板,同日定案)**:**护目镜推在额前**(兜帽披肩后直接戴于额上,不戴眼上——眼睛是表情载体;皮革带+圆镜片,防尘挖掘者的职业信号);**提灯挂腰间,定案不手持**(小号铜色提灯,**不点亮**——原守圣经 §2 明度恒定;2026-09-29 圣经 v3.0 明度分层后,空间光照属允许域,**提灯点亮解禁为候选**,点亮形制(光晕半径/暖色值/与舞台光照关系)归整屏样张批重议,本批不改成图;头部饰品唯一化,头灯方案退役)。
- **衣着细节(2026-09-23 用户拍板)**:**棕色半指手套**(挖土护手,露指保灵巧读感)+ **裤脚卷起、棕色皮护膝**(挖掘跪地的工装感)——小件走皮革木色族,不占剪影凹凸预算,不新增语义色。
- **三态**:① 待机 = 窄、直立、对称(§5.1 小人原型,含背包、旗与提灯记账);② 攻击 = 允许破对称,整体仍窄、不超格宽;③ 受击 = 无血红、无伤害数字(伤害数字归 HUD 层,不进精灵图)。
- **随身件 = 秘宝形象候选池(2026-09-23 用户提案登记)**:铲、旗、背包、提灯四件随身件是未来秘宝批的**形象候选池**——道具实装可借用主角身上已有的同款(彩蛋闭环:玩家在道具栏认出主角身上的物件);具体道具语义/数值归秘宝批 GDD,人设侧义务 = 四件画得出来、认得出。
- **配色修订(2026-09-23 用户拍板:弃全蓝,蓝只在明显处)**:**小人蓝 `#2E86DE` 只保留在兜帽**(披肩状态仍可辨,与棋盘小人/光标识别一致,"全作唯一蓝色"语义不变);上衣改**暖米白 `#EFE3C8`**(数字色牌同族暖纸白)、长裤改**深棕工装 `#6B4A2E`**(木色族深化)——与掩尘棋盘同域协调、靠明度差保持身份识别;皮革件木色族 `#8A5A34`;眼部用数字墨 `#33261B` 族。(色值均候选,样张批圣经 §1.4 冻结终稿)←圣经 §4.1
- **语义约束**:蓝只说己方(圣经 §4.2)——主角是全作唯一蓝色角色,敌方与物件主色禁止入蓝族;禁入带 300°–345° 对全角色生效(Boss 独占例外,§5.5)。
- **生成顺序口径(2026-09-23 用户确认)**:先人设定稿(本档 §5.4 + 提示词卡待机态),定稿后再逐状态出图——战斗画面的**侧身面向怪物**形象属状态图,随表现层批次另行生成;状态图需侧/背身时,该卡负向提示词中的 `side view / back view` 条目按卡摘除(人设立绘卡仍保留禁侧背)。
- **棋盘小人(2026-09-23 用户拍板,原开放项定案)**:棋盘格上的主角小人 = **主角人设图压缩至二头身后的简化版像素小人**——随人设定稿派生(同款装备剪影:铲/背包/旗可辨),二头身在 §5.2 两档内;派生工艺(最近邻降采样+手修简化)归一屏样张批与脚本管线定。
## 5.5 八敌种逐卡(键集与甲档事实源 = `data/tables/enemy.tres`,本表随其修订自动跟随)
## 5.5 八敌种逐卡(键集与甲档事实源 = `data/tables/enemy.tres`,本表随其修订自动跟随,2026-09-25 甲档核对回填)
| 键(enemy_id) | 显示名 | archetype 档 | 甲档 | 主色域与特征锚 |
| 键(enemy_id) | 显示名 | archetype 档 | 甲档 | 主色域与特征锚 |
|---|---|---|---|---|
| goblin | 地精 | 普通怪 | 轻甲 | 橄榄绿皮、圆耳墩形,木棒;Peglin 式胖体量,先读轮廓后读颜色 |
| bugbear | 熊地精 | 普通/精英两版候选出齐供拍板 | 轻甲 | 棕黄蓬毛、獠牙下颚;粗陋皮条无甲板无符纹(轻甲语义,§5.3);精英版 = 恰一处垂直冠棘;**L2 熊地精 = 本键复用**(见下注) |
| ogre_chief | 食人魔酋长 | 精英(第一关关底 Boss 战,形状仍守精英档) | 轻甲 | 暖褐皮、肚圆顶冠;甲面覆盖率≈0——皮条/骨饰点缀、裸身为主(轻甲语义,§5.3) |
| giant_spider | 巨蜘蛛 | 普通怪(群行 [2,3]) | 轻甲 | 炭棕毛+蜜色腹纹;腿收短收粗,小体量可辨 |
| drider | 蛛化卓尔精灵 | Boss(第二关) | 魔法护甲 | 紫赤 `#8F2F6A` 色相独占+形状独占双通道;卓尔刺客上体+蛛腹;符纹饰带(魔法甲语义,§5.3) |
| duergar | 灰矮人 | 精英 | 重甲 | 灰皮灰辫须;冷灰金属甲板+高盔冠(恰一处垂直延伸) |
| hook_horror | 钩爪恐魔 | 普通怪(群行) | 轻甲 | 骨灰几丁质、圆钩双爪(圆钩非锐角);小体量可辨 |
| duergar_king | 灰矮人石君王 | Boss(第三关) | 重甲 | 紫赤独占+形状独占双通道;岩石板甲+不对称王冠,大地/岩石主题(承载技能语义见敌人系统) |
| goblin | 地精 | 普通怪 | 轻甲 | 橄榄绿皮、圆耳墩形,木棒;Peglin 式胖体量,先读轮廓后读颜色 |
| bugbear | 熊地精 | 普通/精英两版候选出齐供拍板 | 轻甲 | 棕黄蓬毛、獠牙下颚;粗陋皮条无甲板无符纹(轻甲语义,§5.3);精英版 = 恰一处垂直冠棘;**L2 熊地精 = 本键复用**(见下注) |
| ogre_chief | 食人魔酋长 | 精英(第一关关底 Boss 战,形状仍守精英档) | 轻甲 | 暖褐皮、肚圆顶冠;甲面覆盖率≈0——皮条/骨饰点缀、裸身为主(轻甲语义,§5.3) |
| giant_spider | 巨蜘蛛 | 普通怪(群行 [2,3]) | 轻甲 | 炭棕毛+蜜色腹纹;腿收短收粗,小体量可辨 |
| drider | 蛛化卓尔精灵 | Boss(第二关) | 魔法护甲 | 紫赤 `#8F2F6A` 色相独占+形状独占双通道;卓尔刺客上体+蛛腹;符纹饰带(魔法甲语义,§5.3) |
| duergar | 灰矮人 | 精英 | 重甲 | 灰皮灰辫须;冷灰金属甲板+高盔冠(恰一处垂直延伸) |
| hook_horror | 钩爪恐魔 | 普通怪(群行) | 轻甲 | 骨灰几丁质、圆钩双爪(圆钩非锐角);小体量可辨 |
| duergar_king | 灰矮人石君王 | Boss(第三关) | 重甲 | 紫赤独占+形状独占双通道;岩石板甲+不对称王冠,大地/岩石主题(承载技能语义见敌人系统) |
- **主色域 = 物种本色**,不受圣经 §4.2 语义色禁则约束(该禁则管格面与 UI 的逻辑色,角色身体主色豁免);**两例外**:禁入带 300°–345° 全角色禁入(仅 drider/duergar_king 两 Boss 独占)、蓝族禁入敌方主色(§5.4 同款)。
- **L2 熊地精 = bugbear 复用**(第二关三行,显示名熊地精系,非独立键):同剪影**换色板不换形**(色板替换走脚本管线——原则三"先换色板"的角色侧应用);变体主色避小人蓝族与禁入带。
- 每敌种出 2–3 版候选(清单 §2.2);受击反馈/死亡表现/顺位标记三类呈现下限见圣经 §4.6.4 资产需求行。
- **立绘源档高(定值单点 = 圣经 §8.2 立绘源档表,本档指路不复述)**:八敌 36–94 逐键定档 + 主角 48 不变、显示一律 ×2 整数档(NEAREST)——**数值权威一律圣经 §8.2**(定值单点纪律,圣经 §8 头注);定档经三体型约束校验(§5.2)确认;派生管线与排版规范同归圣经 §8.2。
- **钩爪恐魔「宽≥高」豁免登记(待样张终审)**:定档源 58 高窄条束状剪影(长臂圆钩),宽高比 <1,与 §5.1 普通怪「近圆墩形宽≥高」冲突——按体量=生物天性(§5.2)登记豁免:以长臂束状轮廓为识别锚,不强行压成墩形;样张(圣经 §1.4)终审复验灰度剪影可辨性。
- **主色域 = 物种本色**,不受圣经 §4.2 语义色禁则约束(该禁则管格面与 UI 的逻辑色,角色身体主色豁免——提示词卡 2026-09-20 成例);**两例外**:禁入带 300°–345° 全角色禁入(仅 drider/duergar_king 两 Boss 独占)、蓝族禁入敌方主色(§5.4 同款)。
- **L2 熊地精 = bugbear 复用**(第二关三行,显示名熊地精系,非独立键):同剪影**换色板不换形**(色板替换走脚本管线——原则三"先换色板"的角色侧应用);变体主色避小人蓝族与禁入带。
- 每敌种出 2–3 版候选(清单 §2.2);受击反馈/死亡表现/顺位标记三类呈现下限见圣经 §4.6.4 资产需求行。
- **立绘源档高(2026-10-06 定档批;定值单点 = 圣经 §8.2 立绘源档表,本档指路不复述)**:八敌 36–94 逐键定档 + 主角 48 不变、显示一律 ×2 整数档(NEAREST)——**数值权威一律圣经 §8.2**(定值单点纪律,圣经 §8 头注);定档经用户三体型约束校验(§5.2)与定档总图确认;派生管线与排版规范同归圣经 §8.2。
- **钩爪恐魔「宽≥高」豁免登记(2026-10-06,待样张批终审)**:定档源 58 高窄条束状剪影(长臂圆钩),宽高比 <1,与 §5.1 普通怪「近圆墩形宽≥高」冲突——按体量=生物天性(§5.2 修订)登记豁免:以长臂束状轮廓为识别锚,不强行压成墩形;样张批(圣经 §1.4)终审复验灰度剪影可辨性。
## 5.6 意图准备动画三档(规格承载节)
## 5.6 意图准备动画三档(2026-09-18 用户拍板,规格承载节)
> **白话**:敌人下一拍做什么,不用文字预告——用三档动作动画让玩家"看姿态就知道要有事"。
> **白话**:敌人下一拍做什么,不用文字预告——用三档动作动画让玩家"看姿态就知道要有事"。
- **三档**:① 普通攻击 = 常态待机(待机即预示,零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器等"摇号感"动作);③ 重击/硬化类 = 蓄力动作或防御姿态。
- **双通道纪律**:三档在灰度/剪影层可辨(色弱安全,圣经 §4.5 同款要求)。
- **呈现纪律**:不显示文字与技能名(文字与技能名彻底退出预告);映射按 enemy_id **全局恒定、跨 run 禁重掷**;预告时序 = 下一拍,挖掘期全程可见,结算不另设独立意图播片。契约锚 = 圣经 §4.6.4 意图姿态行。
- **终稿帧规格**(帧数/时长/结算节拍对齐)归圣经 §8 资产规范与战斗一屏样张(清单 §2.3);占位符号(—/⚔/✦/蓄力)现行使用,动画到位后原位替换。
- **三档**:① 普通攻击 = 常态待机(待机即预示,零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器等"摇号感"动作);③ 重击/硬化类 = 蓄力动作或防御姿态。
- **双通道纪律**:三档在灰度/剪影层可辨(色弱安全,圣经 §4.5 同款要求)。
- **呈现纪律**:不显示文字与技能名(文字与技能名彻底退出预告);映射按 enemy_id **全局恒定、跨 run 禁重掷**;预告时序 = 下一拍,挖掘期全程可见,结算不另设独立意图播片。契约锚 = 圣经 §4.6.4 意图姿态行。
- **终稿帧规格**(帧数/时长/结算节拍对齐)归圣经 §8 资产规范与战斗一屏样张批(清单 §2.3);占位符号(—/⚔/✦/蓄力)现行在役,动画到位后原位替换。
---
+4 -5
View File
@@ -1,8 +1,7 @@
# 远程生图提示词单(对话式生图操作单)
> **渠道定位**:生图渠道 = **远程对话式生图为主,本地 ComfyUI 为备选**;本文件是**对话操作单**(怎么开对话/贴什么参考图/怎么续写),提示词正文词库归 [art-prompts](art-prompts.md)——分工:词库改词库、操作单改流程,同一段正文不双处维护。
> **冲突优先级**:画法与色彩以 [art-bible](art-bible.md) 为准,人物规格以 [character-design](character-design.md) 为准;本单为执行侧操作单,不另立规格。
> **出图登记义务(复现性)**:每张产出登记 **模型名 + 分辨率 + seed(可取则取)+ 对话/日期**,随候选原图同存 `artwork/candidates/<资产名>/`;缺登记的候选不入正稿遴选。
> **渠道定位(2026-10-06 拍板)**:生图渠道 = **远程对话式生图为主,本地 ComfyUI 为备选**(v2.7「彻底关闭」系当时口径,现行更正为备选);本文件是**对话操作单**(怎么开对话/贴什么参考图/怎么续写),提示词正文词库归 [art-prompts](art-prompts.md)——分工:词库改词库、操作单改流程,同一段正文不双处维护。
> **出图登记义务(2026-10-06 P1-1 复现性)**:每张产出登记 **模型名 + 分辨率 + seed(可取则取)+ 对话/日期**,随候选原图同存 `artwork/candidates/<资产名>/`;缺登记的候选不入正稿遴选。
> 开新对话出图:同一角色 2–3 版候选各开新对话(同对话续写=修图);同角色换姿势在同对话续写。分辨率开大档;候选原图存 `artwork/candidates/<资产名>/`。
## 1. 主角战斗立绘
@@ -79,10 +78,10 @@ Pixel art style. true pixel art game sprite drawn on a tiny 48x72 pixel canvas,
## 3. 瓦片纹理
**制式 = 拼表制主选——三步流程**:
**制式 = 拼表制主选(2026-10-06 用户拍板)——三步流程**:
1. **程序占位出样图**:现行程序化格面(占位在役)截一张作参考样图;
2. **整图生成**:开新对话,贴样图 + 发下方整段,一张 2×2 出全部瓦片变体(风格统一、一张管全族);
3. **裁切取用**:按 50% 坐标切图、每格内缩 2–3% 切掉可能的边框 → 统一降采样 → 成片瓦片 21 枚由程序从切出的材质合成(查表已落码 `board_world.gd`)。
3. **裁切取用**:按 50% 坐标切图、每格内缩 2–3% 切掉可能的边框 → 统一降采样 → 成片瓦片 21 枚由程序从切出的材质合成(词表:高墙瓦片词表(已落码 board_world.gd 查表,文件已退役))。
```
Seamless tileable pixel art texture sheet for a dungeon board game, arranged as a clean 2x2 grid of four equal square quadrants filling the entire image edge to edge, with no gaps, no dividing lines, no borders, no frames, no grid lines, no labels and no text. Top-left quadrant: warm tan rock surface with subtle darker seams between stone patches and small lighter facets. Top-right quadrant: the same warm tan rock surface but with a few thin darker crack lines scattered across it. Bottom-left quadrant: the same warm tan rock surface but with a few tiny lighter stone speckles sparsely scattered. Bottom-right quadrant: dry dirt ground in a dark warm brown palette around #B39A78, clearly darker than the tan rock, with a few thin cracks and tiny pebble speckles, sparse. Every quadrant is a flat 2D top-down texture — not an object, no perspective — and each quadrant must wrap seamlessly at its own four edges. Uniform directionless lighting across all four quadrants, no vignette, no cast shadow, no bevel, no edge highlight anywhere. Warm tan-brown palette around #C9AF83 for the three rock quadrants. Clean retro pixel art look, limited palette, no gradients, no text, square image.
+1 -1
View File
@@ -54,7 +54,7 @@
11. **文档同步闸(实现批禁写文档)**——实现批**禁写 `design/` 整个目录与根规则文件**,要往外交代的东西一律落 `reports/`:实现报告 `reports/iter<N>-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务+实现决定记录——超白名单/超卡片明列范围事项逐条记「决了什么/为什么/代价/撤销方式」,全部按卡执行时只写一行「零拍板」)、GDD 反哺点清单 `reports/iter<N>-反哺点-<日期>.md`(每条写清哪个文件哪一节、改成什么、依据是什么)。设计权威文档(GDD/art-bible)**任何批都不直改**:用户在会话中逐点裁决后,由工程/文档设计改动批(直改类)写入 `design/`(执行入口=doc-design 技能),并把反哺条目从 `reports/` 清掉(吸收即死;退役执行=batch-closure 技能)。机械面文档(roadmap 行/术语表/README 工具注)同样归工程/文档设计改动批在合并前更新。反哺点未落地期间相关 GDD 节视为待反哺,读前先查 `reports/` 与待办清单;需求卡片必列「关联文档同步面」按两层标注
12. **实机目检归用户,只登记不阻塞**——真人检测项登记进实现报告目检总清单与体验验证清单即完成批内义务;禁把等目检当停工理由或完成前置
13. **中间产物吸收即死**——细则见 AGENTS.md 硬性红线与 doc-conventions 第 11 节
14. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动的验收断言从真实入口进入、在当轮真实跑批中执行并通过(判据绑定面口径=test-conventions §8.4;禁变异类测试与撤销类取证,2026-10-24/28 用户两裁);合回前独立验收审计。细则=test-conventions §8
14. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动做撤销证明;合回前独立验收审计。细则=test-conventions §8
## 4. 测试执行两入口铁规(细则)
+1 -1
View File
@@ -97,7 +97,7 @@
- **反向**:diff 里每个行为改动至少一行;没有对应行的改动,必须写明「非行为改动」或「由第 X 行覆盖」。
- **测试侧**:新增或改写的每条断言至少挂一行;挂不上的孤儿断言删掉或补挂到需求点。
**能力清单维护锚(2026-10-28 用户裁,验证与清单体系整改阶段 1;阶段 2 后定稿)**:逐断言账本 `game/tests/验证集清单.md` 已随阶段 2「存量断言精简」完成退役——冻结对账基准(基点 `b9260138`/1587 条)与精简对账结果(33 站点并为 7、1587→1561,合回 `87d241c1`)留 git 历史。覆盖索引由 `game/tests/README.md`「能力清单 v1」唯一承担:每行一个能力,给真实入口与 2–3 个代表用例(`文件:函数`)。维护义务——**每批收口核对本批受影响能力行**:代表用例可 grep、在本批真实跑批中执行、真实入口未绕过改动点,并刷新「最后验证批次」;新增能力加行,能力退役删行。改动—验收对照表纪律不变:新增或改写的每条断言仍须挂一行(上文「测试侧」);断言改判仍受 §8.4 真实入口口径约束。
**验证集清单维护锚(2026-10-22 用户裁)**:`game/tests/验证集清单.md` 是迁移断言全体的冻结参照清单(机器提取生成、只剪切禁改写、出口逐条核对,机制见该文件头注;清单自断言迁移批第 0 步建立时起存在)——清单在册后,任何断言**新增、删除或改判**必须同步该清单,与改动—验收对照表同一条纪律:清单没跟上的断言改动视同未完成。
### 8.4 真实入口断言(验收闭合的主判据)
+1 -1
View File
@@ -15,7 +15,7 @@
| `术语表.md` | 全库专用术语的唯一对照表——危险格/安全格/同调位/RAP/GP/真视等定义+退役词对照(2026-10-11 建立,v1.23) |
| `待办清单.md`(仓库根,不属本树) | 全项目待办事项的唯一登记处;各系统文档的「待办」节只留指路存根 |
| `体验验证清单.md`(仓库根,不属本树) | 需要真人试玩观测的验证项登记处(与待办清单分工:待办清单是"要拍板的事",本篇是"要玩过才知道的事");每条注明结论回流路径 |
| `08-Alpha版本标准.md` | Alpha 版本包含哪些东西的清单——里程碑 M3 的完成标准;只列条目不写内容,定义一律指路各权威文档(**已定稿 v1.19**,2026-10-13) |
| `08-Alpha版本标准.md` | Alpha 版本包含哪些东西的清单——里程碑 M3 的完成标准;只列条目不写内容,定义一律指路各权威文档(**已定稿 v1.17**,2026-10-13) |
## systems/ 五个循环
+2 -2
View File
@@ -2,7 +2,7 @@
> **一句话定位**:本文定义这个项目的 **Alpha 版本包含哪些东西**——它是里程碑 M3 的完成标准,也是判断"还差什么"的对照表。
> **本文只列条目,不写内容**:每样东西是什么、规则怎么走,一律归各自的权威文档,本文只给指路链接。想知道某样东西的定义,请点链接,不要在本文里找解释。
> **状态**:**已定稿(v1.19)**——2026-10-13 用户令定稿,历次升版明细见 git 提交史。Alpha 的内容范围自此冻结:增删条目一律须经用户裁决。
> **状态**:**已定稿(v1.18)**——2026-10-13 用户令定稿,历次升版明细见 git 提交史。Alpha 的内容范围自此冻结:增删条目一律须经用户裁决。
> **成文**:2026-10-13。
## 本文怎么用
@@ -48,7 +48,7 @@ Alpha 内以玩法循环本身的内容与表现为主(棋盘、关卡、战
| 冒险结构 | [冒险结构](systems/冒险循环/冒险结构.md) | 在役 |
| 关卡节点地图 | [关卡节点地图](systems/冒险循环/关卡节点地图.md) | 在役(信息迷雾类型可见性已随迭代 34 卡④ 实装,2026-10-22) |
| 关卡节点图重构(增加可选节点) | [关卡节点地图](systems/冒险循环/关卡节点地图.md)+[关卡节点图生成](systems/冒险循环/关卡节点图生成.md) | 在役(2026-10-25 迭代 35 卡①② 合回:图源切为参数档×种子生成图、模板链路退役;真人目检选路项已登记体验验证清单,登记不阻塞) |
| 关卡节点图生成 | [关卡节点图生成](systems/冒险循环/关卡节点图生成.md)(参数化长图:产出规格与生成参数) | 在役(2026-10-24 迭代 35 卡① 生成器核心合回;enemy.tres 与 board_gen.tres 已覆盖到第三关深度 13;生成图 L3 深度域 0–13 超出关卡地图屏画布屏预算约 10 层——等比缩放/滚动视口两升级路径未设计待裁,[待办清单](../../待办清单.md)「超预算图策略升级路径」条在册) |
| 关卡节点图生成 | [关卡节点图生成](systems/冒险循环/关卡节点图生成.md)(参数化长图:产出规格与生成参数) | 在役(2026-10-24 迭代 35 卡① 生成器核心合回;enemy.tres 与 board_gen.tres 已覆盖到第三关深度 13) |
| 难度曲线与 Boss | [难度曲线与Boss](systems/冒险循环/难度曲线与Boss.md) | 在役 |
| 奇物 | [奇物](systems/冒险循环/奇物.md) | 在役 |
+1 -15
View File
@@ -1,7 +1,7 @@
# 术语表
> 全库专用术语的唯一对照表(2026-10-11 用户令建立)。规矩:新术语先登记本表、再在文档使用;退役词见文末对照表,文档正文一律使用现行词。文档中遇到看不懂的词,查本表。用词纪律(禁用词、替换值、豁免范围)见「用语规范」节。
> 状态:**现行 v1.24**——2026-10-11 建立;历次升版明细见 git 提交史。
> 状态:**现行 v1.23**——2026-10-11 建立;历次升版明细见 git 提交史。
## 格与棋盘
@@ -97,20 +97,6 @@
| `seal_missed` | 封印落空事件(原 `flag_missed` 换代):封印动作落在安全格上时发出,订阅面据此做限次与豁免判定(迭代 31 卡② 落地) |
| `forgive_seal_miss` | 本场首次封印落空豁免——专长「完美保险」的效果类型,`domain=battle` 首例;有效误标=误标总数 − 豁免数,豁免不改写误标账(迭代 31 卡② 落地) |
## 美术表现
| 术语 | 定义 |
|---|---|
| 样张 | 按美术圣经参数画成的风格试验图,是砖面形态等视觉定稿的验收闸(规格见[美术圣经](../art/art-bible.md) §1.4) |
| 整屏样张 | 整屏级样张——战斗/关卡/菜单/营地屏各出一张,验屏级文字对比度、明度分层与焦点预算 |
| 掩尘 | 未开格底色的专色名——覆尘完整砖的「未知」面色 |
| 裸土 | 已开格底色的专色名——碎裂裸露空间的沉暗面色 |
| 色牌 | 垫在数字正下方的素色直角小牌,复杂格底时保数字可读的后备手段 |
| 图类型 | 棋盘生成产物 `board_type` 的中文名——三关换皮的单位(同款地砖换色板) |
| 方向稿 | 冻结前的方向性候选稿,供气质目检;过目检后才进机检冻结 |
| 站桩 | 战斗画面区(B 区)里角色直立立绘的显示形态(配血条与状态行) |
| 徽章 | 库存条与结算明细用的小图标(与格面类型微光预读相区分) |
## 开发流程
| 术语 | 定义 |
+12 -11
View File
@@ -30,10 +30,7 @@ var _rap := 0
var rap: int:
get: return _rap
var _last_round_end: Dictionary[String, String] = {} # {path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源
var last_round_end: Dictionary[Variant, Variant]:
get: return _last_round_end
var _settlement_consumed := false # AC-14 门:notify_settlement_done 消费后才受理 round_start
var _next_round_mode := "" # 调试观测量(同板续轮 / 换新图)
var _pending_ap_delta := 0 # 下轮行动点修正排队(action 型 ±1 排队,轮开始兑现后清零)
# 蓄力流运行态:转蓄力=round_end grant_ap(mode:transfer) 订阅(finish_round_stop 定夺)、
# 深挖对价=dig_paid counter_spend 订阅(request_dig 施加面);charge_layers=蓄力层账本
@@ -44,7 +41,6 @@ var charge_layers: int:
var _fx: EffectEngine = null
var _ctx: EffectContext = null
var _last_stop_ap := 0 # 停手侧剩余 AP 记账(转存决策在 round_end 发射点——grant_ap(transfer) 订阅定夺)
var _ap_exhausted_last := false # 上轮 AP 耗尽封印(一滴不剩 round_start when 面)
func set_fx(fx: EffectEngine, ctx: EffectContext) -> void:
@@ -204,9 +200,9 @@ func request_refresh() -> bool:
if _phase != Phase.ACTIVE:
_reject("phase")
return false
if _rap < db.action_economy.rap_refresh_cost:
if _rap < db.run.action_economy.rap_refresh_cost:
return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误
_rap -= db.action_economy.rap_refresh_cost
_rap -= db.run.action_economy.rap_refresh_cost
return true
@@ -216,7 +212,6 @@ func notify_settlement_done(next_round_mode: String) -> bool:
_reject("phase")
return false
_settlement_consumed = true
_next_round_mode = next_round_mode
return true
@@ -249,7 +244,7 @@ func notify_round_start() -> bool:
else:
_reject("phase")
return false
_ap = maxi(0, db.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0)
_ap = maxi(0, db.run.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0)
_pending_ap_delta = 0
_phase = Phase.ACTIVE
_settlement_consumed = false
@@ -272,13 +267,19 @@ func _finish_round(path_tag: String, continuation_hint: String) -> void:
assert(path_tag != "ap_exhausted" or _ap == 0, "耗尽轮剩余行动点恒为 0(实现层断言兜底)")
assert(path_tag != "cleared" or _ap == 0, "清图轮剩余行动点当场清零(不进储备池)")
_last_round_end = {"path_tag": path_tag, "continuation_hint": continuation_hint}
_ap_exhausted_last = path_tag == "ap_exhausted" # 一滴不剩 when 面(轮始发射读数)
_phase = Phase.WAITING
# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末)
# 上轮组包读数({path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源;
# 属性→方法 K5 换形,消费方 minesweeper_rules 单点改址)
func last_round_end() -> Dictionary[String, String]:
return _last_round_end
# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末)——
# 派生读(同源同刻:_last_round_end 组包点即派生原点,K5 派生合并——存储成员退役)
func ap_exhausted_last_round() -> bool:
return _ap_exhausted_last
return _last_round_end.get("path_tag", "") == "ap_exhausted"
func _reject(source: String) -> void:
+7 -7
View File
@@ -105,7 +105,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
_stats.armor_type = String(params.get("armor_type", ""))
summary = {}
_reset_curio_state()
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = 1
_pending_next_level = false
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动)
@@ -120,7 +120,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
# 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓:
# 装配集 kind 分发扫描;只入存量账、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕)
for iid_v2 in _attunement:
var rp_row: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v2))
var rp_row: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v2))
if (rp_row.get("effect_kinds", []) as Array).has("refresh_stock_grant"):
_refresh_bank.add_refresh_stock(int(rp_row.get("magnitude", 0)))
# 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派
@@ -184,7 +184,7 @@ func _resume_ledger(bp: Dictionary, params: Dictionary) -> void:
_wallet.restore_fragment(bp, params)
_journal.restore_fragment(bp, params)
summary = {}
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = int(bp.get("level_index", 1))
_pending_next_level = false
@@ -223,7 +223,7 @@ func _resume_params(bp: Dictionary, params: Dictionary) -> void:
#(如调试浮窗调高期写出的值,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限,
# 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。钳制式单点归
# 数值账 clamp_hp_to_resume_cap(保守口径不含加算区——与原钳制式逐字同)。
_stats.clamp_hp_to_resume_cap(int(db.hero.max_hp))
_stats.clamp_hp_to_resume_cap(int(db.combat.hero.max_hp))
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy)
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
@@ -330,7 +330,7 @@ func present_node_curio(candidates: Array) -> void:
func _attune_refresh_x_total() -> int:
var total := 0
for iid_v in _attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("bonus_refresh"):
total += int(arow.get("magnitude", 0))
return total
@@ -369,8 +369,8 @@ func _loadout_on_level_inventory() -> void:
func _boss_keystone_drop(li: int) -> void:
var first := true
if _save_manager != null:
first = not _save_manager.boss_first_kill_marked(li)
var row: Dictionary[Variant, Variant] = db.meta_boss_keystone_row(li)
first = not _save_manager.meta_state.career_state().boss_first_kill.has(str(li))
var row: Dictionary[Variant, Variant] = db.crossrun.meta.boss_keystone_row(li)
var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0))
_journal.record_keystone_drop({"level": li, "first": first, "amount": amount}) # 逐杀明细+累计单写原语
+3 -3
View File
@@ -33,7 +33,7 @@ func item_ids() -> Array:
func seed_for_adventure(inventory: TreasureItems) -> void:
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) != "active_charged":
continue
_ensure_rows(iid, maxi(0, int(arow.get("charges_per_adventure", 0))), inventory)
@@ -44,7 +44,7 @@ func seed_for_adventure(inventory: TreasureItems) -> void:
func reset_cycle(cycle: String, inventory: TreasureItems) -> void:
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) != "active_charged":
continue
if String(arow.get("charge_reset", "adventure")) != cycle:
@@ -94,7 +94,7 @@ func breakpoint_fragment(inventory: TreasureItems) -> Dictionary[Variant, Varian
var charges: Dictionary[Variant, Variant] = {}
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) == "active_charged":
charges[iid] = charges_left(iid, inventory)
return {"attunement_charges": charges}
+1 -1
View File
@@ -12,6 +12,6 @@ static func slots(db: ConfigDb, save: Variant) -> int:
return 0
var n := 0
for i in range(1, 4):
if save.tree_level("att_socket%d" % i) >= 1:
if save.meta_state.tree_state().nodes.get("att_socket%d" % i, 0) >= 1:
n += 1
return n
+1 -1
View File
@@ -175,5 +175,5 @@ func _fx_fact(fact: String) -> Variant:
# 基线 = enc 口径,-1(表值兜底态)时解析为表值后叠加,守恒口径与 begin_battle 注入一致)
func _sync_hero_ac() -> void:
if int(ac.base) < 0:
ac.base = int(db.hero.armor_class)
ac.base = int(db.combat.hero.armor_class)
damage.set_hero_ac_override(int(ac.base) + int(ac.round) + int(ac.battle))
+20 -22
View File
@@ -180,7 +180,7 @@ func attune_has(item_id: String) -> bool:
func attune_param_sum(kind: String) -> int:
var total := 0
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has(kind):
total += int(arow.get("magnitude", 0))
return total
@@ -190,7 +190,7 @@ func attune_param_sum(kind: String) -> int:
# 首行语义=装配集内至多一件积点制〔试炼镐〕)
func _attune_charge_safe_row() -> Dictionary:
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("ap_charge_safe"):
return arow
return {}
@@ -301,10 +301,8 @@ func _begin_battle_accept(enc: Dictionary) -> bool:
_fatal("敌人表 行集解析为空(%d,%d,%s)" % [int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))])
return false
# E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径)
_hero_atk = int(enc.get("hero_atk", int(db.hero.atk)))
if not _enemy_system.spawn(int(enc.level_index), int(enc.node_depth), int(enc.hero_hp), dice[0],
_hero_atk, int(enc.get("hero_max_hp", -1)), dice[1],
String(enc.get("attack_type", "")), String(enc.get("armor_type", "")), String(enc.get("node_type", "battle"))): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss)
_hero_atk = int(enc.get("hero_atk", int(db.combat.hero.atk)))
if not _enemy_system.spawn({"level_index": int(enc.level_index), "node_depth": int(enc.node_depth), "hero_hp": int(enc.hero_hp), "unit_count_overrides": dice[0], "hero_atk_override": _hero_atk, "hero_max_hp_override": int(enc.get("hero_max_hp", -1)), "enemy_atk_overrides": dice[1], "hero_attack_type": String(enc.get("attack_type", "")), "hero_armor_type": String(enc.get("armor_type", "")), "row_kind": String(enc.get("node_type", "battle"))}): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss)
_fatal("敌人系统 spawn 被拒")
return false
return true
@@ -317,7 +315,7 @@ func _begin_battle_accept(enc: Dictionary) -> bool:
# 返回 [count_overrides, atk_overrides];行集为空 = 返回 [](fail 判据交受理段 _fatal)。
func _begin_battle_dice(enc: Dictionary) -> Array:
# 行类维度:节点身份 battle/boss 决定行集键域(缺省 battle 只服务测试直呼面——生产路径恒传 node_type)
var erowset_pre: Array[Variant] = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle")))
var erowset_pre: Array[Variant] = db.combat.enemy.resolve_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle")))
if erowset_pre.is_empty():
return []
var needs_count := false
@@ -462,7 +460,7 @@ func _install_new_board(during_round := false) -> bool:
func _resolve_global_variant(board: BoardData) -> void:
board.board_variants.clear()
_reward_economy.board_skew = 0
var row: Dictionary[Variant, Variant] = db.global_variant_row(int(encounter.level_index), String(board.board_type),
var row: Dictionary[Variant, Variant] = db.combat.variation.global_variant_row(int(encounter.level_index), String(board.board_type),
String(encounter.get("node_type", "")))
if row.is_empty():
return
@@ -482,7 +480,7 @@ func _resolve_global_variant(board: BoardData) -> void:
func _place_variants(board: BoardData) -> void:
if not board.board_variants.is_empty():
return
var pool: Array[Variant] = db.variant_pool(int(encounter.level_index), String(board.board_type))
var pool: Array[Variant] = db.combat.variation.variant_pool(int(encounter.level_index), String(board.board_type))
if pool.is_empty():
return
var r0 := {}
@@ -496,8 +494,8 @@ func _place_variants(board: BoardData) -> void:
return
var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次)
var vrow: Dictionary[Variant, Variant] = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(candidates.size()) * float(db.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus)
var total: int = int(round(float(candidates.size()) * float(db.combat.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.combat.variation.per_level_bonus)
total = mini(total, candidates.size())
_place_variants_apply(board, candidates, stream, vrow, total)
@@ -506,7 +504,7 @@ func _place_variants(board: BoardData) -> void:
func _place_variants_apply(board: BoardData, candidates: Array[Vector2i], stream: RandomNumberGenerator, vrow: Dictionary, total: int) -> void:
var placed := {}
while placed.size() < total:
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.variation.pack_max)))
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.combat.variation.pack_max)))
var free: Array[Vector2i] = []
for c in candidates:
# 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量
@@ -638,7 +636,7 @@ func _harmful_variants(vids: Array) -> Array:
var out: Array[Variant] = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary[Variant, Variant] = db.variation.row_by_id(vid)
var vrow: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(vid)
if String(vrow.get("effect", "")) == "flat_damage":
out.append(vid)
return out
@@ -677,7 +675,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void:
# 统一流:behavior_roll 无子流派生向量(位置交错,单点定位由事件日志承载)。
# 摇定后、兑现前死亡 → 本拍技能作废。
func _roll_round_actions() -> void:
var pity_m: int = int(db.skill.pity_threshold)
var pity_m: int = int(db.combat.skill.pity_threshold)
# facts 组包(开轮摇号前一次成型——last_round_end 空字典首轮映射 none 唯一落点;hero 事实不进 facts,
# 容器内直读主角行)
var facts := {
@@ -692,7 +690,7 @@ func _roll_round_actions() -> void:
# 技能名提示〔轻量 toast 非演出——D8 呈现面〕。门=确定性谓词,机制面零随机,摇号随机
# 消费仍在 behavior_roll 既有流——同种子逐位复现不破坏)
for sid in _enemy_system.check_trigger_unlocks(u, facts):
var srow_u: Dictionary[Variant, Variant] = db.skill_row(sid)
var srow_u: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
_emit_row({"type": "highlight", "source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "highlight": "skill_unlock"},
round_index, "%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))],
0, {"skill_id": sid})
@@ -733,7 +731,7 @@ func _defender_ac(unit: Dictionary) -> int:
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id)
if not row.is_empty():
side = String(row.target_side)
for eff in _status_effects.query_active(side):
@@ -818,9 +816,9 @@ func _exit_distribution() -> void:
# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道
func can_refresh() -> bool:
return battle_result == "" \
and _refresh_used_this_round < int(db.action_economy.refresh_cap_per_round) \
and _refresh_used_this_round < int(db.run.action_economy.refresh_cap_per_round) \
and action_economy.phase == action_economy.PHASE_ACTIVE \
and action_economy.rap >= int(db.action_economy.rap_refresh_cost)
and action_economy.rap >= int(db.run.action_economy.rap_refresh_cost)
# 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。
@@ -870,14 +868,14 @@ func _battle_over(result: String) -> void:
# + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。
func _roll_gp_drop() -> int:
var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal"
var drops: Dictionary[Variant, Variant] = db.shop_gp_drop_row(tier)
var drops: Dictionary[Variant, Variant] = db.run.shop.gp_drop_row(tier)
var roll := 0
if drops.is_empty():
push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier)
else:
var stream: RandomNumberGenerator = rng.open_stream("gp_drop")
roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0)))
var drop := roll + db.shop_level_gp_bonus(int(encounter.get("level_index", 0)))
var drop := roll + db.run.shop.level_gp_bonus_at(int(encounter.get("level_index", 0)))
# 财富之道·聚(战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕,
# 店内 escrow 不乘〔范围=仅战斗掉落渠道〕;本函数返回值=结算框「GP 增量」行
# 显示源,落在入账前=显示即实得;乘数 provisional 150%)
@@ -899,14 +897,14 @@ func presentation_snapshot() -> Dictionary:
# 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行)
var enemy_effects: Array[Dictionary] = []
for eff in _status_effects.query_active("enemy"):
var erow: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if not erow.is_empty() and String(erow.unit) in ["percent", "ac"]:
enemy_effects.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)})
for i in all.size():
var u: Dictionary[Variant, Variant] = all[i]
var alive: bool = bool(u.get("alive", false))
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary[Variant, Variant] = db.skill_row(pending)
var pending_row: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(pending)
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
+128 -83
View File
@@ -22,12 +22,19 @@ static func target_mine_count(params: Dictionary) -> int:
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。
# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表
func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData: # type-hint: ignore(测量面 _BoardRowStub 冒烟夹具鸭子型注入——typed 参拒 stub)
var row: Dictionary[Variant, Variant] = db.resolve_board_gen(L, D)
var row: Dictionary[Variant, Variant] = db.combat.board_gen.resolve_row(L, D)
if row.is_empty():
_fatal("BoardGen 行缺失 (%d,%d)" % [L, D])
return null
# 生成期表值单次读取快照(防热重载撕裂 attempt 语义)
var params := {
var params := _board_params(row, L, D)
var outcome := _attempt_loop(params, rng_stream, db)
_finalize(outcome["board"], params, outcome["attempts"], outcome["mode"], outcome["r0_rej"])
return outcome["board"]
## 生成期表值单次读取快照(防热重载撕裂 attempt 语义;零 RNG)
func _board_params(row: Dictionary, L: int, D: int) -> Dictionary:
return {
"L": L, "D": D,
"w": int(row.board_width), "h": int(row.board_height),
"density": float(row.mine_density),
@@ -41,10 +48,11 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat
"shave_r": int(row.get("shave_ring_radius", 1)),
"r0_cap": int(row.get("r0_max_cap", 0)),
}
var best_legal: BoardData = null
var best_legal_score := -1.0
var best_bad: BoardData = null
var best_badness := [999999, 999999, 999999]
## 重试主循环:逐尝试评分择优,产出三分出口结果(board/mode/attempts/r0_rej)
func _attempt_loop(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Dictionary: # type-hint: ignore(同 generate——测量面 stub 注入)
var best := {"legal": null, "legal_score": -1.0, "bad": null, "badness": [999999, 999999, 999999]}
var attempts_used := 0
var accepted: BoardData = null
var accept_mode := ""
@@ -53,35 +61,37 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat
attempts_used = k
var b := _attempt(params, rng_stream, db)
var badness := _badness(b, params)
if best_bad == null or _lex_less(badness, best_badness):
best_bad = b
best_badness = badness
if best["bad"] == null or _lex_less(badness, best["badness"]):
best["bad"] = b
best["badness"] = badness
# R₀≤cap 重掷闸触发计数(迭代14① T3——尝试事实,与合法性判定独立记录;占比=T5 闸触发率读数)
if int(params.r0_cap) > 0 and b.compute_r0().size() > int(params.r0_cap):
r0_cap_rejected += 1
if legality(b, params):
var m: Dictionary[Variant, Variant] = measure_inferability(b) # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
var inf: float = m["inferability"]
if best_legal == null or inf > best_legal_score:
best_legal = b
best_legal_score = inf
var inf: float = measure_inferability(b)["inferability"] # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
if best["legal"] == null or inf > best["legal_score"]:
best["legal"] = b
best["legal_score"] = inf
if inf >= params.target: # 单一判据(棋盘生成:现行规则已回归"可推理率 ≥ 及格线")
accepted = b
accept_mode = "first_pass"
break
if accepted == null:
if best_legal != null:
accepted = best_legal
accept_mode = "accept_best"
accepted.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛)
push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
else:
accepted = best_bad
accept_mode = "accept_worst"
accepted.illegal_product = true
push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
_finalize(accepted, params, attempts_used, accept_mode, db, r0_cap_rejected)
return accepted
var fallback := _accept_fallback(params, best["legal"], best["legal_score"], best["bad"])
accepted = fallback[0]
accept_mode = fallback[1]
return {"board": accepted, "mode": accept_mode, "attempts": attempts_used, "r0_rej": r0_cap_rejected}
## 三分出口(重试耗尽):接受最优(降级标记)或接受最不坏(非法产物标记——玩家永不卡死)
func _accept_fallback(params: Dictionary, best_legal: BoardData, best_legal_score: float, best_bad: BoardData) -> Array:
if best_legal != null:
best_legal.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛)
push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
return [best_legal, "accept_best"]
best_bad.illegal_product = true
push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
return [best_bad, "accept_worst"]
# ==================== 五阶段:单次尝试 ====================
@@ -94,8 +104,20 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
b.board_type = str(params.board_type)
b.level_index = params.L
b.node_depth = params.D
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"
# (−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"(_populate_mines 承载——Fisher-Yates 取号段整体随迁)
var removed := _populate_mines(b, params, rng_stream)
# ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位),
# 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
}
return b
## 布雷与剃雷域:域 = 全格 − 出生 3×3;均匀布雷(部分 Fisher-Yates,确定性,消费 board_gen 流)
## 后剃出生环雷,返回 ring_removed(指标语义不变——现值变小=收窄批预期)
func _populate_mines(b: BoardData, params: Dictionary, rng_stream: RandomNumberGenerator) -> int:
var spawn := {}
for c in b.spawn_cells():
spawn[c] = true
@@ -104,7 +126,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
if not spawn.has(c):
domain.append(c)
var n_mines: int = target_mine_count(params)
# 部分 Fisher-Yates(确定性,消费 board_gen 流)
# 部分 Fisher-Yates(确定性,消费 board_gen 流)——取号序冻结(契约:同种子逐位一致)
for i in range(0, mini(n_mines, domain.size())):
var j := rng_stream.randi_range(i, domain.size() - 1)
var tmp = domain[i]
@@ -116,9 +138,14 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
# 先布后删:剃雷域 = 距 spawn_center 切比雪夫距离 ≤1+r 的格(迭代14①收窄批 T2——原「R₀ flood 域∪
# 外扩 1 环」全域剃雷=开局开阔地巨大根因〔10×10 板 R₀ 均值 68.6=板面 68.6%〕;收窄后开阔地=
# 出生邻域外的自然 flood。r 读表 shave_ring_radius(r=1 → 5×5=25 格;r=0 即只剃出生 3×3)。
# 只减不增、无迭代语义不变;ring_removed 指标语义不变(现值变小=预期);零 RNG 消费变化——
# 同种子布雷序列逐位一致,仅移除域收窄。
var shave_r: int = int(params.get("shave_r", 1))
# 只减不增、无迭代语义不变;零 RNG 消费变化。
var removed := _shave_ring(b, int(params.get("shave_r", 1)))
_compute_digits(b)
return removed
## 剃雷环域(零 RNG):距出生心切比雪夫 ≤1+r 的环内剃雷,返回剃除数
func _shave_ring(b: BoardData, shave_r: int) -> int:
var ring := {}
for c in b.all_cells():
if maxi(absi(c.x - b.spawn_center.x), absi(c.y - b.spawn_center.y)) <= 1 + shave_r:
@@ -128,14 +155,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
if b.mines.has(c):
b.mines.erase(c)
removed += 1
_compute_digits(b)
# ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位),
# 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
}
return b
return removed
func _compute_digits(b: BoardData) -> void:
@@ -164,49 +184,12 @@ func measure_inferability(b: BoardData) -> Dictionary:
while changed:
changed = false
# 算子① 连锁:0 格的 8 邻安全格全入 R
var frontier: Array[Variant] = r.keys()
for x in frontier:
if safe_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
changed = _apply_chain_rule(b, r, safe_set) or changed
# 信息源:数字约束 single + subset(solver_rules=single_subset 冻结域)
var constraints := _build_constraints(b, r, known_mines, safe_set)
# single 规则
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
# subset 规则:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary[Variant, Variant] = constraints[i]
var bb: Dictionary[Variant, Variant] = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
for con in derived:
if con.mines == 0:
for c in con.unknown:
if not r.has(c): # 同 no-peek 口径:推导结论不再用 safe_set 复核(约束本身可靠)
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
changed = _apply_single_rules(constraints, r, known_mines) or changed
var derived := _derived_constraints(constraints)
changed = _apply_derived_rules(derived, r, known_mines) or changed
var r_inf := r.keys()
var s_count := safe_set.size()
var inferability: float = float(r_inf.size()) / float(maxi(1, s_count))
@@ -217,6 +200,68 @@ func measure_inferability(b: BoardData) -> Dictionary:
}
## 算子① 连锁域:0 格的 8 邻安全格全入 R(返回本轮是否有新增)
func _apply_chain_rule(b: BoardData, r: Dictionary, safe_set: Dictionary) -> bool:
var changed := false
var frontier: Array[Variant] = r.keys()
for x in frontier:
if safe_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
return changed
## single 规则域:零雷约束开格/满雷约束标雷(返回本轮是否有新增)
func _apply_single_rules(constraints: Array, r: Dictionary, known_mines: Dictionary) -> bool:
var changed := false
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
return changed
## subset 派生域:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)(对约束对做包含判定,产出派生约束集)
func _derived_constraints(constraints: Array) -> Array:
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary[Variant, Variant] = constraints[i]
var bb: Dictionary[Variant, Variant] = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
return derived
## 派生规则域:派生约束的零雷开格/满雷标雷(同 no-peek 口径:推导结论不再用 safe_set 复核——约束本身可靠)
func _apply_derived_rules(derived: Array, r: Dictionary, known_mines: Dictionary) -> bool:
var changed := false
for con in derived:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
return changed
func _build_constraints(b: BoardData, r: Dictionary, known_mines: Dictionary, safe_set: Dictionary) -> Array:
var out: Array[Dictionary] = []
for c in r.keys():
@@ -337,7 +382,7 @@ func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int:
# ==================== 产出上报(生成期指标集) ====================
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, _db, r0_cap_rejected: int = 0) -> void: # type-hint: ignore(同 generate——测量面 stub 注入)
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, r0_cap_rejected: int = 0) -> void:
var m := measure_inferability(b)
# 产物断言(棋盘生成 出生区安全与反退化布雷):实际雷数 ≤ 目标雷数 N——先布后删只减不增,失败即致命
var n_target: int = target_mine_count(params)
+39 -319
View File
@@ -1,6 +1,8 @@
## VERTICAL SLICE - NOT FOR PRODUCTION
## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与类型化访问器(垂直切片验证:改表即改玩法,数值零硬编码)。
## 玩法代码禁止绕过本单点自建 load();消费面一律走访问器,禁直读行集。
## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与门面(垂直切片验证:改表即改玩法,数值零硬编码)。
## 玩法代码禁止绕过本单点自建 load();行查询归各表类(表管自己的行——2026-10-27 全清设计定稿),
## 表持有按域收进三容器(combat 战斗编排域/run 冒险 runner 域/crossrun 跨局元进度域),
## 消费面经 db.<容器>.<表> 直连。「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88)。
## headless --script 模式无 autoload——冒烟脚本显式实例化本类;加载失败 is_ready=false 加 fatal 封印,调用方须查。
class_name ConfigDb
extends Node
@@ -29,39 +31,23 @@ const TABLE_MANIFEST := {
"forge": "forge.tres", # ForgeTable——两根类型轴默认值+六路线行族;攻防所有权切分唯一数据面
}
var action_economy: ActionEconomyTable
var variation: VariationTable
var status_effects: StatusEffectTable
var board_gen: BoardGenTable
var enemy: EnemyTable
var skill: SkillTable
var hero: HeroTable
var reward: RewardTable
var settlement: SettlementTable
var level: LevelTable
var difficulty: DifficultyTable
var level_gen_params: LevelGenParams # 参数档类型化持用(生成器只吃参数档、不认关卡号——卡① 契约 6)
# ShopTable 类型化持用(schema class_name 已已入库;
# 消费面一律走下方 shop_* 访问器,禁直读行集)
var shop: ShopTable = null
# MetaTable 类型化持用(schema 与表同源已入库)
var meta: MetaTable
var meta_tree: MetaTreeTable # 类型化持用(消费面走 tree_node 访问器)
var treasure_items: TreasureItemTable # 类型化持用(消费面走 treasure_* 访问器)
var adventure: AdventureTable
var feats: FeatTable # 类型化持用(消费面走 feat_* 访问器)
var forge: ForgeTable # 锻造工坊配置(默认两轴+六路线行族——消费面走 forge_* 访问器)
var combat: ConfigDomainCombat # 战斗编排域七表(board_gen/variation/status_effects/enemy/skill/hero/settlement)
var run: ConfigDomainRun # 冒险 runner 域七表(level/difficulty/level_gen_params/reward/adventure/action_economy/shop)
var crossrun: ConfigDomainCrossrun # 跨局元进度域五表(meta/meta_tree/treasure_items/feats/forge)
var is_ready := false
var _diagnostics: Array[Dictionary] = [] # {severity, category, message}
var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变)
var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变;load_tables 装配段注入三表)
var _validator := ConfigDbCrossValidator.new() # 跨表校验协作类(无状态——声明期即建)
var _reload_gate := false # 数据配置 EC8——战斗结算 唯一调用方
func _init() -> void:
combat = ConfigDomainCombat.new()
run = ConfigDomainRun.new()
crossrun = ConfigDomainCrossrun.new()
_resolver = ConfigDbResolver.new(push_diag,
func() -> Variant: return board_gen.rows, func() -> Variant: return enemy.rows, func() -> Variant: return difficulty) # 协作类持门面引用(诊断/致命通道回呼)
func() -> Variant: return combat.board_gen.rows, func() -> Variant: return combat.enemy.rows, func() -> Variant: return run.difficulty) # 协作类持门面引用(诊断/致命通道回呼)
func push_diag(severity: String, category: String, message: String) -> void:
@@ -132,312 +118,46 @@ func load_tables(root: String, collect_only: bool) -> bool:
if has_errors and not collect_only:
# 游戏通道:致命(Rule 6)——is_ready 恒 false,调用方(主场景)中止构建
return false
action_economy = loaded.get("action_economy")
variation = loaded.get("variation")
status_effects = loaded.get("status_effects")
board_gen = loaded.get("board_gen")
enemy = loaded.get("enemy")
skill = loaded.get("skill")
hero = loaded.get("hero")
reward = loaded.get("reward")
settlement = loaded.get("settlement")
level = loaded.get("level")
difficulty = loaded.get("difficulty")
level_gen_params = loaded.get("level_gen_params")
shop = loaded.get("shop")
meta = loaded.get("meta")
treasure_items = loaded.get("treasure_items")
adventure = loaded.get("adventure")
feats = loaded.get("feats")
forge = loaded.get("forge")
meta_tree = loaded.get("meta_tree")
# 容器装配(19 表成员按域收进三容器——2026-10-27 全清设计定稿)
combat.board_gen = loaded.get("board_gen")
combat.variation = loaded.get("variation")
combat.status_effects = loaded.get("status_effects")
combat.enemy = loaded.get("enemy")
combat.skill = loaded.get("skill")
combat.hero = loaded.get("hero")
combat.settlement = loaded.get("settlement")
run.level = loaded.get("level")
run.difficulty = loaded.get("difficulty")
run.level_gen_params = loaded.get("level_gen_params")
run.reward = loaded.get("reward")
run.adventure = loaded.get("adventure")
run.action_economy = loaded.get("action_economy")
run.shop = loaded.get("shop")
crossrun.meta = loaded.get("meta")
crossrun.meta_tree = loaded.get("meta_tree")
crossrun.treasure_items = loaded.get("treasure_items")
crossrun.feats = loaded.get("feats")
crossrun.forge = loaded.get("forge")
# resolver 协作件注入三表(resolve 委托承载——resolver 文件与键零搬移)
combat.board_gen._resolver = _resolver
combat.enemy._resolver = _resolver
run.difficulty._resolver = _resolver
is_ready = not has_errors
return not has_errors
# ---- relic 访问器(消费面一律走本组,禁直读行集)----
func feat_row(feat_id: String) -> Dictionary:
return feats.row_by_id(feat_id) if feats != null else {}
# 规则改写型特性带令牌构建源(池接线——CurioDeck.build 消费;行 rarity→投放档映射)
func feat_rows() -> Array:
return feats.rows if feats != null else []
# 天赋树节点行(MetaProgression 树段/营地树 UI 取数口)
func tree_node(node_id: String) -> Dictionary:
return meta_tree.row_by_id(node_id) if meta_tree != null else {}
func tree_rows() -> Array:
return meta_tree.rows if meta_tree != null else []
# 跨表校验(悬空引用 / 池护栏——全部加载期致命)本体:
# ConfigDbCrossValidator(core/config_db_cross_validator.gd)承载,门面公有签名不变。
func _cross_validate(loaded: Dictionary) -> void:
_validator.cross_validate(loaded, push_diag)
# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类)
func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类;门面私有——
# 消费面经门面者改 _ 前缀直调,cross_validator 直验入口公开面不变)
func _validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
return _validator.validate_skill_pool_links(skill_table, enemy_table, status_table)
# ---- R1 难度行解析(ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)----
# 解析本体:ConfigDbResolver(core/config_db_resolver.gd)承载——
# 含 _resolve_progress/_ceil_key 与 R1 告警去重注册表;门面同名方法委托,越域 Fatal 语义一致。
func resolve_board_gen(L: int, D: int) -> Dictionary:
return _resolver.resolve_board_gen(L, D)
func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]:
return _resolver.resolve_enemy_rowset(L, D, row_kind) # 行集数组形态(非单行 Dictionary;D 维带寻址)
# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
return _resolver.resolve_difficulty_modifiers(L, D)
# ---- 地形变异 variant_pool_gate:候选池门控(∅ board_types = 通配;字段名对齐
# board_type 词表,无 biome_tags)----
# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池,
# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。
# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。
func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array:
var pool: Array[Dictionary] = []
for row in variation.rows:
if String(row.get("scope", "region")) == "global" \
and node_type != "boss" and node_type != "elite":
continue
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array[Variant] = row.get("board_types", [])
if tags.is_empty():
pool.append(row)
continue
if tags.has(board_type):
pool.append(row)
return pool
# 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表):
# boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态,
# 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态)
func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary:
if node_type != "boss" and node_type != "elite":
return {}
for row in variant_pool(level_index, board_type, node_type):
if String(row.get("scope", "region")) == "global":
return row
return {}
func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# ---- 关卡行集访问器(表访问收口——level_runner/meta_progression/adventure_runner
# 不直扫 db.level.rows;消费面一律走本访问器,行集解析单点归 LevelTable)----
# 关卡行单点读取(键 = level_index,缺行返回空字典——消费方按既有空行防御路径处理)
func level_row(level_index: int) -> Dictionary:
return level.row_by_level_index(level_index) if level != null else {}
# 参数档单点读取(节点图生成器核心批 卡①——键 = level_index,缺行返回空字典;调用方据此产图,生成器本体不读关卡号)
func level_gen_params_row(level_index: int) -> Dictionary:
return level_gen_params.row_by_level_index(level_index) if level_gen_params != null else {}
# 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径)
func level_count() -> int:
return level.rows.size() if level != null else 0
# ---- ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)----
# ── 秘宝访问器(消费面一律走访问器,禁直读行集)──
# 活行集(非 retired 非 deferred)——投放校验/奖励节点池/呈现可用性的唯一取数口径
func treasure_item_row(item_id: String) -> Dictionary:
return treasure_items.row_by_id(item_id) if treasure_items != null else {}
# 过渡渠道活行(form 空 = 切片期过渡形态)——掉落投放/停点池/工坊解锁取数口径;同调行(form 非空)
# 永不入过渡渠道
func treasure_transitional_rows() -> Array:
return treasure_items.transitional_rows() if treasure_items != null else []
# 同调行集(form 非空——装配覆盖层/效果接线的取数口径)
func treasure_attunement_rows() -> Array:
return treasure_items.attunement_rows() if treasure_items != null else []
# 活行词表(掉落投放 effect_id 白名单——过渡渠道口径)
# ── 同调槽位数=树同调位节点点亮数,判定源=Attunement.slots(无 attunement_growth 谓词表访问器);
# boss_kill_count/clear_count 计数域保留——非槽位硬消费在册 ──
# 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} ·
# level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value,
# base_price, price_increment} · price_level_bonus {level_index, bonus}。
# 行族经 ShopTable 同名数组属性读取(.get 容缺——表已入库前查询恒空,掉落按 0 兑付的兜底在调用方)。
func shop_gp_drop_row(tier: String) -> Dictionary:
for row in _shop_rows("gp_drops"):
if String(row.get("tier", "")) == tier:
return row
return {}
func shop_level_gp_bonus(level_index: int) -> int:
for row in _shop_rows("level_gp_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_price_level_bonus(level_index: int) -> int:
for row in _shop_rows("price_level_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_products() -> Array:
return _shop_rows("products")
func shop_product_row(product_id: String) -> Dictionary:
for row in _shop_rows("products"):
if String(row.get("product_id", "")) == product_id:
return row
return {}
# ── 共享池货架访问器(M-4——消费面一律走访问器,禁直读行集)──
# 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序)
func shop_pool_rows() -> Array:
return _shop_rows("pool_shelf")
func shop_pool_row(kind_id: String) -> Dictionary:
for row in _shop_rows("pool_shelf"):
if String(row.get("kind_id", "")) == kind_id:
return row
return {}
# 乘法价格因子(provisional 投值——0 = 表未投值,消费方按 fail-fast 域校验拦截)
func shop_price_a() -> float:
return float(shop.price_a) if shop != null else 0.0
func shop_price_b() -> float:
return float(shop.price_b) if shop != null else 0.0
# 行族数组读取(ShopTable 属性名 = 行族名;表未已入库/属性缺失 = 空集非错误态)
func _shop_rows(family: String) -> Array:
if shop == null:
return []
var arr = shop.get(family)
return arr if arr is Array else []
# ---- MetaTable 消费面(行集解析单点——MetaProgression/呈现层禁自扫表)----
# 行族四轴(数据契约,行集归 meta.tres,数值全 provisional):
# forge_products {attr, display_name, flat_value, base_price, price_ratio_pct} ·
# boss_keystone {level_index, first_kill, repeat_kill} ·
# workshop_items {item_id, display_name, clear_threshold} ·
# conversion {attr, per_level_shift_pct}。
func meta_boss_keystone_row(level_index: int) -> Dictionary:
for row in _meta_rows("boss_keystone"):
if int(row.get("level_index", -1)) == level_index:
return row
return {}
# 秘宝解锁混合制挂 treasure_items 行内(无 meta_workshop_items 访问器);
# 秘宝解锁投影面=MetaProgression.treasure_shop_status
func meta_conversion_row(attr: String) -> Dictionary:
for row in _meta_rows("conversion"):
if String(row.get("attr", "")) == attr:
return row
return {}
# Y 刷新升级行单点读取(键 = 等级,缺行返回空字典=未购/已满级合法态)
func meta_attune_refresh_row(level: int) -> Dictionary:
for row in _meta_rows("attune_refresh_upgrade"):
if int(row.get("level", -1)) == level:
return row
return {}
# 行族数组读取(MetaTable 属性名 = 行族名;表缺失/属性缺失 = 空集非错误态)
func _meta_rows(family: String) -> Array:
if meta == null:
return []
var arr = meta.get(family)
return arr if arr is Array else []
# ---- 锻造工坊(ForgeTable 消费面:两根类型轴默认值+六路线行族;禁自扫行集)----
# 设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6:默认值归本表常驻持有(hero.tres 两轴已删——
# 契约 2);路线行族=攻防数值成长唯一数据面(契约 1)。数值全 provisional 归 M4。
# 两根类型轴默认值(表未已入库=裸体态兜底 normal/light——与 ForgeTable 缺省值同口径)
func forge_default_attack_type() -> String:
return String(forge.default_attack_type) if forge != null else "normal"
func forge_default_armor_type() -> String:
return String(forge.default_armor_type) if forge != null else "light"
# 六路线行族(副本——调用方禁改)
func forge_routes() -> Array:
return forge.routes.duplicate() if forge != null else []
# 路线行单点(键 route_id;缺行=空字典)
func forge_route(route_id: String) -> Dictionary:
for row in forge_routes():
if String(row.get("route_id", "")) == route_id:
return row
return {}
# 按类型轴反查武器路线(attack_type → 路线行;甲路线 id=甲型同值的显式化入口,避免消费侧依赖该巧合)
func forge_weapon_route_by_type(attack_type: String) -> Dictionary:
for row in forge_routes():
if String(row.get("kind", "")) == "weapon" and String(row.get("type_axis", "")) == attack_type:
return row
return {}
# 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口)
func forge_armor_route_by_type(armor_type: String) -> Dictionary:
for row in forge_routes():
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type:
return row
return {}
# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面)
func skill_row(skill_id: String) -> Dictionary:
return skill.row_by_id(skill_id)
# ---- 数据配置 EC8:debug 热重载门(战斗结算 唯一调用方;切片实现 = 门标志 + 拒绝语义)----
func set_reload_gate(gated: bool) -> void:
+88 -47
View File
@@ -11,21 +11,43 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or st_tab == null:
return # 单表校验已报错,不连锁
# ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝
# 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。
var effect_ids := _effect_ids_of(st)
_cv_reward_dangling(rt, effect_ids, push_diag) # ① 增益词表悬空
_cv_reward_treasure(loaded, rt, push_diag) # ①b aux 投放行双表
_cv_poolmap(st_tab, effect_ids, push_diag) # ② 池映射悬空
_cv_source_labels(st_tab, push_diag) # ③ 来源标签键集
_cv_skill_pool(loaded, push_diag) # ⑤ 技能池引用完整性
_cv_shelf_pool(loaded, push_diag) # ⑥ 商店货架 × 冒险共享池
_cv_counter_closure(loaded, push_diag) # ⑦ 计数器引用闭合
## 增益状态词表全集(effect_id → true)
func _effect_ids_of(st: StatusEffectTable) -> Dictionary:
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
# 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
return effect_ids
## ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
## 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
## (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
func _cv_reward_dangling(rt: RewardTable, effect_ids: Dictionary, push_diag: Callable) -> void:
for row in rt.rows:
if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE:
continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_treasure),不入增益词表面
var eff: String = row.get("effect_id", "")
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
push_diag.call("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id,
# 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
# 后仍投放(连带错误),同拦。
## ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id,
## 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
## 后仍投放(连带错误),同拦。
func _cv_reward_treasure(loaded: Dictionary, rt: RewardTable, push_diag: Callable) -> void:
var treasure_live := {}
var treasure_tab = loaded.get("treasure_items")
if treasure_tab != null:
@@ -39,27 +61,36 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
continue
if not treasure_live.has(treasure_eff):
push_diag.call("error", "reward_dangling_treasure", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), treasure_eff, "双表校验"])
# ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝
# 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。
# ② SettlementTable.effect_pool_map 悬空
## ② SettlementTable.effect_pool_map 悬空
func _cv_poolmap(st_tab: SettlementTable, effect_ids: Dictionary, push_diag: Callable) -> void:
for eff in st_tab.core.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag.call("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss})
## ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss})
func _cv_source_labels(st_tab: SettlementTable, push_diag: Callable) -> void:
var required_sources := ["safe_cell", "seal_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "seal_miss"]
for s in required_sources:
if not st_tab.core.source_label_map.has(s):
push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
## ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
func _cv_skill_pool(loaded: Dictionary, push_diag: Callable) -> void:
var en: EnemyTable = loaded.get("enemy")
var skt = loaded.get("skill")
if skt != null and en != null:
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
push_diag.call("error", e.category, e.message)
# ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域):
# pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种
# 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦)
## ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域):
## pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种
## 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦)
func _cv_shelf_pool(loaded: Dictionary, push_diag: Callable) -> void:
var adv = loaded.get("adventure")
var shp = loaded.get("shop")
if adv != null and shp != null:
@@ -78,34 +109,42 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
if quota < 1:
push_diag.call("error", "shelf_zero_quota", "ShopTable.pool_shelf kind %s 令牌配额为 0(牌库空种入货架 = 库存无锚)" % kind)
# ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命):
# 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id;
# 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。
var feats_tab = loaded.get("feats")
## ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命):
## 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id;
## 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。
func _cv_counter_closure(loaded: Dictionary, push_diag: Callable) -> void:
var trig_rows: Array[Variant] = []
var feats_tab = loaded.get("feats")
if feats_tab != null:
for r in feats_tab.rows:
trig_rows.append(r)
var treasure_tab = loaded.get("treasure_items")
if treasure_tab != null:
for r in treasure_tab.rows:
trig_rows.append(r)
var counter_decls := _counter_decls_of(trig_rows)
_cv_counter_reads(trig_rows, counter_decls, push_diag)
## 计数器声明集收集:任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行)
func _counter_decls_of(trig_rows: Array) -> Dictionary:
var counter_decls := {}
for row in trig_rows:
for tr_v in (row.get("triggers", []) as Array):
var dd: Dictionary[Variant, Variant] = (tr_v as Dictionary).get("do", {}) as Dictionary[Variant, Variant]
# 声明集=任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行)
if dd.has("counter_id"):
counter_decls[String(dd.counter_id)] = true
return counter_decls
## 计数器读点校验:when counter id/do.value_counter/counter_spend 的 counter_id 必命中声明集
func _cv_counter_reads(trig_rows: Array, counter_decls: Dictionary, push_diag: Callable) -> void:
for row in trig_rows:
var rid := String(row.get("feat_id", row.get("item_id", "")))
for tr_v in (row.get("triggers", []) as Array):
var tr: Dictionary[Variant, Variant] = tr_v as Dictionary[Variant, Variant]
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant]
if String(c.get("fact", "")) == "counter" and c.has("id"):
var rd := String(c.id)
if not counter_decls.has(rd):
push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd])
_check_counter_when(rid, tr, counter_decls, push_diag)
var do2: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary[Variant, Variant]
if do2.has("value_counter") and not counter_decls.has(String(do2.value_counter)):
push_diag.call("error", "counter_dangling_ref", "%s value_counter 读点无同名写入行: %s" % [rid, String(do2.value_counter)])
@@ -113,6 +152,16 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
push_diag.call("error", "counter_dangling_ref", "%s counter_spend 无同名写入行: %s" % [rid, String(do2.counter_id)])
## when counter 读点校验(fact=counter 条件的 id 必命中声明集)
func _check_counter_when(rid: String, tr: Dictionary, counter_decls: Dictionary, push_diag: Callable) -> void:
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant]
if String(c.get("fact", "")) == "counter" and c.has("id"):
var rd := String(c.id)
if not counter_decls.has(rd):
push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd])
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 +
# proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。
@@ -141,33 +190,25 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable,
errs.append_array(_pool_cast_side_errs(skill_table, status_table)) # 施放类 side 守卫(buff→敌方侧/debuff→主角侧,防反向挂接——全表恰一次)
for row in enemy_table.rows:
var eid: String = String(row.get("enemy_id", ""))
var basic_n := 0
var buff_n := 0
var debuff_n := 0
var uncond_special_n := 0
var pool_n := {"basic": 0, "buff": 0, "debuff": 0, "uncond_special": 0}
for entry in row.get("pool", []):
var sid: String = String(entry.get("skill_id", ""))
if not skill_types.has(sid):
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
continue
match skill_types[sid]:
"basic":
basic_n += 1
"buff":
buff_n += 1
"debuff":
debuff_n += 1
if pool_n.has(skill_types[sid]):
pool_n[skill_types[sid]] += 1
# 无条件特殊技计数(普攻不计;trigger 缺省行计数——触发条件批 2026-10-26 池构成纪律)
if skill_types[sid] != "basic" and not (skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}).is_empty():
if skill_types[sid] != "basic":
var srow_c: Dictionary[Variant, Variant] = skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}
if not srow_c.has("trigger"):
uncond_special_n += 1
if basic_n != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
if buff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
if debuff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]})
if uncond_special_n < 1:
if not srow_c.is_empty() and not srow_c.has("trigger"):
pool_n["uncond_special"] += 1
if pool_n["basic"] != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, pool_n["basic"]]})
if pool_n["buff"] > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, pool_n["buff"]]})
if pool_n["debuff"] > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, pool_n["debuff"]]})
if pool_n["uncond_special"] < 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池无条件特殊技行数 = 0——每池至少一行无条件特殊技纪律(触发条件批 2026-10-26)" % [eid]})
return errs
+73 -41
View File
@@ -43,11 +43,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
return []
var rows: Array[Dictionary] = []
rows.assign(_enemy_rows_get.call())
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
var level_keys: Array[int] = _level_keys_of(rows)
if level_keys.is_empty():
_diag.call("fatal", "structural", "enemy 行集为空——锚行校验应已拦截")
return []
@@ -58,12 +54,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
_warn_resolve("enemy", "resolve_level_clamp", "enemy 请求 (%d,%d) clamp 解析(超出最大关卡取最大值)→ 关卡 %d" % [L, D, l_star], l_star, D)
elif l_star != L:
_warn_resolve("enemy", "resolve_level_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ 关卡 %d" % [L, D, l_star], l_star, D)
var band_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind:
var bv := int(row.get("depth_band", -1))
if not band_keys.has(bv):
band_keys.append(bv)
var band_keys: Array[int] = _band_keys_of(rows, l_star, row_kind)
if band_keys.is_empty():
return []
band_keys.sort()
@@ -73,6 +64,32 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
_warn_resolve("enemy", "resolve_depth_clamp", "enemy 请求 (%d,%d) clamp 解析(超出顶带取顶带)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star)
elif d_star != D:
_warn_resolve("enemy", "resolve_depth_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star)
return _band_rows(rows, l_star, row_kind, d_star)
## 行集关卡键收集(纯行扫描——告警发射序不变)
func _level_keys_of(rows: Array) -> Array[int]:
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
return level_keys
## 带键收集(纯行扫描——l_star × row_kind 的 depth_band 去重集)
func _band_keys_of(rows: Array, l_star: int, row_kind: String) -> Array[int]:
var band_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind:
var bv := int(row.get("depth_band", -1))
if not band_keys.has(bv):
band_keys.append(bv)
return band_keys
## 带行收集(纯行扫描——命中带全部行、按表文件行序)
func _band_rows(rows: Array, l_star: int, row_kind: String, d_star: int) -> Array[Dictionary]:
var all_out: Array[Dictionary] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind \
@@ -108,11 +125,7 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant
if L < 1 or D < 0:
_diag.call("fatal", "request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D])
return {}
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
var level_keys: Array[int] = _level_keys_of(rows)
if level_keys.is_empty():
_diag.call("fatal", "structural", "%s 行集为空——锚行校验应已拦截" % table_id)
return {}
@@ -121,40 +134,59 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant
var l_clamped := l_star < 0
if l_clamped:
l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(可取范围内向上取整,超出用最大值)
var depth_keys: Array[int] = _depth_keys_of(rows, l_star)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := _missing_label(l_clamped, d_clamped, l_star != L, d_star != D)
if missing != "":
_emit_missing_warning(table_id, missing, [l_star, d_star], [L, D])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
## 层键收集(纯行扫描——l_star 行的 node_depth 去重集)
func _depth_keys_of(rows: Array, l_star: int) -> Array[int]:
var depth_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star:
var dv := int(row.get("node_depth", 0))
if not depth_keys.has(dv):
depth_keys.append(dv)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := ""
return depth_keys
## 解析偏移形态标签(clamp/ceil × level/depth/both 六形态——空串 = 精确命中;
## l_ceil/d_ceil = 解析值 ≠ 请求值)
func _missing_label(l_clamped: bool, d_clamped: bool, l_ceil: bool, d_ceil: bool) -> String:
var label := ""
if l_clamped and d_clamped:
missing = "both_clamp"
label = "both_clamp"
elif l_clamped:
missing = "level_clamp"
label = "level_clamp"
elif d_clamped:
missing = "depth_clamp"
elif l_star != L and d_star != D:
missing = "both_ceil"
elif l_star != L:
missing = "level_ceil"
elif d_star != D:
missing = "depth_ceil"
if missing != "":
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
_diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
label = "depth_clamp"
elif l_ceil and d_ceil:
label = "both_ceil"
elif l_ceil:
label = "level_ceil"
elif d_ceil:
label = "depth_ceil"
return label
## 偏移告警发射(四元组去重注册表语义逐字——R1 既有去重;
## req/resolved = [L, D]/[l_star, d_star] 进度坐标对)
func _emit_missing_warning(table_id: String, missing: String, resolved: Array, req: Array) -> void:
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, resolved[0], resolved[1]]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
_diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, req[0], req[1], verb, resolved[0], resolved[1], missing])
# 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal
+12
View File
@@ -0,0 +1,12 @@
## 战斗域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):战斗编排域七表类型化持有。
## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainCombat
extends Resource
var board_gen: BoardGenTable
var variation: VariationTable
var status_effects: StatusEffectTable
var enemy: EnemyTable
var skill: SkillTable
var hero: HeroTable
var settlement: SettlementTable
+11
View File
@@ -0,0 +1,11 @@
## 跨局域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):跨局元进度域五表类型化持有
## (跨局循环=单局之外的长线进度域)。零方法零行为——纯持有结构;
## 「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainCrossrun
extends Resource
var meta: MetaTable
var meta_tree: MetaTreeTable
var treasure_items: TreasureItemTable
var feats: FeatTable
var forge: ForgeTable
+12
View File
@@ -0,0 +1,12 @@
## 冒险域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):冒险 runner 域七表类型化持有。
## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainRun
extends Resource
var level: LevelTable
var difficulty: DifficultyTable
var level_gen_params: LevelGenParams
var reward: RewardTable
var adventure: AdventureTable
var action_economy: ActionEconomyTable
var shop: ShopTable
+17 -17
View File
@@ -29,13 +29,13 @@ func reset() -> void:
# relic 令牌档 = 行 rarity 直映射,词表外 transitional 值回退映射 rarest→rare/其余→excellent)
func build() -> Array:
var out: Array[Variant] = []
for kind in db.adventure.CURIO_KINDS:
for tier in db.adventure.TIERS:
for i in int(db.adventure.curio_quota(String(kind), String(tier))):
for kind in db.run.adventure.CURIO_KINDS:
for tier in db.run.adventure.TIERS:
for i in int(db.run.adventure.curio_quota(String(kind), String(tier))):
out.append({"kind": String(kind), "tier": String(tier)})
for row in db.feat_rows():
for row in db.crossrun.feats.rows:
var rraw := String(row.get("rarity", ""))
var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
var rtier := rraw if db.run.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
out.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))})
return out
@@ -73,7 +73,7 @@ func pool_stock(kind: String) -> int:
# 读数面 = 商店买走 vs 奖励领走 vs 枯竭)
func stock_snapshot() -> Dictionary:
var out := {}
for row in db.shop_pool_rows():
for row in db.run.shop.pool_rows():
var kind := String(row.get("kind_id", ""))
out[kind] = pool_stock(kind)
return out
@@ -97,23 +97,23 @@ func consume_deck_token(kind: String, tier: String, feat_id: String = "") -> boo
func cap_ok(kind: String, feat_id: String = "") -> bool:
var cap := 99
if kind == "relic":
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id)
cap = int(row.get("cap", 1)) if not row.is_empty() else 1
return taken.get("relic:" + feat_id, 0) < cap
if not db.adventure.CURIO_KINDS.has(kind):
if not db.run.adventure.CURIO_KINDS.has(kind):
return true
cap = int(db.adventure.curio_cap(kind))
cap = int(db.run.adventure.curio_cap(kind))
return int(taken.get(kind, 0)) < cap
# 专长凭证展示判定(四口径:一局限购一次/稀有只在概率不在价格/计步保底/触发清零。
# 调用即一次展示判定与记账——展示次数计 pity、配额与限购双闸)
func feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
if ticket_sold >= int(db.adventure.feat_ticket_limit):
if ticket_sold >= int(db.run.adventure.feat_ticket_limit):
return false # 限购满——不再触发(保底冻结)
if ticket_appeared >= int(db.adventure.feat_ticket_quota):
if ticket_appeared >= int(db.run.adventure.feat_ticket_quota):
return false # 配额满——本局不再出现
var offer: bool = ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance)
var offer: bool = ticket_pity >= int(db.run.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.run.adventure.feat_ticket_chance)
if offer:
ticket_pity = 0
ticket_appeared += 1
@@ -160,7 +160,7 @@ func _candidate_pool(channel: String) -> Array[Variant]:
func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
var out: Array[Variant] = []
var seen: Array[Variant] = []
var want := int(db.adventure.curio_candidate_count)
var want := int(db.run.adventure.curio_candidate_count)
while out.size() < want and not pool.is_empty():
var total := _pool_weight(pool)
if total <= 0:
@@ -180,13 +180,13 @@ func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
func _pool_weight(pool: Array[Variant]) -> int:
var total := 0
for tok in pool:
total += db.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", "")))
total += db.run.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", "")))
return total
func _weighted_index(pool: Array[Variant], roll: int) -> int:
for i in pool.size():
roll -= db.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", "")))
roll -= db.run.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", "")))
if roll <= 0:
return i
return 0
@@ -198,12 +198,12 @@ func _draw_exposure(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
_shuffle(pool, rg)
var sub: Array[Variant] = []
for tok in pool:
if db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT):
if db.run.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.run.adventure.tier_rank(db.run.adventure.TIER_EXCELLENT):
sub.append(tok)
_shuffle(sub, rg)
var out: Array[Variant] = []
var seen: Array[Variant] = []
var want := int(db.adventure.curio_candidate_count)
var want := int(db.run.adventure.curio_candidate_count)
var n_full := maxi(1, want - 1) if want >= 2 else want
while out.size() < want:
var src: Array[Variant] = sub if out.size() >= n_full and not sub.is_empty() else pool
+9 -9
View File
@@ -56,7 +56,7 @@ func rebind_level_runner(runner: LevelRunner, level_index: int) -> void:
func open_level_clear(payload_base: Dictionary) -> void:
var hp_before := _stats.hero_hp
var max_eff := _stats.effective_max_hp() # 回血封顶口径含调试浮窗 pct(同战斗内上限)
var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before)
var heal := mini(int(floor(float(max_eff) * float(int(db.run.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before)
_stats.hero_hp = hp_before + heal
_bank.reset_x() # 碰三选一重置 X 余量(不结转)
_bank.reset_paid() # 付费刷计数随停点清零(对齐商店进店清零)
@@ -109,7 +109,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
return false
if kind == "feat_ticket":
return _redeem_feat_ticket() # T5 拿到即开——停点候选替换为专长三选一(零摘牌/不进持有集)
var value: int = int(db.adventure.curio_value(kind, tier))
var value: int = int(db.run.adventure.curio_value(kind, tier))
var hp_before := _stats.hero_hp
if not _apply_curio_pick(kind, tier, value, feat_id):
push_error("[停点件] pick_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id])
@@ -122,7 +122,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
func _feat_payload_keys(feat_id: String) -> Dictionary:
if feat_id == "":
return {}
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id)
return {"feat_id": feat_id, "feat_name": String(row.get("display_name", "")), "feat_archetype": String(row.get("archetype", ""))}
@@ -133,7 +133,7 @@ func _feat_payload_keys(feat_id: String) -> Dictionary:
# 按挂点消费,档位质变由 FeatsEngine 求值)。兑付即从牌库摘除该令牌(选中踢池;
# 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。
func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String = "") -> bool:
if kind == "relic" and db.feat_row(feat_id).is_empty():
if kind == "relic" and db.crossrun.feats.row_by_id(feat_id).is_empty():
push_error("[停点件] relic 兑付失败——表无此件: %s" % feat_id)
return false # 先校验后摘牌(令牌零丢失)
if not _deck.consume_deck_token(kind, tier, feat_id):
@@ -172,7 +172,7 @@ func _apply_resource_pick(kind: String, value: int, feat_id: String) -> bool:
# 路由加算段(match 收敛进效果类型:属性值提升四类走 adventure_table.CURIO_APPLY_ROUTES 声明
# 路由(bonus 落点键),加新加算类令牌零代码)
func _apply_route_pick(kind: String, value: int) -> bool:
var sink := db.adventure.curio_apply_route(kind)
var sink := db.run.adventure.curio_apply_route(kind)
if sink == "":
return false
_journal.gain_bonus(sink, value, "curio_pick", kind) # 加算区单写原语(获得日记明细+汇总同拍)
@@ -229,7 +229,7 @@ func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") ->
if _deck.find_token(candidates, kind, tier, feat_id).is_empty():
push_error("[停点件] apply_node_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id])
return false
var value: int = int(db.adventure.curio_value(kind, tier))
var value: int = int(db.run.adventure.curio_value(kind, tier))
if not _apply_curio_pick(kind, tier, value, feat_id):
push_error("[停点件] apply_node_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id])
return false
@@ -348,10 +348,10 @@ func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id:
func consume_pool_purchase(kind: String, tier: String) -> bool:
if kind == "feat_ticket":
return _redeem_feat_ticket()
if not db.adventure.CURIO_KINDS.has(kind):
if not db.run.adventure.CURIO_KINDS.has(kind):
push_error("[停点件] 商店购买消费拒绝——件不在池词表: %s" % kind)
return false
return _apply_curio_pick(kind, tier, int(db.adventure.curio_value(kind, tier)))
return _apply_curio_pick(kind, tier, int(db.run.adventure.curio_value(kind, tier)))
# ---- 冒险级复位(宿主 _reset_curio_state 调用——停点半:池归 deck.reset、停点三态与刷新账清零)----
@@ -376,7 +376,7 @@ func restore_pending(bp_pending: Dictionary) -> void:
mig["feat_id"] = String((c as Dictionary).get("feat_id", ""))
migrated.append(mig)
else:
migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON})
migrated.append({"kind": String(c), "tier": db.run.adventure.TIER_COMMON})
_pending = {"candidates": migrated}
var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
curio_choice_presented.emit(payload)
+25 -25
View File
@@ -294,7 +294,7 @@ func _hero_attack_ctx(t: Dictionary) -> Dictionary:
func _ctx_rewrites(t: Dictionary, target: Dictionary, armor: String, matchup: String) -> Dictionary:
var ac_hits: Array[Variant] = (t.context.get("fx_attack_hits", []) as Array)
var roll_read: Dictionary[Variant, Variant] = _armor_roll()
var threshold: int = defender_ac(target) + int(db.settlement.damage_roll.attack_bonus)
var threshold: int = defender_ac(target) + int(db.combat.settlement.damage_roll.attack_bonus)
# armor_roll 发射点(敌护甲掷骰判定;免掷恒折扣订阅面)
var ar_hits := _fx_hits("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)})
var trade_hit := 0
@@ -383,7 +383,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
var ac_hits: Array[Variant] = ctx["ac_hits"]
var roll_read: Dictionary[Variant, Variant] = ctx["roll_read"]
var atk_type: String = ctx["atk_type"]
var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"]))
var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.combat.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"]))
var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))}
payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型)
payload["roll"] = int(roll_read.roll)
@@ -392,7 +392,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
if bool(ctx["crit"]):
payload["crit"] = true
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense"))
var landed: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled)
var landed: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled)
# 加法族:英雄侧 rider(enemy_skill rider 对称位补齐口径)——后置必中附加,独立于掷点与乘区
# (奥术余烬「普攻命中后恒定结算」语义);猎巢人巢控光环(per nest_count)/矿工的执拗开格加成
# (per dig_streak、cap 封顶)同通道合计;档一质变 rider+1 / 档二(crit 时)rider 乘暴击倍率。
@@ -406,7 +406,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
hero_rider += contrib
if hero_rider > 0:
if bool(ctx["crit"]) and bool(inject["rider_tier_crit"]):
hero_rider = int(round(float(hero_rider) * float(db.settlement.damage_roll.crit_multiplier)))
hero_rider = int(round(float(hero_rider) * float(db.combat.settlement.damage_roll.crit_multiplier)))
landed += hero_rider
payload["hero_rider"] = hero_rider
units.apply_damage(target, landed)
@@ -432,7 +432,7 @@ func _kill_overflow(t: Dictionary, target: Dictionary, landed: int) -> void:
# source_class 用中性名(fx_overflow 泛用通道,不挂件名)
var of_t := open_ticket(DamageController.KIND_BOARD_TYPED, "fx_overflow", "贯穿弹射",
{"channel": "board",
"base_value": maxi(int(db.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)),
"base_value": maxi(int(db.combat.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)),
"target_rule": DamageController.TARGET_FRONT_ALIVE, "ri": int(t.round_index),
"origin_cell": t.origin_cell, "attack_type": "pierce"})
if policy == POLICY_SEGMENT:
@@ -492,13 +492,13 @@ func _resolve_enemy_skill(t: Dictionary) -> void:
func _enemy_roll(t: Dictionary) -> Dictionary:
var hero: Dictionary[Variant, Variant] = units.hero_unit()
var roll_read: Dictionary[Variant, Variant] = _armor_roll()
var threshold: int = defender_ac(hero) + int(db.settlement.damage_roll.attack_bonus)
var threshold: int = defender_ac(hero) + int(db.combat.settlement.damage_roll.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var cdelta: int = _counter_delta(String(t.attack_type), String(hero.get("armor_type", "light")))
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.damage_roll.crit_multiplier), cdelta)
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.combat.settlement.damage_roll.crit_multiplier), cdelta)
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output"))
var total: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区
var total: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区
return {"roll": int(roll_read.roll), "threshold": threshold, "crit": crit, "discount": discount, "total": total}
@@ -520,7 +520,7 @@ func _enemy_shield_apply(hits: Array, total: int) -> Dictionary:
var set_v = h.get("set", null)
EffectEngine.counter_add(_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
if shield > 0:
total = maxi(int(db.settlement.damage_roll.damage_floor), total - shield)
total = maxi(int(db.combat.settlement.damage_roll.damage_floor), total - shield)
return {"total": total, "shield": shield}
@@ -541,7 +541,7 @@ func _resolve_board_typed(t: Dictionary) -> void:
# 无模式 delta 才入棋盘窗(mode 路由:clamp_bonus=主角攻击面专享、discount_shift=折扣档面)
if String(h.get("kind", "")) == "modify_ticket" and h.has("delta") and String(h.get("mode", "")) == "":
cdelta += int(h.delta)
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.settlement.damage_roll.damage_floor))
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.combat.settlement.damage_roll.damage_floor))
units.apply_damage(target, dealt)
emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index),
"%s → %s" % [String(t.display), String(target.display_name)], dealt,
@@ -556,7 +556,7 @@ func _resolve_board_blast(t: Dictionary) -> void:
_ineffective_row(t, "无存活敌人")
_outcomes[int(t.ticket_id)] = "zero"
return
var blast_dmg: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
var blast_dmg: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
for u in alive:
units.apply_damage(u, blast_dmg) # 恒定表值(下限保底),逐个;受击即死——死亡即不动手
emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index),
@@ -567,7 +567,7 @@ func _resolve_board_blast(t: Dictionary) -> void:
# 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷
func _resolve_board_flat_hero(t: Dictionary) -> void:
var dealt: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
var dealt: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
units.apply_damage(units.hero_unit(), dealt)
var extra: Dictionary[Variant, Variant] = {"target": "hero"}
for k in t.context.keys():
@@ -580,16 +580,16 @@ func _resolve_board_flat_hero(t: Dictionary) -> void:
# 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit}
func _armor_roll() -> Dictionary:
var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.damage_roll.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.settlement.damage_roll.roll_faces))
return {"roll": roll, "crit": roll == int(db.settlement.damage_roll.crit_roll)}
var stream: RandomNumberGenerator = rng.open_stream(String(db.combat.settlement.damage_roll.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.combat.settlement.damage_roll.roll_faces))
return {"roll": roll, "crit": roll == int(db.combat.settlement.damage_roll.crit_roll)}
# 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点)
func _roll_discount(roll: int, threshold: int) -> int:
if roll == int(db.settlement.damage_roll.crit_roll):
if roll == int(db.combat.settlement.damage_roll.crit_roll):
return 100
return DamageMath.roll_discount_percent(roll, threshold, db.settlement.damage_roll.roll_tier_bounds, db.settlement.damage_roll.roll_tier_discounts)
return DamageMath.roll_discount_percent(roll, threshold, db.combat.settlement.damage_roll.roll_tier_bounds, db.combat.settlement.damage_roll.roll_tier_discounts)
# 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照;
@@ -599,15 +599,15 @@ func defender_ac(unit: Dictionary) -> int:
if bool(unit.get("is_hero", false)):
var total: int = _hero_ac_override
if total < 0:
total = int(db.hero.armor_class)
total = int(db.combat.hero.armor_class)
for eff in effects.query_active("hero"):
var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if not row.is_empty() and String(row.unit) == "ac":
total += int(eff.effective_value)
return total
var total_e: int = int(unit.get("armor_class", 0))
for eff_e in effects.query_active("enemy"):
var erow_ac: Dictionary[Variant, Variant] = db.status_effect_row(eff_e.effect_id)
var erow_ac: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff_e.effect_id)
if not erow_ac.is_empty() and String(erow_ac.unit) == "ac":
total_e += int(eff_e.effective_value)
return total_e
@@ -618,14 +618,14 @@ func defender_ac(unit: Dictionary) -> int:
func _counter_delta(attack_type: String, armor: String) -> int:
if attack_type == "":
return 0
var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness
var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness
var row: Dictionary[Variant, Variant] = tbl.get(attack_type, {})
return int(row.get(armor, 0))
# 折扣档改善(猎弱本能——折扣表向全额方向移 value 档;查不到当前档 = 不动防御口径)
func _discount_shift_up(discount: int, shift: int) -> int:
var tbl: Array[Variant] = db.settlement.damage_roll.roll_tier_discounts
var tbl: Array[Variant] = db.combat.settlement.damage_roll.roll_tier_discounts
var idx := tbl.find(discount)
if idx < 0:
return discount
@@ -636,12 +636,12 @@ func _discount_shift_up(discount: int, shift: int) -> int:
func clamped_pool(pool: String) -> int:
var total := 0
for eff in effects.query_active("hero") + effects.query_active("enemy"):
var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if row.is_empty():
continue
if String(db.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
if String(db.combat.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口)
return clampi(total, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_hi))
return clampi(total, int(db.combat.settlement.core.pool_clamp_lo), int(db.combat.settlement.core.pool_clamp_hi))
# 攻击行显示名(掷点档位随行文本可读;重档折扣 = "被压制")
+169 -109
View File
@@ -94,58 +94,76 @@ func _init(db_ref: ConfigDb = null) -> void:
## 在 dispatch 的 round_start 特判内整桶 clear,adventure 桶随宿主字典引用跨战持久——无副本搬运)。
func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Array = []) -> void:
_triggers.clear()
_load_feat_triggers(feat_ids)
_load_attune_triggers(item_ids)
_push_tree_rules(tree_rules)
## 特性订阅装载域(feats 表序→按持有集过滤→行内数组序——装载声明序第一段)
func _load_feat_triggers(feat_ids: Array) -> void:
if db != null and db.feats != null:
for row in db.feats.rows:
for row in db.crossrun.feats.rows:
var fid := String(row.get("feat_id", ""))
if not feat_ids.has(fid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("feat", fid, tr, row)
## 同调秘宝订阅装载域(treasure 同调行序→按装备集过滤→行内数组序——装载声明序第二段)
func _load_attune_triggers(item_ids: Array) -> void:
if db != null:
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
if not item_ids.has(iid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("attune", iid, tr, row)
# 树规则合成订阅(六键按 v0.8 三组双态):posture 两键=「每关首轮窗」语义
# 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕;
# sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同
# 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕;
# economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。
## 树规则合成订阅域(六键按 v0.8 三组双态——装载声明序第三段):posture 两键=「每关首轮窗」语义
## 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕;
## sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同
## 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕;
## economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。
func _push_tree_rules(tree_rules: Array) -> void:
for key_v in tree_rules:
var rk := String(key_v)
match rk:
"posture_first_strike":
_push_trigger("tree", rk, {"on": "round_start",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
"posture_bulwark":
_push_trigger("tree", rk, {"on": "damage_taken",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": 1}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "damage_mult", "value": 50}}, {})
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used",
"value": 1}}, {})
"sense_guard":
# 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后
# 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立)
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used",
"value": 1}}, {})
_push_tree_rule(String(key_v))
## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动)
func _push_tree_rule(rk: String) -> void:
match rk:
"posture_first_strike":
_push_trigger("tree", rk, {"on": "round_start",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
"posture_bulwark":
_push_trigger("tree", rk, {"on": "damage_taken",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": 1}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "damage_mult", "value": 50}}, {})
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used",
"value": 1}}, {})
"sense_guard":
# 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后
# 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立)
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used",
"value": 1}}, {})
func _push_trigger(source: String, id: String, tr: Dictionary, row: Dictionary) -> void:
@@ -203,13 +221,14 @@ func dispatch(ctx: EffectContext, event: String, payload: Dictionary, facts: Cal
return hits
## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶)。
## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶;
## 前缀判定序 round→lvl→adv→battle 缺省逐字)
static func bucket_of(ctx: EffectContext, counter_id: String) -> Dictionary[Variant, Variant]:
if counter_id.begins_with("round_"):
return ctx.round_bucket
elif counter_id.begins_with("lvl_"):
if counter_id.begins_with("lvl_"):
return ctx.level_bucket
elif counter_id.begins_with("adv_"):
if counter_id.begins_with("adv_"):
return ctx.adventure_bucket
return ctx.battle_bucket
@@ -262,41 +281,68 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal
var val = c.get("value", null)
var actual = _fact_value(ctx, fact, c, payload, facts)
match op:
"eq":
return str(actual) == str(val)
"neq":
return str(actual) != str(val)
"gt":
return int(actual) > int(val)
"gte":
return int(actual) >= int(val)
"lt":
return int(actual) < int(val)
"lte":
return int(actual) <= int(val)
"eq", "neq", "gt", "gte", "lt", "lte":
return _cmp_ok(op, actual, val)
"ends_with":
if val is Array:
for v in (val as Array):
if String(actual).ends_with(String(v)):
return true
return false
return String(actual).ends_with(String(val))
return _ends_with_ok(actual, val)
"has":
# 在场判定(fact 查询面落法——白名单覆盖):value 缺省=非空判定
# (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场);
# value 给定=数组包含判定(保留泛用面)。
if c.has("value"):
if actual is Array:
return (actual as Array).has(val)
return false
if actual is Array:
return not (actual as Array).is_empty()
return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错)
return _has_ok(c, actual)
"mod_zero", "mod_eq", "advantageous", "gte_ref", "lt_ref":
return _counter_op_ok(ctx, c, op, actual)
assert(false, "[效果引擎] when 算子出域: %s" % op)
return false
## 比较算子域(eq/neq/gt/gte/lt/lte 六比较——str 逐字与 int 投影语义逐字)
func _cmp_ok(op: String, actual: Variant, val: Variant) -> bool:
var out := false
match op:
"eq":
out = str(actual) == str(val)
"neq":
out = str(actual) != str(val)
"gt":
out = int(actual) > int(val)
"gte":
out = int(actual) >= int(val)
"lt":
out = int(actual) < int(val)
"lte":
out = int(actual) <= int(val)
return out
## 后缀匹配域(ends_with——值数组任一命中即真)
func _ends_with_ok(actual: Variant, val: Variant) -> bool:
if val is Array:
for v in (val as Array):
if String(actual).ends_with(String(v)):
return true
return false
return String(actual).ends_with(String(val))
## 在场判定域(has——fact 查询面落法,白名单覆盖):value 缺省=非空判定
## (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场);
## value 给定=数组包含判定(保留泛用面)
func _has_ok(c: Dictionary, actual: Variant) -> bool:
if c.has("value"):
if actual is Array:
return (actual as Array).has(c.get("value"))
return false
if actual is Array:
return not (actual as Array).is_empty()
return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错)
## 计数器与矩阵算子域(mod_zero/mod_eq 取模、advantageous 克制矩阵、gte_ref/lt_ref 计数器比较——读经 ctx)
func _counter_op_ok(ctx: EffectContext, c: Dictionary, op: String, actual: Variant) -> bool:
match op:
"mod_zero":
var m := int(val)
var m := int(c.get("value", null))
return m > 0 and int(actual) % m == 0
"mod_eq":
var me_m := int(val)
var me_m := int(c.get("value", null))
return me_m > 0 and int(actual) % me_m == int(c.get("ref", 0))
"advantageous":
# 克制矩阵正面判定(matchup = "{attack_type}_vs_{armor}"——矩阵值 > 0 = 克制成立)
@@ -305,7 +351,6 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal
return int(actual) >= counter(ctx, String(c.get("ref", "")))
"lt_ref":
return int(actual) < counter(ctx, String(c.get("ref", "")))
assert(false, "[效果引擎] when 算子出域: %s" % op)
return false
@@ -329,7 +374,7 @@ func _counter_delta_of(matchup: String) -> int:
var parts := matchup.split("_vs_")
if parts.size() != 2:
return 0
var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness
var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness
var row: Dictionary[Variant, Variant] = tbl.get(String(parts[0]), {})
return int(row.get(String(parts[1]), 0))
@@ -350,44 +395,59 @@ static func validate_trigger_row(rid: String, row: Dictionary, triggers: Array,
errs.append({"category": "enum", "message": "%s 订阅事件出域(事件词汇封闭): %s" % [rid, on]})
elif MINE_EVENTS.has(on):
mine_covered = true
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary
for k in c.keys():
if not WHEN_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]})
var fact := String(c.get("fact", ""))
if not FACTS.has(fact):
errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]})
if not OPS.has(String(c.get("op", "eq"))):
errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]})
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for k in do_d.keys():
if not DO_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]})
var kind := String(do_d.get("kind", ""))
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]})
# 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源)
if do_d.has("counter_id"):
var cid := String(do_d.counter_id)
if not counter_id_legal(cid):
errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]})
# value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)——
# do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例)
for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]:
if do_d.has(vf_k):
var fld := String(do_d.get(vf_k, ""))
if fld != "" and not row.has(fld):
errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]})
for c_v in (tr.get("when", []) as Array):
var c_w: Dictionary[Variant, Variant] = c_v as Dictionary
if c_w.has("value_from"):
var wfld := String(c_w.get("value_from", ""))
if wfld != "" and not row.has(wfld):
errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]})
_vtr_when_keys(rid, tr, errs)
_vtr_do_keys(rid, tr, row, errs)
return mine_covered
## when 键域校验:键白名单/fact 词表/算子词表(追加序承原位——when 键→fact→op)
static func _vtr_when_keys(rid: String, tr: Dictionary, errs: Array) -> void:
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary
for k in c.keys():
if not WHEN_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]})
var fact := String(c.get("fact", ""))
if not FACTS.has(fact):
errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]})
if not OPS.has(String(c.get("op", "eq"))):
errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]})
## do 键域校验:键白名单(零裸 flat 护栏)/效果类型闭集/计数器前缀合法性+value_from 引用悬空域
static func _vtr_do_keys(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for k in do_d.keys():
if not DO_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]})
var kind := String(do_d.get("kind", ""))
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]})
# 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源)
if do_d.has("counter_id"):
var cid := String(do_d.counter_id)
if not counter_id_legal(cid):
errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]})
_vtr_from_refs(rid, tr, row, errs)
## value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)——
## do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例)
static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]:
if do_d.has(vf_k):
var fld := String(do_d.get(vf_k, ""))
if fld != "" and not row.has(fld):
errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]})
for c_v in (tr.get("when", []) as Array):
var c_w: Dictionary[Variant, Variant] = c_v as Dictionary
if c_w.has("value_from"):
var wfld := String(c_w.get("value_from", ""))
if wfld != "" and not row.has(wfld):
errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]})
## 组合规则(需求卡片 §3.4 六族——效果类型的必填属性,发射点施加面引用)
## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap(非 transfer)、
+5 -5
View File
@@ -52,12 +52,12 @@ func _normalize_override(raw: Dictionary) -> Dictionary[Variant, Variant]:
# 调试浮窗百分比后乘;pct=0 时乘法恒等——MC 锚零漂移)----
func effective_max_hp() -> int:
var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp")
var base: int = int(db.combat.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp")
return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0)))
func effective_atk() -> int:
var base: int = int(db.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk")
var base: int = int(db.combat.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk")
return maxi(1, int(round(float(base) * (100.0 + float(_override[0])) / 100.0)))
@@ -70,10 +70,10 @@ func effective_ac() -> int:
# 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线,
# 与 hero 表两轴删除前 armor_class=10 行为连续)
func _armor_base_ac() -> int:
var at: String = armor_type if armor_type != "" else db.forge_default_armor_type()
var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at)
var at: String = armor_type if armor_type != "" else db.crossrun.forge.default_armor_type
var row: Dictionary[Variant, Variant] = db.crossrun.forge.armor_route_by_type(at)
if row.is_empty():
return int(db.hero.armor_class) # 表缺失防御兜底(现役基准)
return int(db.combat.hero.armor_class) # 表缺失防御兜底(现役基准)
return int(row.get("base_ac", 0))
+4 -4
View File
@@ -318,7 +318,7 @@ func restore_fragment(resume_state: Dictionary) -> Dictionary:
func _find_level_row(level_index: int) -> Dictionary:
return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
return db.run.level.row_by_level_index(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
@@ -327,7 +327,7 @@ func _find_level_row(level_index: int) -> Dictionary:
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
var level_index: int = int(row.get("level_index", 0))
var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index)
var params: Dictionary[Variant, Variant] = db.run.level_gen_params.row_by_level_index(level_index)
if params.is_empty():
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
return false
@@ -433,7 +433,7 @@ func visited_ids() -> Array:
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
# {id : |层(id) − 锚层| ≤ N};N = db.run.level.info_visible_layers(禁硬编码);
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
func type_visible_ids() -> Array[int]:
@@ -443,7 +443,7 @@ func type_visible_ids() -> Array[int]:
var cur := current_node()
if not cur.is_empty():
anchor = int(layers.get(_node_id(cur), 0))
var n: int = int(db.level.info_visible_layers)
var n: int = int(db.run.level.info_visible_layers)
for node in nodes():
var nid := _node_id(node)
if String(node.get("type", "")) == "boss" \
+159 -170
View File
@@ -7,6 +7,7 @@ extends Node
var db: ConfigDb
var save: SaveManagerCore
var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(K3′ 装配成员——架构 :90 直连)
# MetaProgression 是 autoload(仿 RngService 先例:类名加 Core 后缀、
@@ -16,6 +17,8 @@ var save: SaveManagerCore
func _init(db_ref: ConfigDb = null, save_ref: SaveManagerCore = null) -> void:
db = db_ref
save = save_ref
if save != null:
meta_state = save.meta_state
func _ready() -> void:
@@ -23,31 +26,23 @@ func _ready() -> void:
db = get_node("/root/ConfigDB")
if save == null:
save = get_node("/root/SaveManager")
func keystone_balance() -> int:
return save.keystone_balance()
meta_state = save.meta_state
# ---- 秘宝解锁服务(解锁混合制,契约 6)----
# 解锁条件单一事实源=treasure_items.tres 行内 unlock_keystone_cost/unlock_requires 谓词
# (C1 逐件分配锁定表)。
# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager)
func is_item_unlocked(item_id: String) -> bool:
return save.item_unlocked(item_id)
# (C1 逐件分配锁定表)。钥石余额/道具解锁读数消费方直连 meta.meta_state(纯转发读已随件退役)。
# 解锁条件计数读数单点(谓词 key → 生涯计数——封闭枚举=TreasureItemTable.UNLOCK_COUNTER_KEYS;
# 词表加键 = 此处增一 match 分支,阈值比较与解锁写档零改动)
func _treasure_unlock_progress(key: String) -> int:
match key:
"adventure_cleared":
return save.adventure_cleared()
return meta_state.career_state().adventure_cleared
"boss_kill_count":
return save.boss_kill_count()
return meta_state.career_state().boss_kill_count
"vision_used":
return save.vision_used()
return meta_state.career_state().vision_used
_:
return 0 # 未知计数键恒 0 = 永不解锁(fail-safe 非致命——schema 词表校验兜底,不静默误解锁)
@@ -55,15 +50,15 @@ func _treasure_unlock_progress(key: String) -> int:
# 钥石购买解锁(营地秘宝匣唯一购买写口):仅购买件(行带 unlock_keystone_cost)可购;
# 已解锁/余额不足/条件件误购 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币)。
func buy_treasure_unlock(item_id: String) -> bool:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id)
var price := int(arow.get("unlock_keystone_cost", 0))
if arow.is_empty() or price < 1 or (arow.get("unlock_requires", []) as Array).size() > 0:
return false # 非购买件(条件件/无价行)拒绝
if save.item_unlocked(item_id):
if meta_state.item_unlocked(item_id):
return false # 已解锁重复购买拒绝
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false # 余额不足(零副作用)
save.unlock_item(item_id)
meta_state.unlock_item(item_id)
return save.write_to_disk()
@@ -71,9 +66,9 @@ func buy_treasure_unlock(item_id: String) -> bool:
# 全部谓词达标才解锁(多谓词=AND——天眼双条件);幂等:已解锁跳过,重复刷新零重复入账。
func refresh_treasure_unlocks() -> Array:
var new_unlocks: Array[String] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
if save.item_unlocked(iid):
if meta_state.item_unlocked(iid):
continue
var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array
if ureqs.is_empty():
@@ -85,7 +80,7 @@ func refresh_treasure_unlocks() -> Array:
met = false
break
if met:
save.unlock_item(iid)
meta_state.unlock_item(iid)
new_unlocks.append(iid)
if not new_unlocks.is_empty():
save.write_to_disk()
@@ -96,9 +91,9 @@ func refresh_treasure_unlocks() -> Array:
# {item_id, display_name, desc, tier, unlocked, mode(keystone|condition|owned), price, reqs:[{key,threshold,progress}]}
func treasure_shop_status() -> Array:
var out: Array[Dictionary] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
var unlocked := save.item_unlocked(iid)
var unlocked := meta_state.item_unlocked(iid)
var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array
var req_view: Array[Variant] = []
for req_v in ureqs:
@@ -128,18 +123,18 @@ func treasure_shop_status() -> Array:
# 等级语义:未解锁/未锻造=0 加值(裸体态);锻造第一级=「获得装备」=首个 +1;逐级递进。
# 路线解锁态派生(不落档——初始持有∨直购∨练满前一档;派生单点只此一处)
func forge_route_unlocked(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
func _forge_route_unlocked(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty():
return false
if bool(row.get("initial_owned", false)):
return true
if save.forge_unlocked_purchases().has(route_id):
if meta_state.forge_state().unlocked.has(route_id):
return true
var prereq := String(row.get("prereq_route", ""))
if prereq != "":
var prow: Dictionary[Variant, Variant] = db.forge_route(prereq)
if not prow.is_empty() and save.forge_route_level(prereq) >= int(prow.get("cap", 0)):
var prow: Dictionary[Variant, Variant] = db.crossrun.forge.route(prereq)
if not prow.is_empty() and meta_state.forge_state().levels.get(prereq, 0) >= int(prow.get("cap", 0)):
return true
return false
@@ -147,86 +142,60 @@ func forge_route_unlocked(route_id: String) -> bool:
# 路线购买(几何价=基础价×150%^已购级数——锚表 20/30/45/67/101;等级+1 即时写盘)。
# 未解锁/已满级/余额不足/未知路线 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币不写盘)。
func forge_buy_level(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or not forge_route_unlocked(route_id):
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or not _forge_route_unlocked(route_id):
return false
var lvl := save.forge_route_level(route_id)
var lvl := meta_state.forge_state().levels.get(route_id, 0)
if lvl >= int(row.get("cap", 0)):
return false
var price := ForgeTable.level_price(row, lvl)
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_forge_route_level(route_id, lvl + 1)
meta_state.set_forge_route_level(route_id, lvl + 1)
return save.write_to_disk()
# 一次性解锁价直购(并联双通道②)。已解锁(含练满前一档派生)= 拒绝零收费——两通道互不影响:
# 未练满也可直购;直购后练满不重复收(练满通道本就是派生、零收费)。初始持有行无解锁面。
func forge_buy_unlock(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or bool(row.get("initial_owned", false)):
return false
if forge_route_unlocked(route_id):
if _forge_route_unlocked(route_id):
return false
var price := int(row.get("unlock_price", 0))
if price < 1 or not save.spend_keystone(price):
if price < 1 or not meta_state.spend_keystone(price):
return false
save.add_forge_unlock(route_id)
meta_state.add_forge_unlock(route_id)
return save.write_to_disk()
# 当前武器/甲选中切换(免费——只受理已解锁路线;写存档域+即时写盘。冒险开始锁死归参数面快照,
# 契约 8;冒险中无切换入口=UI 面无路径可达本口)
func forge_select_weapon(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(route_id):
# 契约 8;冒险中无切换入口=UI 面无路径可达本口)。并组入口:kind ∈ {weapon, armor}。
func forge_select(kind: String, route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(route_id):
return false
save.set_forge_weapon(route_id)
if kind == "weapon":
meta_state.set_forge_weapon(route_id)
else:
meta_state.set_forge_armor(route_id)
return save.write_to_disk()
func forge_select_armor(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(route_id):
return false
save.set_forge_armor(route_id)
return save.write_to_disk()
# 当前选中武器攻击加值 / 当前甲 AC 加值(路线等级×每级增量——0=裸体态/未锻造合法态)。
# 选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态,禁把脏选中带进冒险。
func forge_weapon_atk_bonus() -> int:
var rid := save.forge_weapon()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid):
return 0
return save.forge_route_level(rid) * int(row.get("per_level_gain", 0))
func forge_armor_ac_bonus() -> int:
var rid := save.forge_armor()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid):
return 0
return save.forge_route_level(rid) * int(row.get("per_level_gain", 0))
# 两根类型轴当前生效值(选中路线类型轴;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。
# 两根类型轴当前生效值({attack, armor} 并组承载;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。
# 默认值与柜台解锁状态解耦:树钥匙未点亮时本函数照常返回默认值(配置常驻可读)。
func hero_attack_type() -> String:
var rid := save.forge_weapon()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid):
return db.forge_default_attack_type()
return String(row.get("type_axis", ""))
func hero_armor_type() -> String:
var rid := save.forge_armor()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid):
return db.forge_default_armor_type()
return String(row.get("type_axis", ""))
func hero_type_axes() -> Dictionary:
var fs := meta_state.forge_state()
var wrow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["weapon"]))
var attack := db.crossrun.forge.default_attack_type
if not wrow.is_empty() and String(wrow.get("kind", "")) == "weapon" and _forge_route_unlocked(String(fs["weapon"])):
attack = String(wrow.get("type_axis", ""))
var arow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["armor"]))
var armor := db.crossrun.forge.default_armor_type
if not arow.is_empty() and String(arow.get("kind", "")) == "armor" and _forge_route_unlocked(String(fs["armor"])):
armor = String(arow.get("type_axis", ""))
return {"attack": attack, "armor": armor}
# ---- 锻造工坊柜台只读投影(呈现层数据源:两区六路线行板;余额联动禁用归视图)----
@@ -235,16 +204,16 @@ func hero_armor_type() -> String:
# prereq_level, prereq_cap, prereq_maxed}——纯结构面,禁嵌余额判断(视图按 balance 禁用,同秘宝匣先例)。
func forge_shop_status() -> Array:
var out: Array[Dictionary] = []
for row in db.forge_routes():
for row in db.crossrun.forge.route_rows():
var rid := String(row.get("route_id", ""))
var lvl := save.forge_route_level(rid)
var lvl := meta_state.forge_state().levels.get(rid, 0)
var cap_v := int(row.get("cap", 0))
var unlocked := forge_route_unlocked(rid)
var unlocked := _forge_route_unlocked(rid)
var prereq := String(row.get("prereq_route", ""))
var plvl := save.forge_route_level(prereq) if prereq != "" else 0
var pcap := int(db.forge_route(prereq).get("cap", 0)) if prereq != "" else 0
var selected := (String(row.get("kind", "")) == "weapon" and save.forge_weapon() == rid) \
or (String(row.get("kind", "")) == "armor" and save.forge_armor() == rid)
var plvl := meta_state.forge_state().levels.get(prereq, 0) if prereq != "" else 0
var pcap := int(db.crossrun.forge.route(prereq).get("cap", 0)) if prereq != "" else 0
var selected := (String(row.get("kind", "")) == "weapon" and meta_state.forge_state().weapon == rid) \
or (String(row.get("kind", "")) == "armor" and meta_state.forge_state().armor == rid)
out.append({
"route_id": rid,
"display_name": String(row.get("display_name", "")),
@@ -258,7 +227,7 @@ func forge_shop_status() -> Array:
"next_price": ForgeTable.level_price(row, lvl) if (unlocked and lvl < cap_v) else 0,
"unlock_price": int(row.get("unlock_price", 0)),
"prereq_route": prereq,
"prereq_name": String(db.forge_route(prereq).get("display_name", "")) if prereq != "" else "",
"prereq_name": String(db.crossrun.forge.route(prereq).get("display_name", "")) if prereq != "" else "",
"prereq_level": plvl,
"prereq_cap": pcap,
"prereq_maxed": prereq != "" and plvl >= pcap,
@@ -274,10 +243,10 @@ func forge_shop_status() -> Array:
# apply_adventure_summary 逐关卡完成关次置位)。本函数只做标记+入账+归档。
func settle_adventure(summary: Dictionary) -> Dictionary:
# 首次冒险布尔位置位(关卡节点图生成 v0.4——完成/失败/放弃三结局同经本收束挂点,写入只在收束)
save.mark_first_adventure_done()
meta_state.mark_first_adventure_done()
var drops: Array[Variant] = (summary.get("keystone_drops", []) as Array).duplicate(true)
save.apply_adventure_summary(summary, drops)
save.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件)
meta_state.apply_adventure_summary(summary, drops)
meta_state.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件)
var new_unlocks := refresh_treasure_unlocks() # 条件解锁刷新
save.write_to_disk()
var total := int(summary.get("keystone_total", 0))
@@ -299,51 +268,75 @@ func settle_adventure(summary: Dictionary) -> Dictionary:
# 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。
func hero_override() -> Dictionary:
# 锻造路线加值内联(原 forge_weapon_atk_bonus/forge_armor_ac_bonus 单调用点收编——输出逐字:
# 路线等级×每级增量,选中态异常/未解锁防御回退 0)
var fs := meta_state.forge_state()
var atk_bonus := _forge_axis_bonus(fs, "weapon", "weapon")
var ac_bonus := _forge_axis_bonus(fs, "armor", "armor")
# 树淬炼 flat 内联(原 tree_stat_flat 单调用点收编——四线 flat 汇总同轮扫描,三轴输出逐字等价)
var tree_atk := 0
var tree_hp := 0
var tree_ac := 0
var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"]
for row in db.crossrun.meta_tree.rows:
var lvl := int(nodes.get(String(row.get("node_id", "")), 0))
if lvl <= 0:
continue
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
tree_atk += int(fx.get("atk", 0)) * lvl
tree_hp += int(fx.get("max_hp", 0)) * lvl
tree_ac += int(fx.get("armor_class", 0)) * lvl
return {
"atk": forge_weapon_atk_bonus() + tree_stat_flat("atk"),
"max_hp": tree_stat_flat("max_hp"),
"armor_class": forge_armor_ac_bonus() + tree_stat_flat("armor_class"),
"atk": atk_bonus + tree_atk,
"max_hp": tree_hp,
"armor_class": ac_bonus + tree_ac,
}
# 锻造轴加值原语(选中路线等级×每级增量;选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态)
func _forge_axis_bonus(fs: Dictionary, sel_key: String, kind: String) -> int:
var rid := String(fs[sel_key])
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(rid)
if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(rid):
return 0
return int(fs["levels"].get(rid, 0)) * int(row.get("per_level_gain", 0))
# ---- 天赋树段(设计权威 整合定稿 §1.4 一树双币三脉一脊)----
# 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」)
func tree_spent_total() -> int:
func _tree_spent_total() -> int:
var total := 0
for lvl in (save.tree_nodes() as Dictionary).values():
for lvl in (meta_state.tree_state().nodes as Dictionary).values():
total += int(lvl)
return total
# 节点可购校验(行门槛 + 显式前置 + 币 + cap;free 节点=钥匙不经购买,彩蛋=觉醒不经购买)
func tree_can_buy(node_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
if row.is_empty():
return false
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
# 彩蛋不经购买(觉醒唯一入口=_check_easter_egg 特化判定——V8 反例:手动购亮=红)
if String(row.get("kind", "")) == "easter":
return false
var lvl := save.tree_level(node_id)
if lvl >= int(row.get("cap", 1)):
if row.is_empty() or String(row.get("kind", "")) == "easter":
return false
var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"]
var lvl := int(nodes.get(node_id, 0))
var blocked := lvl >= int(row.get("cap", 1))
# 刷新升级节点:cap 主语=Y 账本(单一事实源——树上入口与装配段同 cap 同价,防双账漂移)
if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.attune_refresh_level() + 1).is_empty():
return false
if node_id == "ks_refresh" and db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1).is_empty():
blocked = true
# 行门槛(一线一节点:脊柱节点第 N 级需全树总投入 ≥4×(N−1))
if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat":
if tree_spent_total() < db.meta_tree.level_gate(lvl + 1):
return false
if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat" and _tree_spent_total() < db.crossrun.meta_tree.level_gate(lvl + 1):
blocked = true
for req in (row.get("requires", []) as Array):
if save.tree_level(String(req)) < 1:
return false
if int(nodes.get(String(req), 0)) < 1:
blocked = true
# 等级阈值前置(requires_level {node_id: n}:前置节点等级 ≥ n 才可购;
# 现役表零配置=空转零行为影响)
var req_lv: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary
for req_id in req_lv.keys():
if save.tree_level(String(req_id)) < int(req_lv[req_id]):
return false
return true
if int(nodes.get(String(req_id), 0)) < int(req_lv[req_id]):
blocked = true
return not blocked
# 节点购买(营地树 UI 唯一写口):树全钥石单币。free=工作间钥匙零币点亮
@@ -353,13 +346,13 @@ func tree_can_buy(node_id: String) -> bool:
func tree_buy(node_id: String) -> bool:
if node_id == "ks_refresh":
return buy_attune_refresh()
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
if row.is_empty() or not tree_can_buy(node_id):
return false
var price := int(row.get("price", 0))
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_tree_level(node_id, save.tree_level(node_id) + 1)
meta_state.set_tree_level(node_id, meta_state.tree_state().nodes.get(node_id, 0) + 1)
_check_easter_egg()
return save.write_to_disk()
@@ -367,45 +360,35 @@ func tree_buy(node_id: String) -> bool:
# Fork 切换(营地树 UI——一节点双态:购买解锁组默认 A 态,换态走本口零币;同态重复=零操作;
# 未解锁组切换=拒绝——解锁唯一入口=购买面 tree_buy_fork)
func tree_toggle_fork(group: String, side: String) -> bool:
if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"):
if not db.crossrun.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"):
return false
var cur := save.fork_side(group)
var cur := meta_state.tree_state().fork_sides.get(group, "")
if cur == "" or cur == side:
return false # 未解锁走购买面;同态零操作
save.set_fork_side(group, side)
meta_state.set_fork_side(group, side)
return save.write_to_disk()
# Fork 节点购买入口(一节点双态——购=解锁开关并置默认 A 态;重复购买由 cap 经 can_buy 拦截)
func tree_buy_fork(node_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
if row.is_empty() or String(row.get("kind", "")) != "fork":
return tree_buy(node_id)
if not tree_can_buy(node_id):
return false
var price := int(row.get("price", 0))
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_tree_level(node_id, 1)
save.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态
meta_state.set_tree_level(node_id, 1)
meta_state.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态
return save.write_to_disk()
# 树效果投影:四线 flat 汇总 + rule 键集(激活 Fork 侧 effects 并入)
func tree_stat_flat(key: String) -> int:
var total := 0
for row in db.tree_rows():
var lvl := save.tree_level(String(row.get("node_id", "")))
if lvl <= 0:
continue
total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl
return total
# 树规则键投影(激活 Fork 侧 effects 并入)
func tree_rule_keys() -> Array:
var out: Array[Variant] = []
var sides: Dictionary[Variant, Variant] = save.fork_sides()
for row in db.tree_rows():
var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) != "fork":
continue
var active := String(sides.get(String(row.get("fork_group", "")), ""))
@@ -418,10 +401,15 @@ func tree_rule_keys() -> Array:
return out
# 营地树面投影(CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工)
func tree_camp_rows() -> Array:
# 营地树面投影(并组入口——{rows, fork_groups};CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工)
func tree_camp_status() -> Dictionary:
return {"rows": _tree_camp_rows(), "fork_groups": _tree_fork_groups()}
## 树行板投影段(原 tree_camp_rows)
func _tree_camp_rows() -> Array:
var out: Array[Variant] = []
for row in db.tree_rows():
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) == "fork":
continue # Fork 行由 fork_groups 段承载
out.append({
@@ -430,32 +418,33 @@ func tree_camp_rows() -> Array:
"vein": String(row.get("vein", "")),
"kind": String(row.get("kind", "")),
"cap": int(row.get("cap", 1)),
"level": save.attune_refresh_level() if String(row.get("node_id", "")) == "ks_refresh" else save.tree_level(String(row.get("node_id", ""))), # 刷新升级行主语=Y 账本(同账路由 V15)
"level": meta_state.attune_state().refresh_level if String(row.get("node_id", "")) == "ks_refresh" else meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0), # 刷新升级行主语=Y 账本(同账路由 V15)
"price": int(row.get("price", 0)),
"currency": String(row.get("currency", "keystone")),
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
"gate_text": tree_gate_text(String(row.get("node_id", ""))),
"gate_text": _tree_gate_text(String(row.get("node_id", ""))),
})
return out
# 行板门槛文案(R1 机制位呈现——requires_level 未达标项拼「需 {前置名} Lv{n}」;达标/未配置=空串。
# 只呈现未达标项:达标后锁标消失,不做全量前置罗列)
func tree_gate_text(node_id: String) -> String:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
func _tree_gate_text(node_id: String) -> String:
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
var rl: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary
var parts: Array[String] = []
for req_id in rl.keys():
if save.tree_level(String(req_id)) < int(rl[req_id]):
var req_row: Dictionary[Variant, Variant] = db.tree_node(String(req_id))
if meta_state.tree_state().nodes.get(String(req_id), 0) < int(rl[req_id]):
var req_row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(String(req_id))
parts.append("需 %s Lv%d" % [String(req_row.get("display_name", req_id)), int(rl[req_id])])
return "、".join(parts)
func tree_fork_groups() -> Array:
## Fork 行板投影段(原 tree_fork_groups)
func _tree_fork_groups() -> Array:
var out: Array[Variant] = []
var sides: Dictionary[Variant, Variant] = save.fork_sides()
for row in db.tree_rows():
var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) != "fork":
continue
var fg := String(row.get("fork_group", ""))
@@ -464,7 +453,7 @@ func tree_fork_groups() -> Array:
"node_id": String(row.get("node_id", "")),
"display_name": String(row.get("display_name", "")),
"active_side": String(sides.get(fg, "")),
"owned": save.tree_level(String(row.get("node_id", ""))) > 0,
"owned": meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0) > 0,
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
"price": int(row.get("price", 0)),
"sides": [
@@ -478,16 +467,16 @@ func tree_fork_groups() -> Array:
# 彩蛋觉醒检查(特化判定——生命/命中两线拉满 且 同调位 1/2/3 全解锁 → easter_ley 免费点亮;
# 不依赖 requires/requires_level 前置形态)
func _check_easter_egg() -> void:
if save.tree_level("easter_ley") > 0:
if meta_state.tree_state().nodes.get("easter_ley", 0) > 0:
return
for nid in ["sp_hp", "sp_hit"]:
var row: Dictionary[Variant, Variant] = db.tree_node(nid)
if row.is_empty() or save.tree_level(nid) < int(row.get("cap", 1)):
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(nid)
if row.is_empty() or meta_state.tree_state().nodes.get(nid, 0) < int(row.get("cap", 1)):
return
for i in range(1, 4):
if save.tree_level("att_socket%d" % i) < 1:
if meta_state.tree_state().nodes.get("att_socket%d" % i, 0) < 1:
return
save.set_tree_level("easter_ley", 1)
meta_state.set_tree_level("easter_ley", 1)
# ---- 同调位账本(装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2)----
@@ -498,30 +487,30 @@ func _check_easter_egg() -> void:
# 格 1=普通格/格 2=优秀格/格 3=稀有格递增向下兼容;槽位来源=树同调位节点点亮数,树接口接缝现役)。
# 装配关系写存档持久。
func toggle_attunement(item_id: String) -> bool:
var equipped: Array[Variant] = save.attunement_equipped()
var equipped: Array[Variant] = meta_state.attune_state().equipped
if not equipped.has(item_id):
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id)
if arow.is_empty() or String(arow.get("form", "")) == "":
return false # 非同调行(过渡/退役行)禁装配
if not save.item_unlocked(item_id):
if not meta_state.item_unlocked(item_id):
return false # 未解锁禁装配(解锁混合制契约 6——装配以解锁为前置)
if equipped.size() >= Attunement.slots(db, save):
return false # 满位禁选
var rank: int = int(db.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99))
var rank: int = int(db.crossrun.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99))
if rank > equipped.size() + 1:
return false # 件档高于将占格档(第 k 格档=k——普通/优秀/稀有递增)
equipped.append(item_id)
else:
equipped.erase(item_id)
save.set_attunement_equipped(equipped)
meta_state.set_attunement_equipped(equipped)
return save.write_to_disk()
# 营地装配面投影(账面归本类、拼句归 CampOverlay——_camp_data 同款分工)
func attune_camp_status() -> Dictionary:
var equipped: Array[Variant] = save.attunement_equipped()
var equipped: Array[Variant] = meta_state.attune_state().equipped
var items: Array[Dictionary] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
items.append({
"item_id": iid,
@@ -530,10 +519,10 @@ func attune_camp_status() -> Dictionary:
"tier": String(row.get("tier", "")),
"form": String(row.get("form", "")),
"equipped": equipped.has(iid),
"unlocked": save.item_unlocked(iid),
"unlocked": meta_state.item_unlocked(iid),
})
var lvl := save.attune_refresh_level()
var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(lvl + 1)
var lvl := meta_state.attune_state().refresh_level
var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(lvl + 1)
return {
"slots": Attunement.slots(db, save),
"equipped": equipped,
@@ -549,15 +538,15 @@ func attune_camp_status() -> Dictionary:
# 每冒险开局刷新次数 Y(当前等级行表值;0 级 = 0 次即无刷新能力——P1 甲初始 0 同构)
func attune_refresh_charges() -> int:
return int(db.meta_attune_refresh_row(save.attune_refresh_level()).get("refresh_charges", 0))
return int(db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level).get("refresh_charges", 0))
# Y 升级购买(直购价 = 下一级行表值 keystone_cost;已满级/余额不足 false 零副作用——buy_forge 同款)
func buy_attune_refresh() -> bool:
var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(save.attune_refresh_level() + 1)
var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1)
if next_row.is_empty():
return false
if not save.spend_keystone(int(next_row.get("keystone_cost", 0))):
if not meta_state.spend_keystone(int(next_row.get("keystone_cost", 0))):
return false
save.set_attune_refresh_level(save.attune_refresh_level() + 1)
meta_state.set_attune_refresh_level(meta_state.attune_state().refresh_level + 1)
return save.write_to_disk()
+176
View File
@@ -0,0 +1,176 @@
## 存档内元进度数据件(D3 拆件——自 save_manager 收编元进度族):存档内元进度数据的读写唯一层。
## 数据读写原语族(写口 13)+只读投影(读 6——tree/forge/attune/career 四域值拷贝出户);
## 业务规则与写口编排归 MetaProgression,本件只承载键域读写(照单承载,零业务判定)。
## 零 _data 副本零缓存——全部读写经注入 save 的内存数据模型单点。
class_name MetaStateStore
extends RefCounted
var _save # SaveManagerCore 注入引用(_data 唯一取数点)
func _init(save_ref) -> void:
_save = save_ref
# ---- 写口原语族(13)----
# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据)
func spend_keystone(n: int) -> bool:
if keystone_balance() < n:
return false
_save._data["meta"]["keystone_balance"] = keystone_balance() - n
return true
func unlock_item(item_id: String) -> void:
_save._data["meta"]["item_unlocks"][item_id] = true
func set_tree_level(node_id: String, level: int) -> void:
var tn: Dictionary[Variant, Variant] = _save._data["meta"].get("tree_nodes", {}) as Dictionary
tn[node_id] = maxi(0, level)
_save._data["meta"]["tree_nodes"] = tn
func set_fork_side(group: String, side: String) -> void:
var fs: Dictionary[Variant, Variant] = _save._data["meta"].get("fork_sides", {}) as Dictionary
fs[group] = side
_save._data["meta"]["fork_sides"] = fs
func set_forge_route_level(route_id: String, level: int) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary
lv[route_id] = maxi(0, level)
dom["levels"] = lv
_save._data["meta"]["forge_routes"] = dom
func add_forge_unlock(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var un: Array[Variant] = dom.get("unlocked", []) as Array
if not un.has(route_id):
un.append(route_id)
dom["unlocked"] = un
_save._data["meta"]["forge_routes"] = dom
func set_forge_weapon(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["weapon"] = route_id
_save._data["meta"]["forge_routes"] = dom
func set_forge_armor(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["armor"] = route_id
_save._data["meta"]["forge_routes"] = dom
func set_attunement_equipped(item_ids: Array) -> void:
var out: Array[Variant] = []
for v in item_ids:
out.append(String(v))
_save._data["meta"]["attunement_equipped"] = out
func set_attune_refresh_level(n: int) -> void:
_save._data["meta"]["attune_refresh_level"] = maxi(0, n)
# 真视类工具累计使用入账(写入唯一经本口冒险收束聚合,受理计数点=battle 层经冒险层聚合)
func add_vision_used(n: int) -> void:
_save._data["meta"]["vision_used"] = maxi(0, career_state()["vision_used"] + maxi(0, n))
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为
# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计:
# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入
# 单口=本函数,禁第二写入点)。
func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void:
_save._data["meta"]["adventure_total"] = career_state()["adventure_total"] + 1
if bool(summary.get("cleared", false)):
_save._data["meta"]["adventure_cleared"] = career_state()["adventure_cleared"] + 1
_add_keystone(int(summary.get("keystone_total", 0)))
var cleared := int(summary.get("levels_cleared", 0))
for li in range(1, cleared + 1):
var key := str(li)
_save._data["meta"]["level_clear_counts"][key] = int(_save._data["meta"]["level_clear_counts"].get(key, 0)) + 1
_save._data["meta"]["boss_first_kill"][key] = true
_save._data["meta"]["boss_kill_count"] = career_state()["boss_kill_count"] + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复)
var entry: Dictionary[Variant, Variant] = summary.duplicate(true)
entry["boss_drops"] = boss_drops.duplicate(true)
var arr: Array[Variant] = _save._data["meta"]["recent_summaries"]
arr.push_front(entry)
while arr.size() > _save.SUMMARY_CAP:
arr.pop_back()
func mark_first_adventure_done() -> void:
_save._data["meta"]["first_adventure_done"] = true
# 钥石入账原语(生产消费零——apply_adventure_summary 专用;测量面经 _ 前缀直调)
func _add_keystone(n: int) -> void:
_save._data["meta"]["keystone_balance"] = keystone_balance() + n
# ---- 只读投影族(6——值拷贝出户,禁碰内部结构契约同款)----
func keystone_balance() -> int:
return int(_save._data["meta"]["keystone_balance"])
func item_unlocked(item_id: String) -> bool:
return _save._data["meta"]["item_unlocks"].has(item_id)
## 天赋树域只读投影:{nodes: node_id→已购等级, fork_sides: fork_group→"a"|"b"}
func tree_state() -> Dictionary:
return {
"nodes": (_save._data["meta"].get("tree_nodes", {}) as Dictionary).duplicate(),
"fork_sides": (_save._data["meta"].get("fork_sides", {}) as Dictionary).duplicate(),
}
## 锻造域只读投影:{levels: 六路线等级, unlocked: 直购清单, weapon/armor: 当前选中}
## (解锁态不落档——派生判定单点在 MetaProgression.forge_route_unlocked)
func forge_state() -> Dictionary:
var dom: Dictionary[Variant, Variant] = _forge_domain()
return {
"levels": (dom.get("levels", {}) as Dictionary).duplicate(),
"unlocked": (dom.get("unlocked", []) as Array).duplicate(),
"weapon": String(dom.get("weapon", "sword")),
"armor": String(dom.get("armor", "light")),
}
## 同调域只读投影:{equipped: 装配 item_id 有序数组(装配序即呈现序), refresh_level: Y 升级等级}
func attune_state() -> Dictionary:
return {
"equipped": (_save._data["meta"].get("attunement_equipped", []) as Array).duplicate(),
"refresh_level": int(_save._data["meta"].get("attune_refresh_level", 0)),
}
## 生涯域只读投影:{clear_counts, boss_first_kill, boss_kill_count, adventure_total,
## adventure_cleared, vision_used, recent_summaries, first_adventure_done}
func career_state() -> Dictionary:
return {
"clear_counts": (_save._data["meta"]["level_clear_counts"] as Dictionary).duplicate(),
"boss_first_kill": (_save._data["meta"]["boss_first_kill"] as Dictionary).duplicate(),
"boss_kill_count": int(_save._data["meta"].get("boss_kill_count", 0)),
"adventure_total": int(_save._data["meta"].get("adventure_total", 0)),
"adventure_cleared": int(_save._data["meta"].get("adventure_cleared", 0)),
"vision_used": int(_save._data["meta"].get("vision_used", 0)),
"recent_summaries": (_save._data["meta"]["recent_summaries"] as Array).duplicate(true),
"first_adventure_done": bool(_save._data["meta"].get("first_adventure_done", true)),
}
# 锻造域取数(本件内部单点)
func _forge_domain() -> Dictionary:
return _save._data["meta"].get("forge_routes", {}) as Dictionary
+13 -9
View File
@@ -304,15 +304,22 @@ func _do_dig(c: Vector2i) -> Dictionary:
if was_sealed:
_seals.erase(c)
var reveal_order: Array[Variant] = _chain_reveal_order(c, eff_seals)
_reveal_chain(c, reveal_order, was_sealed)
if action_economy.phase != action_economy.PHASE_ACTIVE:
_emit_round_end_seals_and_payload()
return _outcome(true, "executed", "", "")
## 连锁揭示域:首格+连锁新格揭示(BFS 规范序——cell_revealed 发射序即链序,整体随段搬迁)。
## 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带,
## 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
## dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印)
func _reveal_chain(c: Vector2i, reveal_order: Array, was_sealed: bool) -> void:
for rc in reveal_order:
if _revealed.has(rc):
continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|)
var is_mine := _board.is_mine(rc)
var channel := "dig" if rc == c else "chain"
# 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带,
# 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
# dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印)
if is_mine:
if _board.is_mine(rc):
_revealed[rc] = {"kind": "mine", "digit": -1}
cell_revealed.emit({
"cell": rc, "kind": "mine", "digit": -1,
@@ -326,9 +333,6 @@ func _do_dig(c: Vector2i) -> Dictionary:
"variant_ids": _board.variant_ids(rc), "channel": channel, "resource": true,
"dug_under_seal": was_sealed and rc == c,
})
if action_economy.phase != action_economy.PHASE_ACTIVE:
_emit_round_end_seals_and_payload()
return _outcome(true, "executed", "", "")
func _board_face_dig(c: Vector2i) -> String:
@@ -492,7 +496,7 @@ func _emit_seals_batch() -> void:
func _emit_round_end_seals_and_payload() -> void:
_emit_seals_batch()
var payload: Dictionary[Variant, Variant] = action_economy.last_round_end # 逐字段取自 行动经济(读取权 = 接口契约)
var payload: Dictionary[Variant, Variant] = action_economy.last_round_end() # 逐字段取自 行动经济(读取权 = 接口契约;属性→方法 K5)
round_end_board.emit({"path_tag": payload.get("path_tag", ""), "continuation_hint": payload.get("continuation_hint", "")})
+219 -124
View File
@@ -66,6 +66,14 @@ func _setup_and_build(params: Dictionary, seed_val: int) -> Dictionary:
## 参数档可行性判定(执行边界③:参数无解=配置错误)——域校验外加深度预算与战斗保底可放性。
static func check_params(params: Dictionary) -> Array:
var errs: Array[Dictionary] = _cp_domain(params)
if not errs.is_empty():
return errs
return _cp_structural(params)
## 域校验段:五旋钮值域/岔口去路数锁值/配比四类合计 1
static func _cp_domain(params: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L := int(params.get("layers", 0))
var e := int(params.get("exit_count", 0))
@@ -89,8 +97,16 @@ static func check_params(params: Dictionary) -> Array:
sum += float(params.get(rk, -1.0))
if not is_equal_approx(sum, 1.0):
errs.append({"category": "domain", "message": "配比四类合计 %f ≠ 1" % sum})
if not errs.is_empty():
return errs
return errs
## 结构可行性段:主链段容量与战斗保底可放性(多出口按区域签名累加可放槽)
static func _cp_structural(params: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L := int(params.get("layers", 0))
var e := int(params.get("exit_count", 0))
var en := int(params.get("entry_count", 0))
var bf := int(params.get("battle_floor", -1))
var t := 0 if e == 1 else _trunk_len(L, e)
if e > 1 and t < 1:
errs.append({"category": "structural", "message": "层深 %d 与出口数 %d 无解(主链段放不下归属区域)" % [L, e]})
@@ -116,11 +132,33 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
return errs
var by_id := {}
var indeg := {}
if not _vo_node_fields(nodes, errs, by_id, indeg):
return errs
if not _vo_edges(nodes, by_id, indeg, errs):
return errs
var flow := _vo_flow_facts(nodes, indeg, errs)
if not flow["ok"]:
return errs
if not _vo_depth_reach(nodes, by_id, flow["entries"], flow["terminals"], errs):
return errs
_vo_shop_counts(nodes, flow["terminals"], errs)
_vo_terminal_shops(nodes, flow["terminals"], errs)
if not _all_paths_one_shop(by_id, flow["entries"], flow["terminals"]):
errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"})
_check_fork_rules(nodes, by_id, errs)
_vo_terminal_depths(flow["terminals"], by_id, errs)
_vo_width_rules(nodes, errs)
_vo_params_check(params, nodes, by_id, [flow["entries"], flow["terminals"]], errs)
return errs
## 阶段一·节点字段域:字段齐备/句柄唯一/类型词表/深度非负(by_id/indeg 引用填充)
func _vo_node_fields(nodes: Array, errs: Array, by_id: Dictionary, indeg: Dictionary) -> bool:
for n in nodes:
for k in ["id", "type", "depth", "next"]:
if not (n as Dictionary).has(k):
errs.append({"category": "structural", "message": "节点字段缺失: %s" % k})
return errs
return false
var id: int = int(n["id"])
if by_id.has(id):
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % id})
@@ -130,8 +168,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if int(n["depth"]) < 0:
errs.append({"category": "domain", "message": "节点深度为负: %d" % n["depth"]})
indeg[id] = 0
if not errs.is_empty():
return errs
return errs.is_empty()
## 阶段二·边域:出边悬空与环/回边判据(indeg 引用累计)
func _vo_edges(nodes: Array, by_id: Dictionary, indeg: Dictionary, errs: Array) -> bool:
for n in nodes:
for oid in (n["next"] as Array):
if not by_id.has(int(oid)):
@@ -141,9 +182,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
errs.append({"category": "structural", "message": "连线未朝前进方向(目标深度 %d 不严格大于源 %d)——环/回边" % [
int(by_id[int(oid)]["depth"]), int(n["depth"])]})
indeg[int(oid)] = int(indeg[int(oid)]) + 1
if not errs.is_empty():
return errs
return errs.is_empty()
## 阶段三·流事实域:入口集(入度 0)/终点集(无出边)与 Boss 终点判据、去路数形态
func _vo_flow_facts(nodes: Array, indeg: Dictionary, errs: Array) -> Dictionary:
var entries := []
var terminals := []
for n in nodes:
@@ -159,11 +202,12 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
errs.append({"category": "structural", "message": "Boss 节点带出边: %d" % id0})
elif outs.size() != 1 and not (outs.size() >= 2 and outs.size() <= 3):
errs.append({"category": "domain", "message": "节点 %d 去路数 %d 非〔恰一条或岔口 2–3〕" % [id0, outs.size()]})
if not errs.is_empty():
return errs
return {"entries": entries, "terminals": terminals, "ok": errs.is_empty()}
# 深度=自入口最长路径步数(契约 2 赋值语义——出边严格前进的无环判据成立的前提)
var longest := _longest_paths(nodes, by_id, entries, indeg)
## 阶段四·深度与可达域:深度=自入口最长路径步数(契约 2)+全节点可达某终点
func _vo_depth_reach(nodes: Array, by_id: Dictionary, entries: Array, terminals: Array, errs: Array) -> bool:
var longest := _longest_paths(nodes, by_id, entries)
for n in nodes:
var id1: int = int(n["id"])
if int(n["depth"]) != int(longest.get(id1, -1)):
@@ -175,16 +219,21 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
for n in nodes:
if not reaches.has(int(n["id"])):
errs.append({"category": "structural", "message": "节点 %d 不可达任何终点(断头/孤儿)" % n["id"]})
if not errs.is_empty():
return errs
return errs.is_empty()
# 摆放约束①:每终点前一位恰一店、商店总数=终点数、终点前一层去路集合恰含一个终点
## 摆放约束①前半·商店总数:商店总数=终点数(商店数=出口数已锁)
func _vo_shop_counts(nodes: Array, terminals: Array, errs: Array) -> void:
var shop_count := 0
for n in nodes:
if String(n["type"]) == "shop":
shop_count += 1
if shop_count != terminals.size():
errs.append({"category": "structural", "message": "商店总数 %d ≠ 终点数 %d(商店数=出口数已锁)" % [shop_count, terminals.size()]})
## 摆放约束①后半·终点前驱:每终点前一位恰一店且为唯一前驱、终点前一层去路集合恰含一个终点
func _vo_terminal_shops(nodes: Array, terminals: Array, errs: Array) -> void:
for tid in terminals:
var preds := []
for n in nodes:
@@ -204,16 +253,19 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if t_hits != 1:
errs.append({"category": "structural", "message": "终点 %d 前一层去路集合含终点 %d 个(归属锁定破)" % [tid, t_hits]})
if not _all_paths_one_shop(by_id, entries, terminals):
errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"})
_check_fork_rules(nodes, by_id, errs)
## 计数规则①·多终点深度互异(推论条款 A——定位信息只有两样)
func _vo_terminal_depths(terminals: Array, by_id: Dictionary, errs: Array) -> void:
var tdepths := {}
for tid in terminals:
var dd: int = int(by_id[tid]["depth"])
if tdepths.has(dd):
errs.append({"category": "structural", "message": "多终点同深度 %d(推论条款 A 破——定位信息只有两样)" % dd})
tdepths[dd] = true
## 计数规则②·宽度上限:同深度节点数 ≤ 分叉生长上限 4(禁平行层摆放)
func _vo_width_rules(nodes: Array, errs: Array) -> void:
var per_depth := {}
for n in nodes:
var d2: int = int(n["depth"])
@@ -222,23 +274,28 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if int(per_depth[d3]) > MAX_WIDTH:
errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限 4(平行层摆放)" % [d3, per_depth[d3]]})
## 计数规则③·参数档对照:战斗保底/入口数/终点数/层高(params 为空时跳过)
func _vo_params_check(params: Dictionary, nodes: Array, by_id: Dictionary, flow_ends: Array, errs: Array) -> void:
var entries: Array = flow_ends[0]
var terminals: Array = flow_ends[1]
var battles := 0
for n in nodes:
if String(n["type"]) == "battle":
battles += 1
if not params.is_empty():
if battles < int(params.get("battle_floor", 0)):
errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]})
if entries.size() != int(params.get("entry_count", entries.size())):
errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]})
if terminals.size() != int(params.get("exit_count", terminals.size())):
errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]})
var maxd := 0
for tid in terminals:
maxd = maxi(maxd, int(by_id[tid]["depth"]))
if maxd != int(params.get("layers", maxd)):
errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]})
return errs
if params.is_empty():
return
if battles < int(params.get("battle_floor", 0)):
errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]})
if entries.size() != int(params.get("entry_count", entries.size())):
errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]})
if terminals.size() != int(params.get("exit_count", terminals.size())):
errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]})
var maxd := 0
for tid in terminals:
maxd = maxi(maxd, int(by_id[tid]["depth"]))
if maxd != int(params.get("layers", maxd)):
errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]})
# ---- 构型(长图算法 HOW 归实现——D1:28;合法性由 validate_output 强制)----
@@ -252,28 +309,11 @@ func _build(rg: RandomNumberGenerator) -> Dictionary:
var entries: Array[int] = []
for i in range(en):
entries.append(_mk("battle", 0))
var prevs: Array[int] = entries.duplicate()
for d in range(1, t + 1):
var nid := _mk("battle", d)
for p in prevs:
(_by_id[p]["next"] as Array).append(nid)
prevs = [nid]
var prevs: Array = _build_trunk(t, entries.duplicate())
var terminals: Array[int] = []
for r in range(e):
var bdepth := L - r
var bid := _mk("boss", bdepth)
var sid := _mk("shop", bdepth - 1)
(_by_id[sid]["next"] as Array).append(bid)
var cur := sid
var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle"
for d in range(bdepth - 2, t, -1):
var fid := _mk(sig, d)
(_by_id[fid]["next"] as Array).append(cur)
cur = fid
terminals.append(bid)
for p in prevs:
(_by_id[p]["next"] as Array).append(cur)
_build_region(r, e, L, prevs, terminals)
# e>1 时 prevs 收束为主链段末节点一处 ⇒ 该节点即归属大岔口(各区域互不连达);
# e==1 时 prevs 为入口数组,直接接单区域头(无大岔口——L1 出口恒 1)。
@@ -281,41 +321,61 @@ func _build(rg: RandomNumberGenerator) -> Dictionary:
_assign_types(rg, e)
var nodes: Array[Dictionary] = []
for n in _pool:
nodes.append(n)
nodes.assign(_pool)
return {"nodes": nodes, "entries": entries, "terminals": terminals}
## 主链段(深度 1..t 逐层单链,主链段零 RNG):返回链末节点集(e>1 时收束为一处即归属大岔口)
func _build_trunk(t: int, prevs: Array) -> Array:
for d in range(1, t + 1):
var nid := _mk("battle", d)
for p in prevs:
(_by_id[p]["next"] as Array).append(nid)
prevs = [nid]
return prevs
## 归属区域段(每出口一条支路:Boss 终点+店+签名填充链;区域段零 RNG):
## Boss 深度 = L−r、店在其前一位、签名链自店向主链段方向回铺,支路头接入 prevs(归属大岔口)
func _build_region(r: int, e: int, L: int, prevs: Array, terminals: Array) -> void:
var t := 0 if e == 1 else _trunk_len(L, e)
var bdepth := L - r
var bid := _mk("boss", bdepth)
var sid := _mk("shop", bdepth - 1)
(_by_id[sid]["next"] as Array).append(bid)
var cur := sid
var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle"
for d in range(bdepth - 2, t, -1):
var fid := _mk(sig, d)
(_by_id[fid]["next"] as Array).append(cur)
cur = fid
terminals.append(bid)
for p in prevs:
(_by_id[p]["next"] as Array).append(cur)
## 岔口布置:主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的节点扩为 2–3 去路岔口;
## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继,
## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。
func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
var wanted := clampi(int(round(float(_params["branch_density"]) * float(L))), 1, 24)
var anchors := []
for n in _pool:
if int(n["depth"]) > (t - 2 if e > 1 else L - 3):
continue
if (n["next"] as Array).size() != 1:
continue
anchors.append(n)
var anchors := _collect_fork_anchors(L, t, e)
anchors = _shuffle(rg, anchors)
var selected := {} # 已扩岔节点 id
var succ_selected := {} # 已扩岔节点的主去路 id(禁再作锚点)
for a in anchors:
if wanted <= 0:
break
var node = a
var nid := int(node["id"])
if (node["next"] as Array).size() != 1:
var nid := int(a["id"])
if (a["next"] as Array).size() != 1:
continue # 同一节点不重复扩岔(去路数上限 3)
var v = _by_id[int((node["next"] as Array)[0])]
var v = _by_id[int((a["next"] as Array)[0])]
var vid := int(v["id"])
# 相邻两层不得同时扩岔:若主去路 v 也是岔口,v 的侧支会落进本岔口主支路的互斥段,
# 使侧支自己的类型不再独有(契约 5③)——两个方向都要拦。
if selected.has(vid) or succ_selected.has(nid):
continue
var v_outs = v["next"]
if v_outs.is_empty():
if (v["next"] as Array).is_empty():
continue # 主去路无后继(无可汇入点)——该锚点弃用
var extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < 0.35) else 1
var made := 0
@@ -323,8 +383,8 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
if _depth_count(int(v["depth"])) >= MAX_WIDTH:
break
var wid := _mk("battle", int(v["depth"]))
(node["next"] as Array).append(wid)
(_by_id[wid]["next"] as Array).append(int(v_outs[0]))
(a["next"] as Array).append(wid)
(_by_id[wid]["next"] as Array).append(int((v["next"] as Array)[0]))
_detour_heads.append(_by_id[wid])
_detour_ids[wid] = true
made += 1
@@ -334,6 +394,18 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
wanted -= 1
## 锚收集段(零 RNG):主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的去路恰一节点
func _collect_fork_anchors(L: int, t: int, e: int) -> Array:
var anchors := []
for n in _pool:
if int(n["depth"]) > (t - 2 if e > 1 else L - 3):
continue
if (n["next"] as Array).size() != 1:
continue
anchors.append(n)
return anchors
# 确定性洗牌(Fisher-Yates,取数只经注入的纯流 RNG——Array.shuffle() 走引擎全局随机、
# 破「同(种子,参数档)⇒ 同图」的纯函数复现前提,禁用于生成器)
func _shuffle(rg: RandomNumberGenerator, arr: Array) -> Array:
@@ -358,31 +430,42 @@ func _depth_count(d: int) -> int:
## 单出口图的配比填余(摆放优先、配比填余、战斗保底兜底——§8.1 次序),再统一修岔口下一跳互异。
func _assign_types(rg: RandomNumberGenerator, e: int) -> void:
if e == 1:
var free: Array[Dictionary] = []
var chain_battles := 0
for n in _pool:
if String(n["type"]) == "shop" or String(n["type"]) == "boss":
continue
if _detour_ids.has(int(n["id"])):
continue # 侧链节点类型由岔口互异修型决定,不占配比填余面
if int(n["depth"]) == 1:
continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形
if String(n["type"]) == "battle":
chain_battles += 1
free.append(n)
free = _shuffle(rg, free)
var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"]))))
var floor_n := int(_params["battle_floor"])
var rest_first := rg.randf() < 0.5
for i in range(want):
if i >= free.size():
break
# 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上)
if chain_battles - 1 < floor_n:
break
var node = free[i]
node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward"
chain_battles -= 1
_fill_ratios(rg)
_fix_detour_heads()
## 配比填余段(单出口图;shuffle+randf 取号序整体搬迁——战斗保底优先、侧链不占填余面):
## 摆放优先、配比填余、战斗保底兜底——§8.1 次序
func _fill_ratios(rg: RandomNumberGenerator) -> void:
var free: Array[Dictionary] = []
var chain_battles := 0
for n in _pool:
if String(n["type"]) == "shop" or String(n["type"]) == "boss":
continue
if _detour_ids.has(int(n["id"])):
continue # 侧链节点类型由岔口互异修型决定,不占配比填余面
if int(n["depth"]) == 1:
continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形
if String(n["type"]) == "battle":
chain_battles += 1
free.append(n)
free = _shuffle(rg, free)
var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"]))))
var floor_n := int(_params["battle_floor"])
var rest_first := rg.randf() < 0.5
for i in range(want):
if i >= free.size():
break
# 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上)
if chain_battles - 1 < floor_n:
break
var node = free[i]
node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward"
chain_battles -= 1
## 岔口修型段(零 RNG):侧链首节点类型取岔口各去路下一节点类型的补集首件——下一跳互异承面
func _fix_detour_heads() -> void:
for head in _detour_heads:
var h = head
var fork = null
@@ -416,12 +499,15 @@ static func _trunk_len(L: int, e: int) -> int:
# ---- 自证辅助 ----
func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array, indeg: Dictionary) -> Dictionary:
func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array) -> Dictionary:
var out := {}
var din := {}
var q := []
for id in indeg:
din[int(id)] = int(indeg[int(id)])
for n in nodes:
din[int(n["id"])] = 0
for n in nodes:
for oid in (n["next"] as Array):
din[int(oid)] = int(din[int(oid)]) + 1
for eid in entries:
out[int(eid)] = 0
q.append(int(eid))
@@ -476,44 +562,53 @@ func _check_fork_rules(nodes: Array, by_id: Dictionary, errs: Array) -> void:
if head_types.has(ty):
errs.append({"category": "structural", "message": "岔口 %d 各去路下一节点类型重复: %s" % [n["id"], ty]})
head_types[ty] = true
var reach_sets := []
for oid in outs:
reach_sets.append(_descendants(int(oid), by_id))
var comps := []
for i in range(reach_sets.size()):
var excl := {}
for rid in reach_sets[i]:
var shared := false
for j in range(reach_sets.size()):
if j != i and reach_sets[j].has(rid):
shared = true
break
if not shared:
excl[int(rid)] = String(by_id[int(rid)]["type"])
var arr := []
for v in excl.values():
arr.append(v)
arr.sort()
comps.append(arr)
var comps := _fork_signatures(outs, by_id)
for i in range(comps.size()):
for j in range(i + 1, comps.size()):
if comps[i] == comps[j]:
errs.append({"category": "structural", "message": "岔口 %d 支路 %d/%d 类型构成相同" % [n["id"], i, j]})
for i in range(comps.size()):
var uniq := false
for ty in comps[i]:
var in_other := false
for j in range(comps.size()):
if j != i and (comps[j] as Array).has(ty):
in_other = true
break
if not in_other:
uniq = true
break
if not uniq:
if not _fork_has_unique(comps, i):
errs.append({"category": "structural", "message": "岔口 %d 支路 %d 无独有类型" % [n["id"], i]})
## 支路签名构造:各去路的独占后代类型集(他人不可达的后代类型,排序数组——支路互斥段签名)
func _fork_signatures(outs: Array, by_id: Dictionary) -> Array:
var reach_sets := []
for oid in outs:
reach_sets.append(_descendants(int(oid), by_id))
var comps := []
for i in range(reach_sets.size()):
var excl := {}
for rid in reach_sets[i]:
var shared := false
for j in range(reach_sets.size()):
if j != i and reach_sets[j].has(rid):
shared = true
break
if not shared:
excl[int(rid)] = String(by_id[int(rid)]["type"])
var arr := []
for v in excl.values():
arr.append(v)
arr.sort()
comps.append(arr)
return comps
## 独有类型判定:支路 i 的签名中存在他支路不含的类型
func _fork_has_unique(comps: Array, i: int) -> bool:
for ty in comps[i]:
var in_other := false
for j in range(comps.size()):
if j != i and (comps[j] as Array).has(ty):
in_other = true
break
if not in_other:
return true
return false
func _descendants(start: int, by_id: Dictionary) -> Dictionary:
var out := {start: true}
var q := [start]
+2 -2
View File
@@ -81,8 +81,8 @@ func reset_paid() -> void:
# 本次 GP 付费刷新价(与商店货架刷新同源:ceil(基价 × 曲线^本次停点已付费刷))
func gp_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(_paid_refreshes))))
return int(ceil(float(db.run.adventure.shop_refresh_cost_base)
* pow(db.run.adventure.shop_refresh_cost_curve, float(_paid_refreshes))))
# 冒险级复位(宿主 reset_adventure_state 调用——刷新三源与付费计数全零)
+40 -24
View File
@@ -40,18 +40,8 @@ func roll_board(eligible: Array) -> bool:
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)")
return false
# 行主序规范化 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key)
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
_eligible = list
_eligible = _normalize_eligible(eligible)
var list: Array[Dictionary] = _eligible
_granted.clear() # 新批重置映射受理面(旧图快照消亡)
_power_roll.clear()
_clear_power = -1
@@ -85,6 +75,21 @@ func roll_board(eligible: Array) -> bool:
return true
## 资格清单规范化域(开流前——零 RNG):行主序 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key)
func _normalize_eligible(eligible: Array) -> Array[Dictionary]:
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
return list
# ---- 查询面(Ready 只读;未开格恒可查——秘宝 桥接结构前提)----
# 相位只读出口(诊断观测面——unrolled/ready/retired;消费方禁直读 _phase 下划线字段)
@@ -167,7 +172,7 @@ func retire_mapping() -> bool:
# ---- 极性路由字典面(战斗结算 授予段消费):(类型, 极性)→ 格面池字典行(拷贝出口)----
func polarity_row(cell_type: String, polarity: String) -> Dictionary:
for row in db.reward.rows:
for row in db.run.reward.rows:
if String(row.get("pool_tag", "")) == "cell" \
and String(row.get("cell_type", "")) == cell_type \
and String(row.get("polarity", "")) == polarity:
@@ -217,6 +222,20 @@ func _skewed_cell_rows() -> Array:
if board_skew <= 0:
return _rollable_rows()
var base: Array[Variant] = _rollable_rows()
var families := _skew_families(base)
if families["down"].is_empty() or families["up"].is_empty():
return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批)
# 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局)
var drops := _largest_remainder_split(families["down"], board_skew, 100, (2 * _pool_weight(families["down"]) * board_skew + 100) / 200)
# 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整
var gains := _largest_remainder_split(families["up"], drops_total(drops), _pool_weight(families["up"]), drops_total(drops))
var out := _apply_skew(base, families["down"], families["up"], drops, gains)
assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)")
return out
## 分族域:生存下调族与进攻上浮族行集拆分(池结构不变式零触碰)
func _skew_families(base: Array) -> Dictionary:
var down: Array[Variant] = []
var up: Array[Variant] = []
for row in base:
@@ -225,13 +244,11 @@ func _skewed_cell_rows() -> Array:
down.append(row)
elif SKEW_UP_KINDS.has(k):
up.append(row)
if down.is_empty() or up.is_empty():
return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批)
var v := board_skew
# 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局)
var drops := _largest_remainder_split(down, v, 100, (2 * _pool_weight(down) * v + 100) / 200)
# 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整
var gains := _largest_remainder_split(up, drops_total(drops), _pool_weight(up), drops_total(drops))
return {"down": down, "up": up}
## 权重装配域:逐行精确份额增减(下调族扣 drops、上浮族加 gains——守恒 assert 随段迁移)
func _apply_skew(base: Array, down: Array, up: Array, drops: Array, gains: Array) -> Array:
var di := 0
var ui := 0
var out: Array[Variant] = []
@@ -248,7 +265,6 @@ func _skewed_cell_rows() -> Array:
var skewed: Dictionary[Variant, Variant] = row.duplicate()
skewed["weight"] = w
out.append(skewed)
assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)")
return out
@@ -309,7 +325,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void:
func _rollable_rows() -> Array:
# 摇号池 = cell 池正向行 weight>0(负向行纯字典不消费权重;封锁格不在本函数职责域——清单由调用方裁剪)
var out: Array[Dictionary] = []
for row in db.reward.rows:
for row in db.run.reward.rows:
if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE:
var iid := String(row.get("effect_id", ""))
if iid == "" or not _treasure_unlocked.has(iid):
@@ -321,7 +337,7 @@ func _rollable_rows() -> Array:
func _pool_rows(pool_tag: String) -> Array:
var out: Array[Dictionary] = []
for row in db.reward.rows:
for row in db.run.reward.rows:
if row.pool_tag == pool_tag and int(row.weight) > 0:
out.append(row)
return out
@@ -335,7 +351,7 @@ func _pool_weight(rows: Array) -> int:
func _row_by_id(rid: String) -> Dictionary:
for row in db.reward.rows:
for row in db.run.reward.rows:
if row.reward_id == rid:
return row
return {}
+4 -4
View File
@@ -120,7 +120,7 @@ func _charge_gate(v: Dictionary, target: Vector2i) -> Dictionary:
for h in tu_hits:
if String(h.get("kind", "")) == "counter_spend":
if not EffectEngine.counter_spend(fx_host.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 1))):
var vrow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.item_id))
var vrow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.item_id))
var cn_info: int = int(vrow.get("charge_n", 0)) # 读受理行自身
var seen := int(attune_safe_seen_cb.call())
return {"ok": false, "reason": "%s:安全格积点未达(%d/%d)" % [String(vrow.get("display_name", "")), seen % cn_info if cn_info > 0 else 0, cn_info]}
@@ -199,8 +199,8 @@ func grant_routed(snap: Dictionary, polarity: String, att: Dictionary) -> bool:
# premium_pct 贯穿 _apply_percent_row 应用与呈现同乘)——标空/挖格/清图不乘,雷安同池冻结不动。
# 取整=四舍五入(与伤害管线 round_direction=floor 分域——registry clause3_inequality 登记;
# 1 点量纲行 ac_up/action 取整后不变=整数粒度地板)。
if source_class == "seal_hit" and int(db.settlement.core.seal_hit_premium) != 100:
var prem_pct: int = int(db.settlement.core.seal_hit_premium)
if source_class == "seal_hit" and int(db.combat.settlement.core.seal_hit_premium) != 100:
var prem_pct: int = int(db.combat.settlement.core.seal_hit_premium)
row = row.duplicate()
row["power"] = int(round(int(row.power) * prem_pct / 100.0))
row["premium_pct"] = prem_pct
@@ -229,7 +229,7 @@ func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool:
var eff := String(row.effect_id)
if eff == "gp_gain" or eff == "gp_loss":
return _apply_gp_row(row, att)
var srow: Dictionary[Variant, Variant] = db.status_effect_row(eff)
var srow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff)
# 封印溢价贯穿(仅封印命中正向行携带 premium_pct;其余授予路径缺省 100 零扰动):
# 应用幅度与呈现行值同乘,容器经 magnitude_override 拿同一快照(抗表重载口径保持)。
var prem_pct: int = int(row.get("premium_pct", 100))
+143 -519
View File
@@ -4,6 +4,8 @@
## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。
## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。
## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。
## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore
## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。
class_name SaveManagerCore
extends Node
@@ -18,18 +20,18 @@ const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派
const LEGACY_SAVE_PATH := "user://save.json" # 旧隐式单档路径(Q1 甲:首次启动一次性字节迁入槽 1,此后无任何隐式读写)
const LEGACY_MIGRATED_SUFFIX := ".migrated.bak" # 迁移后旧档改名留痕后缀(字节级留盘不删——处置留痕)
var _path: String # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝
var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准)
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v6 生产迁移链现行——_init 自注册)
var _slot_template := SLOT_PATH_TEMPLATE # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名)
var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89)
var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90)
var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点)
func _init(path: String = "") -> void:
_path = path
_active_slot = _slot_index_of(path)
_data = _default_data()
slots = SlotStore.new(self, path)
meta_state = MetaStateStore.new(self)
# 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤)
register_migration(1, _migrate_v1_v2)
register_migration(2, _migrate_v2_v3)
@@ -43,176 +45,103 @@ func _init(path: String = "") -> void:
# 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready,
# 读盘仍由调用方显式触发。
# 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、
# 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。
# 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。
# 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。
func _ready() -> void:
_migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX)
slots._migrate_legacy_save(LEGACY_SAVE_PATH, slots.slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX)
# 原地重置(探针隔离面,仿 RngService.set_master_seed 单例原地重置:
# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)。
# 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活);
# 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。
func attach_path(path: String) -> void:
_path = path
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册),
# 冒烟经本口验证链式升级。
func register_migration(from_version: int, fn: Callable) -> void:
_migrations[from_version] = fn
# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)----
# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
func load_from_disk() -> bool:
_read_only = false
_active_slot = _slot_index_of(path)
_data = _default_data()
load_from_disk()
# 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间,
# 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间)
func attach_slot_template(template: String) -> void:
_slot_template = template
_path = ""
_active_slot = 0
_read_only = false
_data = _default_data()
# ---- 三存档位承载(卡①:隐式单档 → 3 存档位;位数与文件名口径=SLOT_COUNT/SLOT_PATH_TEMPLATE)----
# 槽文件路径唯一派生口(模板单点——禁第二处拼名)
func slot_path(slot: int) -> String:
return _slot_template % slot
# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档,卡③ Q1 甲)
func active_slot() -> int:
return _active_slot
# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口)
func has_write_target() -> bool:
return _path != ""
# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。
# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽;
# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。
func activate_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro := _read_only
var prev_data := _data
_path = slot_path(slot)
_active_slot = slot
_read_only = false
_data = _default_data()
if not load_from_disk():
_path = prev_path
_active_slot = prev_slot
_read_only = prev_ro
_data = prev_data
return false
return true
# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。
# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档,卡③ 契约 4);写盘失败回退切换前三态。
func create_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro := _read_only
var prev_data := _data
_path = slot_path(slot)
_active_slot = slot
_read_only = false
_data = _default_data()
if not write_to_disk():
_path = prev_path
_active_slot = prev_slot
_read_only = prev_ro
_data = prev_data
return false
return true
# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效(卡内工程规定,挪用现役断点覆盖流语义)
# +无 .tmp 残留;删激活槽时激活指针连清除(内存回默认空态,该槽不再可写——激活态生命周期终点)。
# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。
# 删不存在文件的空槽=幂等成功;越界槽号拒绝。
func delete_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var p := slot_path(slot)
if FileAccess.file_exists(p) and not _remove_file(p):
push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p)
return false
var tmp := p + ".tmp"
if FileAccess.file_exists(tmp):
_remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义)
if _active_slot == slot:
_path = ""
_active_slot = 0
_data = _default_data()
return true
# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。
# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。
# 返回 {"readable", "has_file", "keystone", "cleared", "total"}——readable=true=可读槽(空槽=零值合法态,
# .tmp 残留按空槽——主文件不在即空);readable=false=不可读槽(损坏/高版本/缺版本字段/打开失败,
# 三值无意义——卡③ Q2 裁甲:UI 按空槽呈现,进档时走正式 load 兜底/拒读链);
# has_file=主文件在盘与否(空/有档操作集二分判定来处——卡③ T0 对齐结论;非摘要第四项,零 UI 语义)。
# 迁移链在此只读推演(生产迁移 Callable 为纯函数,零盘面副作用)。
func peek_slot_summary(slot: int) -> Dictionary:
if slot < 1 or slot > SLOT_COUNT:
return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽
return _peek_summary_at(slot_path(slot))
# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名)
func peek_summary_at(path: String) -> Dictionary:
return _peek_summary_at(path)
func _peek_summary_at(path: String) -> Dictionary:
var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0}
if not FileAccess.file_exists(path):
out["readable"] = true # 空槽=可读零值合法态
return out
out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分)
var f := FileAccess.open(path, FileAccess.READ)
if not FileAccess.file_exists(slots._path):
_data = _default_data() # 新档合法态
return true
var f := FileAccess.open(slots._path, FileAccess.READ)
if f == null:
return out
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
return _load_parsed(f)
## 读盘解析域(load_from_disk 主体段提取——解析/版本/迁移/规整;corrupt_fallback 调用点随段迁移,副作用序逐字)
func _load_parsed(f: FileAccess) -> bool:
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"):
return out
if typeof(parsed) != TYPE_DICTIONARY:
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
if not parsed.has("version"):
return _corrupt_fallback("缺版本字段")
var v := int(parsed["version"])
if v > SAVE_FORMAT_VERSION:
return out
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
return out
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
return out
parsed = stepped
v = int(parsed.get("version", v + 1))
var norm := _normalize(parsed)
out["readable"] = true
out["keystone"] = int(norm["meta"].get("keystone_balance", 0))
out["cleared"] = int(norm["meta"].get("adventure_cleared", 0))
out["total"] = int(norm["meta"].get("adventure_total", 0))
return out
_read_only = true
_data = _default_data()
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
return false
var fail := []
var migrated = _peek_migrated(parsed, fail)
if migrated == null:
return _corrupt_fallback(fail[0])
_data = _normalize(migrated)
# 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本)
if not _data["meta"].has("first_adventure_done"):
_data["meta"]["first_adventure_done"] = true
return true
# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准)
func _slot_index_of(path: String) -> int:
for i in range(1, SLOT_COUNT + 1):
if path == slot_path(i):
return i
return 0
# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)----
# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰);
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
func write_to_disk() -> bool:
if slots._path == "":
push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)")
return false
if _read_only:
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
return false
var tmp := slots._path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
return false
f.store_string(JSON.stringify(_data, "\t", true))
f.close()
if not _rename_overwrite(tmp, slots._path):
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, slots._path])
return false
return true
# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)----
# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。
func set_breakpoint(bp: Dictionary) -> void:
_data["breakpoint"] = bp.duplicate(true)
func clear_breakpoint() -> void:
_data["breakpoint"] = {}
func has_breakpoint() -> bool:
return not (_data.get("breakpoint", {}) as Dictionary).is_empty()
func breakpoint_snapshot() -> Dictionary:
return (_data.get("breakpoint", {}) as Dictionary).duplicate(true)
# ---- 私有:迁移链 / 损坏兜底 / 规整 / 文件原语 ----
# v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务)
func _migrate_v1_v2(data: Dictionary) -> Dictionary:
if not data.has("breakpoint"):
@@ -266,6 +195,53 @@ func _migrate_v6_v7(data: Dictionary) -> Dictionary:
return data
# 迁移循环提取(load/peek 两调用方同构共享):成功=迁移完成 Dictionary;失败=null
# (原因经 fail 数组带出——load 侧落 corrupt 兜底文案、peek 侧保持「返回未填充 out」逐字)
func _peek_migrated(parsed: Variant, fail: Array) -> Variant:
var v := int(parsed["version"])
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
fail.append("迁移链缺步骤 %d→%d" % [v, v + 1])
return null
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
fail.append("迁移步骤 %d→%d 执行失败" % [v, v + 1])
return null
parsed = stepped
v = int(parsed.get("version", v + 1))
return parsed
## 摘要窥视内核(只读推演单点——SlotStore 两 peek 方法经槽路径派生委托至此;
## 迁移链在此只读推演——生产迁移 Callable 为纯函数,零盘面副作用)
func _peek_summary_at(path: String) -> Dictionary:
var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0}
if not FileAccess.file_exists(path):
out["readable"] = true # 空槽=可读零值合法态
return out
out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分)
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return out
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"):
return out
if int(parsed["version"]) > SAVE_FORMAT_VERSION:
return out
var fail := []
var migrated = _peek_migrated(parsed, fail)
if migrated == null:
return out
var norm := _normalize(migrated)
out["readable"] = true
out["keystone"] = int(norm["meta"].get("keystone_balance", 0))
out["cleared"] = int(norm["meta"].get("adventure_cleared", 0))
out["total"] = int(norm["meta"].get("adventure_total", 0))
return out
# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)----
func _default_data() -> Dictionary[String, Variant]:
@@ -299,368 +275,15 @@ func _default_data() -> Dictionary[String, Variant]:
}
# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)----
# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板);
# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。
# 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据)
func _reset_to_default() -> void:
_data = _default_data()
func is_first_adventure_done() -> bool:
return bool(_data["meta"].get("first_adventure_done", true))
func mark_first_adventure_done() -> void:
_data["meta"]["first_adventure_done"] = true
# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)----
# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
func load_from_disk() -> bool:
_read_only = false
if not FileAccess.file_exists(_path):
_data = _default_data() # 新档合法态
return true
var f := FileAccess.open(_path, FileAccess.READ)
if f == null:
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
if not parsed.has("version"):
return _corrupt_fallback("缺版本字段")
var v := int(parsed["version"])
if v > SAVE_FORMAT_VERSION:
_read_only = true
_data = _default_data()
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
return false
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1])
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1])
parsed = stepped
v = int(parsed.get("version", v + 1))
_data = _normalize(parsed)
# 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本)
if not _data["meta"].has("first_adventure_done"):
_data["meta"]["first_adventure_done"] = true
return true
# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)----
# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰);
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
func write_to_disk() -> bool:
if _path == "":
push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)")
return false
if _read_only:
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
return false
var tmp := _path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
return false
f.store_string(JSON.stringify(_data, "\t", true))
f.close()
if not _rename_overwrite(tmp, _path):
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path])
return false
return true
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册),
# 冒烟经本口验证链式升级。
func register_migration(from_version: int, fn: Callable) -> void:
_migrations[from_version] = fn
# ---- 类型化域访问器(消费面——只经访问器操作,禁碰内部结构)----
func keystone_balance() -> int:
return int(_data["meta"]["keystone_balance"])
func add_keystone(n: int) -> void:
_data["meta"]["keystone_balance"] = keystone_balance() + n
# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据)
func spend_keystone(n: int) -> bool:
if keystone_balance() < n:
return false
_data["meta"]["keystone_balance"] = keystone_balance() - n
return true
func item_unlocked(item_id: String) -> bool:
return _data["meta"]["item_unlocks"].has(item_id)
func unlock_item(item_id: String) -> void:
_data["meta"]["item_unlocks"][item_id] = true
func clear_count(level_index: int) -> int:
return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0))
func boss_first_kill_marked(level_index: int) -> bool:
return _data["meta"]["boss_first_kill"].has(str(level_index))
# ---- 同调位账本域(里程碑谓词表计数源 + 装配关系 + Y 升级态)----
# Boss 击杀总数(含重复——底稿裁决 4 计数源;收束入账 = apply_adventure_summary 逐关卡完成关次 +1,
# Boss 恒唯一终点故 levels_cleared 即本次冒险 Boss 击杀数)
func boss_kill_count() -> int:
return int(_data["meta"].get("boss_kill_count", 0))
# ---- 生涯终身计数域(跨局汇总页数据源;只读面经访问器,
# 写入唯一经 apply_adventure_summary 收束口,禁第二写入点)----
# 冒险总局数(口径=开局即计:三结局 completed/failed/abandoned 同经收束恰计一次,含放弃与失败)
func adventure_total() -> int:
return int(_data["meta"].get("adventure_total", 0))
# 冒险成功局数(口径=三关全通才计 1——判定字段 summary.cleared,adventure_runner「completed」
# 唯一出口=末关的关卡完成)
func adventure_cleared() -> int:
return int(_data["meta"].get("adventure_cleared", 0))
# 真视类工具累计使用次数(信标网络解锁行为条件;写入唯一经 add_vision_used
# 冒险收束聚合入账,受理计数点=battle 层经冒险层聚合)
func vision_used() -> int:
return int(_data["meta"].get("vision_used", 0))
func add_vision_used(n: int) -> void:
_data["meta"]["vision_used"] = maxi(0, vision_used() + maxi(0, n))
# ---- 天赋树域(MetaProgression 树段读写面)----
func tree_nodes() -> Dictionary:
return (_data["meta"].get("tree_nodes", {}) as Dictionary).duplicate()
func tree_level(node_id: String) -> int:
return int((_data["meta"].get("tree_nodes", {}) as Dictionary).get(node_id, 0))
func set_tree_level(node_id: String, level: int) -> void:
var tn: Dictionary[Variant, Variant] = _data["meta"].get("tree_nodes", {}) as Dictionary
tn[node_id] = maxi(0, level)
_data["meta"]["tree_nodes"] = tn
func fork_sides() -> Dictionary:
return (_data["meta"].get("fork_sides", {}) as Dictionary).duplicate()
func fork_side(group: String) -> String:
return String((_data["meta"].get("fork_sides", {}) as Dictionary).get(group, ""))
func set_fork_side(group: String, side: String) -> void:
var fs: Dictionary[Variant, Variant] = _data["meta"].get("fork_sides", {}) as Dictionary
fs[group] = side
_data["meta"]["fork_sides"] = fs
# ---- 锻造工坊域 forge_routes(MetaProgression 锻造写口读写面;无特效域)----
# 解锁态不落档(派生):初始持有=表 initial_owned、练满前一档=levels[prereq]≥cap——判定单点在
# MetaProgression.forge_route_unlocked(读表+读档);本类只承载直购清单 unlocked 与选中标记。
# 域结构:{levels:{六路线 id→等级}, unlocked:[直购路线 id], weapon:当前武器路线 id, armor:当前甲路线 id}
func _forge_domain() -> Dictionary:
return _data["meta"].get("forge_routes", {}) as Dictionary
func forge_routes_snapshot() -> Dictionary:
return _forge_domain().duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态)
func forge_route_level(route_id: String) -> int:
var lv: Dictionary[Variant, Variant] = _forge_domain().get("levels", {}) as Dictionary
return int(lv.get(route_id, 0))
func set_forge_route_level(route_id: String, level: int) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary
lv[route_id] = maxi(0, level)
dom["levels"] = lv
_data["meta"]["forge_routes"] = dom
func forge_unlocked_purchases() -> Array:
return (_forge_domain().get("unlocked", []) as Array).duplicate()
func add_forge_unlock(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var un: Array[Variant] = dom.get("unlocked", []) as Array
if not un.has(route_id):
un.append(route_id)
dom["unlocked"] = un
_data["meta"]["forge_routes"] = dom
func forge_weapon() -> String:
return String(_forge_domain().get("weapon", "sword"))
func set_forge_weapon(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["weapon"] = route_id
_data["meta"]["forge_routes"] = dom
func forge_armor() -> String:
return String(_forge_domain().get("armor", "light"))
func set_forge_armor(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["armor"] = route_id
_data["meta"]["forge_routes"] = dom
# 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。
# 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——口径见 存档序列化.md 契约 2。
func attunement_equipped() -> Array:
return (_data["meta"].get("attunement_equipped", []) as Array).duplicate()
func set_attunement_equipped(item_ids: Array) -> void:
var out: Array[Variant] = []
for v in item_ids:
out.append(String(v))
_data["meta"]["attunement_equipped"] = out
# Y 刷新升级购买等级(0 = 未购 → 每冒险刷新次数 0;等级行值归营地升级行族)
func attune_refresh_level() -> int:
return int(_data["meta"].get("attune_refresh_level", 0))
func set_attune_refresh_level(n: int) -> void:
_data["meta"]["attune_refresh_level"] = maxi(0, n)
# 同调位计数源组装(Attunement 服务取数口——clear_count = 逐关完成次数合计〔含重复关,底稿 §2.1 口径〕;
# clear_count(level_index) 逐关访问器零改动复用)
func recent_summaries() -> Array:
return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态)
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为
# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计:
# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入
# 单口=本函数,禁第二写入点)。
func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void:
_data["meta"]["adventure_total"] = adventure_total() + 1
if bool(summary.get("cleared", false)):
_data["meta"]["adventure_cleared"] = adventure_cleared() + 1
add_keystone(int(summary.get("keystone_total", 0)))
var cleared := int(summary.get("levels_cleared", 0))
for li in range(1, cleared + 1):
var key := str(li)
_data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1
_data["meta"]["boss_first_kill"][key] = true
_data["meta"]["boss_kill_count"] = boss_kill_count() + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复)
var entry: Dictionary[Variant, Variant] = summary.duplicate(true)
entry["boss_drops"] = boss_drops.duplicate(true)
var arr: Array[Variant] = _data["meta"]["recent_summaries"]
arr.push_front(entry)
while arr.size() > SUMMARY_CAP:
arr.pop_back()
# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)----
# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。
func set_breakpoint(bp: Dictionary) -> void:
_data["breakpoint"] = bp.duplicate(true)
func clear_breakpoint() -> void:
_data["breakpoint"] = {}
func has_breakpoint() -> bool:
return not (_data.get("breakpoint", {}) as Dictionary).is_empty()
func breakpoint_snapshot() -> Dictionary:
return (_data.get("breakpoint", {}) as Dictionary).duplicate(true)
# ---- 私有:损坏兜底与文件原语 ----
# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本
# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据);
# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名
# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。
# tmp+rename 原子形态与 write_to_disk 同纪律。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。
func _migrate_legacy_save(src: String, dst: String, bak: String) -> void:
if not FileAccess.file_exists(src):
return # 执行前置:旧档不存在=无操作
if FileAccess.file_exists(dst):
return # 幂等判据:槽 1 已存在不重迁
var tmp := dst + ".tmp"
if not _copy_file(src, tmp):
push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp])
return
if not _rename_overwrite(tmp, dst):
push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst])
return
_rename_overwrite(src, bak)
push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak])
# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造)
func _copy_file(from: String, to: String) -> bool:
var src := FileAccess.open(from, FileAccess.READ)
if src == null:
return false
var dst := FileAccess.open(to, FileAccess.WRITE)
if dst == null:
src.close()
return false
while not src.eof_reached():
var chunk := src.get_buffer(65536)
if chunk.size() > 0:
dst.store_buffer(chunk)
src.close()
dst.close()
return true
# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点)
func _remove_file(p: String) -> bool:
var dir := DirAccess.open(p.get_base_dir())
if dir == null:
return false
return dir.remove(p.get_file()) == OK
# 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。
func _corrupt_fallback(reason: String) -> bool:
var bak := _path + ".corrupt.bak"
if not _rename_overwrite(_path, bak):
var bak := slots._path + ".corrupt.bak"
if not _rename_overwrite(slots._path, bak):
push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason])
else:
push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason])
@@ -689,7 +312,8 @@ func _normalize(data: Dictionary) -> Dictionary[String, Variant]:
return merged
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义)
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点——
# SlotStore 迁移流经本件调用)
func _rename_overwrite(from: String, to: String) -> bool:
var dir := DirAccess.open(from.get_base_dir())
if dir == null:
+2 -2
View File
@@ -78,7 +78,7 @@ func _enemy_actions(ri: int) -> void:
if int(oc._enemy_system.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
var skill_id := oc._enemy_system.pending_action(u)
var srow: Dictionary[Variant, Variant] = oc.db.skill_row(skill_id)
var srow: Dictionary[Variant, Variant] = oc.db.combat.skill.row_by_id(skill_id)
if srow.is_empty():
oc._fatal("敌实例待发动作悬空: %s" % skill_id)
continue
@@ -97,7 +97,7 @@ func _enemy_actions(ri: int) -> void:
var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0))))
var rider := 0
if srow.has("rider_mult"):
rider = maxi(int(oc.db.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
rider = maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
var et := oc._damage.open_ticket(DamageController.KIND_ENEMY_SKILL, "enemy_action",
"%s %s" % [u.display_name, String(srow.display_name)],
{"channel": "combat", "base_value": baseline, "target_rule": DamageController.TARGET_HERO,
+2 -2
View File
@@ -147,7 +147,7 @@ func _reveal_mine_apply(mh_hits: Array, snap: Dictionary, cell: Vector2i, ri: in
if fx_host._fx_bonus_delta.has(axis):
fx_host._fx_bonus_delta[axis] += int(h.get("delta", 0))
"blast_scaled":
var blast_v := maxi(int(oc.db.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1))))
var blast_v := maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1))))
var bs_t := oc._damage.open_ticket(DamageController.KIND_BOARD_BLAST, "feat_blast_scaled", "陷阱爆发",
{"channel": "board", "base_value": blast_v, "target_rule": DamageController.TARGET_ALL_ALIVE,
"ri": ri, "origin_cell": cell})
@@ -163,7 +163,7 @@ func _terrain_batch(ri: int) -> void:
for evt in oc._facts_reveals:
var vids: Array[Variant] = evt.get("variant_ids", [])
for vid in vids:
var vrow: Dictionary[Variant, Variant] = oc.db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = oc.db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) != "flat_damage":
continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面)
# 地形变异伤害单据——无类型必中,下限保底在控制器管线内
+16 -16
View File
@@ -34,7 +34,7 @@ func bind(refs: Dictionary, hooks: Dictionary) -> void:
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
func shop_products() -> Array:
var out: Array[Dictionary] = []
for prow in db.shop_products():
for prow in db.run.shop.product_rows():
out.append({
"product_id": String(prow.get("product_id", "")),
"display_name": String(prow.get("display_name", "")),
@@ -49,7 +49,7 @@ func shop_products() -> Array:
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
# (散折价:economy_spend 激活时商品标价 ×80%——见 _spend_discounted)
func current_price(prow: Dictionary) -> int:
return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(_level_index()) \
return _spend_discounted(int(prow.get("base_price", 0)) + db.run.shop.price_level_bonus_at(_level_index()) \
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
@@ -58,7 +58,7 @@ func current_price(prow: Dictionary) -> int:
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
func _spend_discounted(price: int) -> int:
if _rules().has("economy_spend"):
return maxi(1, int(floor(float(price) * float(db.shop.spend_discount))))
return maxi(1, int(floor(float(price) * float(db.run.shop.spend_discount))))
return price
@@ -69,7 +69,7 @@ func purchased_tier(product_id: String) -> int:
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
func buy_product(product_id: String) -> bool:
var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id)
var prow: Dictionary[Variant, Variant] = db.run.shop.product_row(product_id)
if prow.is_empty():
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
return false
@@ -133,7 +133,7 @@ func leave() -> void:
func _draw_shelf() -> void:
shelf = []
var sellable: Array[Variant] = []
for row in db.shop_pool_rows():
for row in db.run.shop.pool_rows():
var shelf_kind := String(row.get("kind_id", ""))
if _pool_cap.is_valid() and not bool(_pool_cap.call(shelf_kind, "")):
continue # cap 满剔除(货架候选视图剔除;牌库本体不动)
@@ -184,14 +184,14 @@ func pool_stock(kind: String) -> int:
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
# 基价 provisional 15(表 knob relic_base_price——shop.tres 词表扩列归数值批)。
func pool_price(kind: String) -> int:
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind)
if row.is_empty():
if kind == "relic":
return _spend_discounted(int(ceil(float(db.shop.relic_base_price) * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) * pow(db.shop_price_a(), float(visit_buys)))))
return _spend_discounted(int(ceil(float(db.run.shop.relic_base_price) * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) * pow(db.run.shop.price_a, float(visit_buys)))))
return 999999
var base := float(maxi(1, int(row.get("base_price", 1))))
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) \
* pow(db.shop_price_a(), float(visit_buys))
var price_f: float = base * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) \
* pow(db.run.shop.price_a, float(visit_buys))
return _spend_discounted(int(ceil(price_f)))
@@ -202,11 +202,11 @@ func shop_shelf() -> Array:
for tok in shelf:
var kind := String((tok as Dictionary).get("kind", ""))
var tier := String((tok as Dictionary).get("tier", ""))
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind)
var display := String(row.get("display_name", kind))
var value := int(db.adventure.curio_value(kind, tier))
var value := int(db.run.adventure.curio_value(kind, tier))
if row.is_empty() and kind == "relic":
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", "")))
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(tok.get("feat_id", "")))
display = String(rrow.get("display_name", "特性"))
value = 0
out.append({
@@ -227,22 +227,22 @@ func shop_shelf() -> Array:
func shop_refresh_free_left() -> int:
# 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
# 与基础 knob 同生命周期;未装配=基值)
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
return maxi(0, int(db.run.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
# 刷新执照装配合计(装配集 kind 分发扫描;零装配=0)
func _attune_shop_refresh_bonus() -> int:
var total := 0
for iid_v in _attunement():
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"):
total += int(arow.get("magnitude", 0))
return total
func shop_refresh_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(paid_refreshes))))
return int(ceil(float(db.run.adventure.shop_refresh_cost_base)
* pow(db.run.adventure.shop_refresh_cost_curve, float(paid_refreshes))))
func shop_refresh() -> bool:
+189
View File
@@ -0,0 +1,189 @@
## 存档槽位文件域件(D3 拆件——自 save_manager 收编槽位族):槽位挂载与模板、激活与建删、
## 摘要窥视(peek 族)的唯一所有者。槽位态(路径/激活号/模板)随件承载;数据重置与读写盘
## 经注入的 save 内部协作家族(_reset_to_default/_load_parsed/_rename_overwrite)完成——
## 本件零 _data 副本零缓存(load 整体换新不陈旧)。
class_name SlotStore
extends RefCounted
var _save # SaveManagerCore 注入引用(IO 与数据域协作单点)
var _path: String = "" # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝
var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准)
var _slot_template: String # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名)
func _init(save_ref, path: String = "") -> void:
_save = save_ref
_slot_template = _save.SLOT_PATH_TEMPLATE
_path = path
_active_slot = _slot_index_of(path)
## 进档注入口:换路径+回默认数据+重读盘(对象引用恒定,MetaProgression 持有的 save 引用零失效)。
## 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活);
## 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。
func attach_path(path: String) -> void:
_path = path
_save._read_only = false
_active_slot = _slot_index_of(path)
_save._reset_to_default()
_save.load_from_disk()
## 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间,
## 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间)
func attach_slot_template(template: String) -> void:
_slot_template = template
_path = ""
_active_slot = 0
_save._read_only = false
_save._reset_to_default()
# 槽文件路径唯一派生口(模板单点——禁第二处拼名)
func slot_path(slot: int) -> String:
return _slot_template % slot
# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档)
func active_slot() -> int:
return _active_slot
# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口)
func has_write_target() -> bool:
return _path != ""
# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。
# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽;
# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。
func activate_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro: bool = _save._read_only
var prev_data = _save._data
_path = slot_path(slot)
_active_slot = slot
_save._read_only = false
_save._reset_to_default()
if not _save.load_from_disk():
_path = prev_path
_active_slot = prev_slot
_save._read_only = prev_ro
_save._data = prev_data
return false
return true
# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。
# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档);写盘失败回退切换前三态。
func create_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro: bool = _save._read_only
var prev_data = _save._data
_path = slot_path(slot)
_active_slot = slot
_save._read_only = false
_save._reset_to_default()
if not _save.write_to_disk():
_path = prev_path
_active_slot = prev_slot
_save._read_only = prev_ro
_save._data = prev_data
return false
return true
# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效+无 .tmp 残留;删激活槽时激活指针连清除
# (内存回默认空态,该槽不再可写——激活态生命周期终点)。
# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。
# 删不存在文件的空槽=幂等成功;越界槽号拒绝。
func delete_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var p := slot_path(slot)
if FileAccess.file_exists(p) and not _remove_file(p):
push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p)
return false
var tmp := p + ".tmp"
if FileAccess.file_exists(tmp):
_remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义)
if _active_slot == slot:
_path = ""
_active_slot = 0
_save._reset_to_default()
return true
# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。
# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。
# 迁移链在此只读推演(内核 _peek_summary_at 归 save IO 域——本方法经槽路径派生委托)。
func peek_slot_summary(slot: int) -> Dictionary:
if slot < 1 or slot > _save.SLOT_COUNT:
return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽
return _save._peek_summary_at(slot_path(slot))
# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名)
func peek_summary_at(path: String) -> Dictionary:
return _save._peek_summary_at(path)
# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准)
func _slot_index_of(path: String) -> int:
for i in range(1, _save.SLOT_COUNT + 1):
if path == slot_path(i):
return i
return 0
# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本
# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据);
# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名
# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。
# tmp+rename 原子形态与 write_to_disk 同纪律(改名原语 _rename_overwrite 归 save IO 域单点)。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。
func _migrate_legacy_save(src: String, dst: String, bak: String) -> void:
if not FileAccess.file_exists(src):
return # 执行前置:旧档不存在=无操作
if FileAccess.file_exists(dst):
return # 幂等判据:槽 1 已存在不重迁
var tmp := dst + ".tmp"
if not _copy_file(src, tmp):
push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp])
return
if not _save._rename_overwrite(tmp, dst):
push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst])
return
_save._rename_overwrite(src, bak)
push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak])
# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造)
func _copy_file(from: String, to: String) -> bool:
var src := FileAccess.open(from, FileAccess.READ)
if src == null:
return false
var dst := FileAccess.open(to, FileAccess.WRITE)
if dst == null:
src.close()
return false
while not src.eof_reached():
var chunk := src.get_buffer(65536)
if chunk.size() > 0:
dst.store_buffer(chunk)
src.close()
dst.close()
return true
# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点)
func _remove_file(p: String) -> bool:
var dir := DirAccess.open(p.get_base_dir())
if dir == null:
return false
return dir.remove(p.get_file()) == OK
+41 -27
View File
@@ -29,44 +29,58 @@ func apply_effect(effect_id: String, source: Dictionary, duration_override: int
if not _open:
push_warning("[增益状态] 域闭授予(EC1)")
return false
var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id)
if row.is_empty():
push_warning("[增益状态] 未知 effect_id(EC2): %s" % effect_id)
return false
var policy: String = row.stack_policy
var dur: int = int(row.duration_settlements) if duration_override < 1 else duration_override
var max_stack: int = int(row.max_stack)
# 授予时快照(抗表重载——EC9)
var snap_m: int = int(row.magnitude)
if source.has("magnitude_override"):
snap_m = int(source["magnitude_override"]) # 封印溢价贯穿(迭代14② T4——封印命中 percent 形态同乘快照;缺省路径零扰动)
var snap_unit: String = String(row.unit)
var snap_display: String = String(row.display_name)
var snap := _grant_snapshot(row, source)
match policy:
"independent":
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
"refresh":
for inst in _instances:
if inst.effect_id == effect_id:
inst.remaining = dur # R ← D(取实例快照 D——同表值);L 不变;空刷新恒 true
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
return _apply_refresh(effect_id, dur, snap, source)
"stack":
for inst in _instances:
if inst.effect_id == effect_id:
if int(inst.layers) >= max_stack:
return false # 满层拒绝原子——容器零写入(拒收由战斗结算记"奖励未生效"行)
inst.layers = int(inst.layers) + 1 # stack 授予不触碰 R(spec 决议)
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
return _apply_stack(effect_id, dur, snap, source, max_stack)
push_warning("[增益状态] 未知 stack_policy")
return false
func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, m: int, unit: String, display: String) -> Dictionary:
## 授予时快照域(抗表重载——EC9;封印溢价贯穿——迭代14② T4 percent 形态同乘快照、缺省路径零扰动)
func _grant_snapshot(row: Dictionary, source: Dictionary) -> Dictionary:
var snap_m: int = int(row.magnitude)
if source.has("magnitude_override"):
snap_m = int(source["magnitude_override"])
return {"m": snap_m, "unit": String(row.unit), "display": String(row.display_name)}
## refresh 臂:命中改 R 未命中追加(R ← D 取实例快照 D——同表值;L 不变;空刷新恒 true)
func _apply_refresh(effect_id: String, dur: int, snap: Dictionary, source: Dictionary) -> bool:
for inst in _instances:
if inst.effect_id == effect_id:
inst.remaining = dur
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
## stack 臂:满层拒零写入·未满加层·未命中追加(stack 授予不触碰 R——spec 决议;
## 满层拒绝原子——容器零写入,拒收由战斗结算记"奖励未生效"行)
func _apply_stack(effect_id: String, dur: int, snap: Dictionary, source: Dictionary, max_stack: int) -> bool:
for inst in _instances:
if inst.effect_id == effect_id:
if int(inst.layers) >= max_stack:
return false
inst.layers = int(inst.layers) + 1
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, snap: Dictionary) -> Dictionary:
return {
"effect_id": effect_id,
"layers": layers,
@@ -74,9 +88,9 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary,
"source_class": String(source.get("source_class", "")),
"origin_cell": source.get("origin_cell", Vector2i(-1, -1)),
"round_index": int(source.get("round_index", 0)),
"snap_m": m,
"snap_unit": unit,
"snap_display": display,
"snap_m": snap["m"],
"snap_unit": snap["unit"],
"snap_display": snap["display"],
}
@@ -84,7 +98,7 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary,
func query_active(side: String) -> Array:
var out: Array[Dictionary] = []
for inst in _instances:
var row: Dictionary[Variant, Variant] = db.status_effect_row(inst.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(inst.effect_id)
if row.is_empty():
continue
if String(row.target_side) != side:
+44 -26
View File
@@ -68,9 +68,10 @@ func _usable_now(item_id: String, board_has_preview_info: Callable) -> bool:
func _item_row(item_id: String) -> Dictionary:
if _db == null:
return {}
if _db.has_method("treasure_item_row"):
return _db.treasure_item_row(item_id) # 表访问收口(正式面走 ConfigDB 访问器)
return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无访问器——直读保留,随测试面演进退役)
var db_crossrun = _db.get("crossrun")
if db_crossrun != null:
return db_crossrun.treasure_items.row_by_id(item_id) # 表访问收口(正式面走 ConfigDB 容器直连)
return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无容器——直读保留,随测试面演进退役)
# 不标具体类型原因:单元测试注入 StubDB 桩表(treasure_items_test.gd)——typed 赋值会运行期拒收,保守保持 null 注
var _db = null # ConfigDB(行定义读取——_init 注入;测试可注入桩表)
@@ -97,31 +98,16 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre
var kinds: Array[Variant] = row.get("effect_kinds", [])
var resolved: Array[Variant] = []
if kinds.has("mine_reveal") or kinds.has("reward_preview") or kinds.has("reveal_tiles") or kinds.has("safe_dig"):
# 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析;
# 真视窄域护栏 REVEAL_KINDS 同守):
if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y):
return _reject("目标越板")
if String(row.get("target", "")) == "cell":
# 单格档(线香真视单格;≤3×3 窄域契约内的点目标)
resolved.append(target)
else:
# 3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩)
for dy in range(-1, 2):
for dx in range(-1, 2):
var c := target + Vector2i(dx, dy)
if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y):
resolved.append(c)
# 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析;真视窄域护栏 REVEAL_KINDS 同守)
var spatial := _spatial_reject_reason(row, target, board_size)
if spatial != "":
return _reject(spatial)
resolved = _window_cells(row, target, board_size)
# 点击级零信息:纯预读行窗口全已开 = 没东西可看(拒绝零消耗);
# 真视恒可用(零雷窗口=「这 9 格没雷」信息非空——GDD 裁定)
if kinds.has("reward_preview") and not kinds.has("mine_reveal"):
var any_unrevealed := false
if cell_unrevealed_cb.is_valid():
for c in resolved:
if bool(cell_unrevealed_cb.call(c)):
any_unrevealed = true
break
if not any_unrevealed:
return _reject("窗口内无可预读的未开格")
if kinds.has("reward_preview") and not kinds.has("mine_reveal") \
and _preview_window_dead(resolved, cell_unrevealed_cb):
return _reject("窗口内无可预读的未开格")
return {
"ok": true,
"reason": "",
@@ -132,6 +118,38 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre
}
## 空间解析域:目标越板拒(cell/3×3 两档共用板界)——返回拒绝原因,空串=通过
func _spatial_reject_reason(row: Dictionary, target: Vector2i, board_size: Vector2i) -> String:
if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y):
return "目标越板"
return ""
## 预读窗口域:单格档=点目标(线香真视单格;≤3×3 窄域契约内);其余=3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩)
func _window_cells(row: Dictionary, target: Vector2i, board_size: Vector2i) -> Array:
var resolved: Array[Variant] = []
if String(row.get("target", "")) == "cell":
resolved.append(target)
else:
for dy in range(-1, 2):
for dx in range(-1, 2):
var c := target + Vector2i(dx, dy)
if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y):
resolved.append(c)
return resolved
## 预读死窗判定:纯预读行窗口全已开 = 没东西可看(cell_unrevealed_cb = 排雷玩法 只读面)
func _preview_window_dead(resolved: Array, cell_unrevealed_cb: Callable) -> bool:
var any_unrevealed := false
if cell_unrevealed_cb.is_valid():
for c in resolved:
if bool(cell_unrevealed_cb.call(c)):
any_unrevealed = true
break
return not any_unrevealed
# ---- 叠加层(战斗结算逐 kind 应用——空间先、ap 后的序在编排侧钉死)----
func apply_overlay(kind: String, cells: Array) -> void:
+59 -30
View File
@@ -33,42 +33,58 @@ func _init(db_ref: ConfigDb) -> void:
# (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。
# 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和)
# ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。
func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides: Array = [],
atk_override: int = -1, max_hp_override: int = -1, enemy_atk_overrides: Array = [],
hero_attack_type: String = "", hero_armor_type: String = "", row_kind: String = "battle") -> bool:
# spawn 引用束(K6′ 签名束化——10 形参收单 Dictionary 束):束键封闭词表——
# 必载 level_index/node_depth/hero_hp;可选 unit_count_overrides=[]/enemy_atk_overrides=[]/
# hero_atk_override=-1/hero_max_hp_override=-1/hero_attack_type=""/hero_armor_type=""/
# row_kind="battle"(缺省语义与现行形参缺省逐字同值)。
# enemy_atk_override:遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。
# 行集受理:unit_count_overrides / enemy_atk_overrides 为**逐行数组**
# (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。
# 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和)
# ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。
func spawn(req: Dictionary) -> bool:
if _spawned:
push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)")
return false
# 行集数组口径(resolver 按 (L,band) 带寻址返回全部行、文件序)
var rowset: Array[Variant] = db.resolve_enemy_rowset(level_index, node_depth, row_kind)
var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(req.level_index), int(req.node_depth), String(req.get("row_kind", "battle")))
if rowset.is_empty():
push_error("[敌人系统] 遭遇行解析失败/越域 (%d,%d,%s)" % [level_index, node_depth, row_kind])
push_error("[敌人系统] 遭遇行解析失败/越域 (%s,%s,%s)" % [str(req.get("level_index")), str(req.get("node_depth")), String(req.get("row_kind", "battle"))])
assert(false, "遭遇行悬空(R1 前置守卫)")
return false
var eff_atk: int = atk_override if atk_override >= 1 else int(db.hero.atk)
var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.hero.max_hp)
if not _spawn_hero_unit(int(req.hero_hp), int(req.get("hero_atk_override", -1)), int(req.get("hero_max_hp_override", -1)), String(req.get("hero_attack_type", "")), String(req.get("hero_armor_type", ""))):
return false
var mod: Dictionary[Variant, Variant] = db.run.difficulty.resolve_modifiers(int(req.level_index), int(req.node_depth))
if not _spawn_rowset(rowset, req.get("unit_count_overrides", []), req.get("enemy_atk_overrides", []), float(mod.get("hp_mult", 1.0)), float(mod.get("atk_mult", 1.0))):
return false
_spawned = true
_despawned = false
return true
## 英雄单位构建域:生效上限与类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light)。
## max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁
## (恢复点/治疗包的满血守卫与治疗上限随之失效)
func _spawn_hero_unit(hero_hp: int, atk_override: int, max_hp_override: int, hero_attack_type: String, hero_armor_type: String) -> bool:
var eff_atk: int = atk_override if atk_override >= 1 else int(db.combat.hero.atk)
var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.combat.hero.max_hp)
if hero_hp > eff_max_hp or hero_hp <= 0:
push_warning("[敌人系统] hero_hp 入参越界(EC16): %d" % hero_hp)
return false
# max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁
# (恢复点/治疗包的满血守卫与治疗上限随之失效)
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.forge_default_armor_type(), "attack_type": hero_attack_type if hero_attack_type != "" else db.forge_default_attack_type()} # 护甲/攻击类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light;甲效矩阵消费面)
_enemies.clear() # 难度曲线与Boss DifficultyTable 修饰:施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15)
var mod: Dictionary[Variant, Variant] = db.resolve_difficulty_modifiers(level_index, node_depth)
var hp_mult: float = float(mod.get("hp_mult", 1.0))
var atk_mult: float = float(mod.get("atk_mult", 1.0))
# 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值;
# 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值)
var per_row_counts: Array[int] = []
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.combat.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.crossrun.forge.default_armor_type, "attack_type": hero_attack_type if hero_attack_type != "" else db.crossrun.forge.default_attack_type} # 护甲/攻击类型轴快照(甲效矩阵消费面)
return true
## 行集实例化域(难度曲线修饰施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为〔AC-10〕;
## 混编行集内全部敌种同乘——(L,D) 精确匹配一次取值,F9 同乘不改变异,敌人系统 v0.15)
func _spawn_rowset(rowset: Array, unit_count_overrides: Array, enemy_atk_overrides: Array, hp_mult: float, atk_mult: float) -> bool:
_enemies.clear()
var per_row_counts := _row_instance_counts(rowset, unit_count_overrides)
if per_row_counts.is_empty():
return false
var total_instances := 0
for row_i in range(rowset.size()):
var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i]
var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1
var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count)
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count])
return false
per_row_counts.append(count)
for count in per_row_counts:
total_instances += count
# 行集实例总数守卫(行集构成纪律③——各行骰后数量求和 ≤4;骰后值加载期不可判,此处 fail-fast)
if total_instances > EnemyTable.ROWSET_INSTANCE_MAX:
@@ -99,11 +115,24 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides
"is_hero": false,
})
handle += 1
_spawned = true
_despawned = false
return true
## 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值;
## 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值)。越域行 = 空数组(受理拒绝信号)
func _row_instance_counts(rowset: Array, unit_count_overrides: Array) -> Array[int]:
var per_row_counts: Array[int] = []
for row_i in range(rowset.size()):
var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i]
var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1
var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count)
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count])
return []
per_row_counts.append(count)
return per_row_counts
# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条
# 快照纪律。触发条件:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记
# trigger_latched 初始锁定;缺省无 trigger=无条件技)
@@ -115,7 +144,7 @@ func _pool_snapshot(row: Dictionary) -> Array[Dictionary]:
"weight": int(entry.get("weight", 0)),
"cooldown_left": 0,
}
var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", "")))
var srow_snap: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(entry.get("skill_id", "")))
if srow_snap.has("trigger"):
snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true)
if bool((snap_entry["trigger"] as Dictionary).get("latch", false)):
@@ -218,7 +247,7 @@ func special_skills(u: Dictionary, facts: Dictionary = {}) -> Array:
for e in u.pool:
if _entry_blocked(e, u, facts):
continue
var srow: Dictionary[Variant, Variant] = db.skill_row(String(e.skill_id))
var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(e.skill_id))
if not srow.is_empty() and String(srow.type) != "basic":
out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)})
return out
@@ -276,7 +305,7 @@ func commit_action(u: Dictionary) -> void:
if sid == "":
_diag("commit_action 无待发动作(兑现序违例)")
return
var srow: Dictionary[Variant, Variant] = db.skill_row(sid)
var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
var cd: int = int(srow.get("cooldown_beats", 0)) if not srow.is_empty() else 0
for e in u.pool:
if String(e.skill_id) == sid:
@@ -0,0 +1,15 @@
## 冒险表·已拿上限段(K4′ 拆段——curio_cap_* 9 槽随段件嵌套;段内槽名逐字不动;
## 概率与上限解耦〔奇物.md 池结构案D〕——概率位=档+令牌数、上限=本字段〔拿了才计、刷出不计〕;
## cap 满后该 kind 从候选视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段)。
class_name AdventureCapSegment
extends Resource
@export var curio_cap_attack: int = 99 # [1,99]
@export var curio_cap_max_hp: int = 99 # [1,99]
@export var curio_cap_armor: int = 99 # [1,99]
@export var curio_cap_gp: int = 99 # [1,99]
@export var curio_cap_ap: int = 99 # [1,99]
@export var curio_cap_heal: int = 99 # [1,99]
@export var curio_cap_refresh: int = 99 # [1,99]
@export var curio_cap_rap: int = 99 # [1,99]
@export var curio_cap_time_sand: int = 99 # [1,99]
@@ -0,0 +1,18 @@
## 冒险表·令牌配额段(K4′ 拆段——curio_quota_* 13 槽随段件嵌套;段内槽名逐字不动;
## 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)。kind/tier 词表与配额结构锚留宿主。
class_name AdventureQuotaSegment
extends Resource
@export var curio_quota_attack_common: int = 18 # [0,99]
@export var curio_quota_attack_excellent: int = 5 # [0,99]
@export var curio_quota_max_hp_common: int = 17 # [0,99]
@export var curio_quota_max_hp_excellent: int = 5 # [0,99]
@export var curio_quota_armor_common: int = 17 # [0,99]
@export var curio_quota_armor_excellent: int = 5 # [0,99]
@export var curio_quota_gp_common: int = 18 # [0,99]
@export var curio_quota_gp_excellent: int = 5 # [0,99]
@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变)
@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参)
@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档)
@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参)
@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参)
+40 -78
View File
@@ -45,49 +45,11 @@ const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 7, "legendary": 0}
@export var shop_refresh_cost_base: int = 20 # [1,999] knob(MC-1 provisional——GP 刷新基价)
@export var shop_refresh_cost_curve: float = 1.8 # [1.0,4.0] knob(MC-1 provisional——按已付费刷新数幂曲线,A 链同族)
# —— 令牌配额(kind × tier 计数;未列组合 = 0。四级档名 common/excellent/rare/legendary
# 承袭同一物理位序〔70/25/7/0〕;各 kind 现值见行注默认)——
@export var curio_quota_attack_common: int = 18 # [0,99]
@export var curio_quota_attack_excellent: int = 5 # [0,99]
@export var curio_quota_max_hp_common: int = 17 # [0,99]
@export var curio_quota_max_hp_excellent: int = 5 # [0,99]
@export var curio_quota_armor_common: int = 17 # [0,99]
@export var curio_quota_armor_excellent: int = 5 # [0,99]
@export var curio_quota_gp_common: int = 18 # [0,99]
@export var curio_quota_gp_excellent: int = 5 # [0,99]
@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变)
@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参)
@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档)
@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参)
@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参)
# —— 令牌件值(小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)——
@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤
@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤
@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命
@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命
@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲
@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲
@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP
@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP
@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限)
@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区)
@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量
@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N
@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N
# —— 已拿上限 cap(概率与上限解耦〔奇物.md 池结构案D〕——案②令牌池+提前退役:
# 概率位=档+令牌数〔牌库物理张数〕、上限=本字段〔拿了才计、刷出不计〕;cap 满后该 kind 从候选
# 视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段〕)——
@export var curio_cap_attack: int = 99 # [1,99]
@export var curio_cap_max_hp: int = 99 # [1,99]
@export var curio_cap_armor: int = 99 # [1,99]
@export var curio_cap_gp: int = 99 # [1,99]
@export var curio_cap_ap: int = 99 # [1,99]
@export var curio_cap_heal: int = 99 # [1,99]
@export var curio_cap_refresh: int = 99 # [1,99]
@export var curio_cap_rap: int = 99 # [1,99]
@export var curio_cap_time_sand: int = 99 # [1,99]
# —— 列族三段件(K4′ 拆段——36 个列族标量槽随段件嵌套,段内槽名逐字不动;
# 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)——
@export var quota_segment: AdventureQuotaSegment = AdventureQuotaSegment.new() # 令牌配额段(curio_quota_* 13)
@export var value_segment: AdventureValueSegment = AdventureValueSegment.new() # 令牌件值段(curio_value_* 13)
@export var cap_segment: AdventureCapSegment = AdventureCapSegment.new() # 已拿上限段(curio_cap_* 9)
# —— 专长获取专长凭证(非 Boss 渠道的专长入口形态,奇物.md v2.7 渠道轴定位;
# 四口径契约④:拿到即开不可囤积/罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出
@@ -120,53 +82,53 @@ func curio_apply_route(kind: String) -> String:
## heal/refresh excellent、ap rare、凭证 kind rare 档)
func curio_quota(kind: String, tier: String) -> int:
match [kind, tier]:
["attack", TIER_COMMON]: return curio_quota_attack_common
["attack", TIER_EXCELLENT]: return curio_quota_attack_excellent
["max_hp", TIER_COMMON]: return curio_quota_max_hp_common
["max_hp", TIER_EXCELLENT]: return curio_quota_max_hp_excellent
["armor", TIER_COMMON]: return curio_quota_armor_common
["armor", TIER_EXCELLENT]: return curio_quota_armor_excellent
["gp", TIER_COMMON]: return curio_quota_gp_common
["gp", TIER_EXCELLENT]: return curio_quota_gp_excellent
["heal", TIER_EXCELLENT]: return curio_quota_heal_excellent
["refresh", TIER_EXCELLENT]: return curio_quota_refresh_excellent
["ap", TIER_RARE]: return curio_quota_ap_rare
["rap", TIER_RARE]: return curio_quota_rap_rare
["time_sand", TIER_RARE]: return curio_quota_time_sand_rare
["attack", TIER_COMMON]: return quota_segment.curio_quota_attack_common
["attack", TIER_EXCELLENT]: return quota_segment.curio_quota_attack_excellent
["max_hp", TIER_COMMON]: return quota_segment.curio_quota_max_hp_common
["max_hp", TIER_EXCELLENT]: return quota_segment.curio_quota_max_hp_excellent
["armor", TIER_COMMON]: return quota_segment.curio_quota_armor_common
["armor", TIER_EXCELLENT]: return quota_segment.curio_quota_armor_excellent
["gp", TIER_COMMON]: return quota_segment.curio_quota_gp_common
["gp", TIER_EXCELLENT]: return quota_segment.curio_quota_gp_excellent
["heal", TIER_EXCELLENT]: return quota_segment.curio_quota_heal_excellent
["refresh", TIER_EXCELLENT]: return quota_segment.curio_quota_refresh_excellent
["ap", TIER_RARE]: return quota_segment.curio_quota_ap_rare
["rap", TIER_RARE]: return quota_segment.curio_quota_rap_rare
["time_sand", TIER_RARE]: return quota_segment.curio_quota_time_sand_rare
return 0
## 令牌件值(kind × tier;四级构成随配额表)
func curio_value(kind: String, tier: String = TIER_COMMON) -> int:
match [kind, tier]:
["attack", TIER_COMMON]: return curio_value_attack_common
["attack", TIER_EXCELLENT]: return curio_value_attack_excellent
["max_hp", TIER_COMMON]: return curio_value_max_hp_common
["max_hp", TIER_EXCELLENT]: return curio_value_max_hp_excellent
["armor", TIER_COMMON]: return curio_value_armor_common
["armor", TIER_EXCELLENT]: return curio_value_armor_excellent
["gp", TIER_COMMON]: return curio_value_gp_common
["gp", TIER_EXCELLENT]: return curio_value_gp_excellent
["heal", TIER_EXCELLENT]: return curio_value_heal_excellent
["refresh", TIER_EXCELLENT]: return curio_value_refresh_excellent
["ap", TIER_RARE]: return curio_value_ap_rare
["rap", TIER_RARE]: return curio_value_rap_rare
["time_sand", TIER_RARE]: return curio_value_time_sand_rare
["attack", TIER_COMMON]: return value_segment.curio_value_attack_common
["attack", TIER_EXCELLENT]: return value_segment.curio_value_attack_excellent
["max_hp", TIER_COMMON]: return value_segment.curio_value_max_hp_common
["max_hp", TIER_EXCELLENT]: return value_segment.curio_value_max_hp_excellent
["armor", TIER_COMMON]: return value_segment.curio_value_armor_common
["armor", TIER_EXCELLENT]: return value_segment.curio_value_armor_excellent
["gp", TIER_COMMON]: return value_segment.curio_value_gp_common
["gp", TIER_EXCELLENT]: return value_segment.curio_value_gp_excellent
["heal", TIER_EXCELLENT]: return value_segment.curio_value_heal_excellent
["refresh", TIER_EXCELLENT]: return value_segment.curio_value_refresh_excellent
["ap", TIER_RARE]: return value_segment.curio_value_ap_rare
["rap", TIER_RARE]: return value_segment.curio_value_rap_rare
["time_sand", TIER_RARE]: return value_segment.curio_value_time_sand_rare
return 0
## 已拿上限读数(案②——kind 维度;专长 cap 走 feats.tres 行字段不走本面)
func curio_cap(kind: String) -> int:
match kind:
"attack": return curio_cap_attack
"max_hp": return curio_cap_max_hp
"armor": return curio_cap_armor
"gp": return curio_cap_gp
"ap": return curio_cap_ap
"heal": return curio_cap_heal
"refresh": return curio_cap_refresh
"rap": return curio_cap_rap
"time_sand": return curio_cap_time_sand
"attack": return cap_segment.curio_cap_attack
"max_hp": return cap_segment.curio_cap_max_hp
"armor": return cap_segment.curio_cap_armor
"gp": return cap_segment.curio_cap_gp
"ap": return cap_segment.curio_cap_ap
"heal": return cap_segment.curio_cap_heal
"refresh": return cap_segment.curio_cap_refresh
"rap": return cap_segment.curio_cap_rap
"time_sand": return cap_segment.curio_cap_time_sand
return 99
@@ -0,0 +1,18 @@
## 冒险表·令牌件值段(K4′ 拆段——curio_value_* 13 槽随段件嵌套;段内槽名逐字不动;
## 小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)。
class_name AdventureValueSegment
extends Resource
@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤
@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤
@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命
@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命
@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲
@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲
@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP
@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP
@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限)
@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区)
@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量
@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N
@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N
+93 -56
View File
@@ -19,63 +19,100 @@ func validate() -> Array:
var levels := {}
var anchor_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if levels.has(key):
errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key})
levels[key] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if D < 0:
errs.append({"category": "domain", "message": "node_depth 须 ≥0"})
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
if w < 8 or w > 64 or h < 8 or h > 64:
errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]})
var d: float = float(row.get("mine_density", 0.0))
if d <= 0.0 or d > 0.5:
errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]})
var t: float = float(row.get("inferability_target", 0.0))
if t < TARGET_ABS_FLOOR or t > 1.0:
errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]})
if not SOLVER_RULES.has(row.get("solver_rules", "")):
errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]})
if row.get("solver_rules", "") != "single_subset":
errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]})
var ms: int = int(row.get("min_safe_count", 0))
if ms < 1 or ms > 999:
errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]})
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
# 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
if w * h - n_mines < ms:
errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]})
# F2c 成本可行性
if att * w * h > COST_BUDGET:
errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]})
# 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订)
var bt: String = str(row.get("board_type", ""))
if not BOARD_TYPE_VOCABULARY.has(bt):
errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]})
# 初始展开收窄两参数(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径)
var sr: int = int(row.get("shave_ring_radius", 1))
if sr < 0 or sr > 2:
errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]})
var r0_cap: int = int(row.get("r0_max_cap", 0))
if r0_cap > 0:
# 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半
if r0_cap < BoardData.SPAWN_AREA_CELLS:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]})
if r0_cap > (w * h) / 2:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]})
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
errs.append_array(_validate_row(row, levels, anchor_ok))
errs.append_array(_validate_row_constraints(row))
errs.append_array(_validate_shave_params(row))
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "level %d 缺 depth-0 锚行(EC15)" % L})
return errs
## 行域:键唯一与进度/尺寸/密度/推理目标/solver 规则冻结(levels/anchor_ok 引用累计)
func _validate_row(row: Dictionary, levels: Dictionary, anchor_ok: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if levels.has(key):
errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key})
levels[key] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if D < 0:
errs.append({"category": "domain", "message": "node_depth 须 ≥0"})
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
if w < 8 or w > 64 or h < 8 or h > 64:
errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]})
var d: float = float(row.get("mine_density", 0.0))
if d <= 0.0 or d > 0.5:
errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]})
var t: float = float(row.get("inferability_target", 0.0))
if t < TARGET_ABS_FLOOR or t > 1.0:
errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]})
if not SOLVER_RULES.has(row.get("solver_rules", "")):
errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]})
if row.get("solver_rules", "") != "single_subset":
errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]})
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
return errs
## 约束域:min_safe/生成尝试预算/布雷数下限与可行性/成本护栏/图类型词表
func _validate_row_constraints(row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
var d: float = float(row.get("mine_density", 0.0))
var ms: int = int(row.get("min_safe_count", 0))
if ms < 1 or ms > 999:
errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]})
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
# 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
if w * h - n_mines < ms:
errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]})
# F2c 成本可行性
if att * w * h > COST_BUDGET:
errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]})
# 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订)
var bt: String = str(row.get("board_type", ""))
if not BOARD_TYPE_VOCABULARY.has(bt):
errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]})
return errs
## 初始展开收窄域(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径)
func _validate_shave_params(row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
var sr: int = int(row.get("shave_ring_radius", 1))
if sr < 0 or sr > 2:
errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]})
var r0_cap: int = int(row.get("r0_max_cap", 0))
if r0_cap > 0:
# 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半
if r0_cap < BoardData.SPAWN_AREA_CELLS:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]})
if r0_cap > (w * h) / 2:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]})
return errs
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 进度行解析委托(消费面 db.combat.board_gen.resolve_row——R1 ceil/clamp 语义归 ConfigDbResolver)
func resolve_row(L: int, D: int) -> Dictionary:
return _resolver.combat.board_gen.resolve_row(L, D)
+8
View File
@@ -28,3 +28,11 @@ func validate() -> Array:
if v < MULT_MIN or v > MULT_MAX:
errs.append({"category": "mult_oor_warning", "message": "%s (%s) 越域 [0.1,10] 使用时 clamp: %f" % [key, k, v]})
return errs
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 难度修饰解析委托(消费面 db.run.difficulty.resolve_modifiers——clamp 语义归 ConfigDbResolver)
func resolve_modifiers(L: int, D: int) -> Dictionary:
return _resolver.run.difficulty.resolve_modifiers(L, D)
+142 -85
View File
@@ -44,98 +44,147 @@ func validate() -> Array:
var anchor_ok := {}
var boss_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var rk: String = String(row.get("row_kind", ""))
var band: int = int(row.get("depth_band", -1))
var eid: String = row.get("enemy_id", "")
var key := "%d_%s_%d_%s" % [L, rk, band, eid]
if seen.has(key):
errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or band < 0:
errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]})
if not ROW_KINDS.has(rk):
errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]})
if eid == "":
errs.append({"category": "domain", "message": "enemy_id 空"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
var uc: int = int(row.get("unit_count", 0))
if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX:
errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]})
# unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > UNIT_COUNT_MAX:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
var atk: int = int(row.get("atk", -1))
if atk < 0 or atk > 999:
errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid})
# atk_min/atk_max(可省略配对):遭遇骰同流 spawn 期整战持久 roll;
# 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk)
var has_amin: bool = row.has("atk_min")
var has_amax: bool = row.has("atk_max")
if has_amin != has_amax:
errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid})
elif has_amin:
var amin: int = int(row.get("atk_min", 0))
var amax: int = int(row.get("atk_max", 0))
if amin < 0 or amax > 999 or amin > amax:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
# 池模板(单表结构校验;跨表构成纪律在 ConfigDB):
# 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。
# 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。
var pool: Array[Variant] = row.get("pool", [])
if pool.is_empty():
errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid})
var pool_ids := {}
for entry_v in pool:
if not (entry_v is Dictionary):
errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]})
continue
var entry: Dictionary[Variant, Variant] = entry_v
var sid: String = String(entry.get("skill_id", ""))
if sid == "":
errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid})
elif pool_ids.has(sid):
errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]})
pool_ids[sid] = true
var w: int = int(entry.get("weight", 0))
if w <= 0 or w > 9999:
errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]})
# armor_type(可省略——缺省 light 向后兼容)
var at: String = String(row.get("armor_type", "light"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]})
# attack_type(可省略,缺省 normal 向后兼容;写法沿用 armor_type 同款)
var att: String = String(row.get("attack_type", "normal"))
if not ATTACK_TYPES.has(att):
errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]})
# armor_class(可省略——缺省 0 向后兼容;防守方掷点判定阈值基数)
var ac: int = int(row.get("armor_class", 0))
if ac < 0 or ac > 999:
errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]})
errs.append_array(_validate_row_base(row, seen))
var eid2 := String(row.get("enemy_id", ""))
errs.append_array(_validate_row_numbers(row, eid2))
errs.append_array(_validate_row_atk_range(row, eid2))
errs.append_array(_validate_row_pool(row, eid2))
errs.append_array(_validate_row_types(row, eid2))
# 行集构成纪律②(行序即契约,敌人系统 v0.15):同带键多行必须
# 文件内相邻。迭代中记录上一行行集键;当前行集键曾出现过但 ≠ 上一行行集键 = 同带键行被隔断,报错。
# 同带键多敌种混编合法;同带键同敌种第二行仍由
# dup_key 拦截(键含 enemy_id)。错误类别沿用 rowset_rule(定位面不变)。
var rowset_key := "%d_%s_%d" % [L, rk, band]
var rowset_key := "%d_%s_%d" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]
if rowset_key != last_rowset_key:
if closed_rowset.has(rowset_key):
errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [L, rk, band]})
errs.append_array(_validate_rowset_closed(rowset_key, closed_rowset, row))
closed_rowset[rowset_key] = true
last_rowset_key = rowset_key
if rk == "battle" and band == 0:
anchor_ok[L] = true
elif rk == "boss":
boss_ok[L] = true
if not anchor_ok.has(L):
anchor_ok[L] = false
var L2: int = int(row.get("level_index", 0))
var rk2: String = String(row.get("row_kind", ""))
if rk2 == "battle" and int(row.get("depth_band", -1)) == 0:
anchor_ok[L2] = true
elif rk2 == "boss":
boss_ok[L2] = true
if not anchor_ok.has(L2):
anchor_ok[L2] = false
errs.append_array(_validate_anchor_rows(anchor_ok, boss_ok))
return errs
## 行头基面域:键提取/查重与登记/进度键越域/row_kind 枚举/enemy_id 非空/display_name
func _validate_row_base(row: Dictionary, seen: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var rk: String = String(row.get("row_kind", ""))
var band: int = int(row.get("depth_band", -1))
var eid: String = row.get("enemy_id", "")
var key := "%d_%s_%d_%s" % [L, rk, band, eid]
if seen.has(key):
errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or band < 0:
errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]})
if not ROW_KINDS.has(rk):
errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]})
if eid == "":
errs.append({"category": "domain", "message": "enemy_id 空"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
return errs
## 数值域:unit_count(含可省略上限)/hp/atk 基值
func _validate_row_numbers(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var uc: int = int(row.get("unit_count", 0))
if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX:
errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]})
# unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > UNIT_COUNT_MAX:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
var atk: int = int(row.get("atk", -1))
if atk < 0 or atk > 999:
errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid})
return errs
## atk 区间域(可省略配对):遭遇骰同流 spawn 期整战持久 roll;
## 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk)
func _validate_row_atk_range(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var atk: int = int(row.get("atk", -1))
var has_amin: bool = row.has("atk_min")
var has_amax: bool = row.has("atk_max")
if has_amin != has_amax:
errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid})
elif has_amin:
var amin: int = int(row.get("atk_min", 0))
var amax: int = int(row.get("atk_max", 0))
if amin < 0 or amax > 999 or amin > amax:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
return errs
## 池模板域(单表结构校验;跨表构成纪律在 ConfigDB):
## 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。
## 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。
func _validate_row_pool(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var pool: Array[Variant] = row.get("pool", [])
if pool.is_empty():
errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid})
var pool_ids := {}
for entry_v in pool:
if not (entry_v is Dictionary):
errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]})
continue
var entry: Dictionary[Variant, Variant] = entry_v
var sid: String = String(entry.get("skill_id", ""))
if sid == "":
errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid})
elif pool_ids.has(sid):
errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]})
pool_ids[sid] = true
var w: int = int(entry.get("weight", 0))
if w <= 0 or w > 9999:
errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]})
return errs
## 攻防类型域(均可省略——armor_type 缺省 light/attack_type 缺省 normal/armor_class 缺省 0 向后兼容)
func _validate_row_types(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var at: String = String(row.get("armor_type", "light"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]})
var att: String = String(row.get("attack_type", "normal"))
if not ATTACK_TYPES.has(att):
errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]})
# armor_class(防守方掷点判定阈值基数)
var ac: int = int(row.get("armor_class", 0))
if ac < 0 or ac > 999:
errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]})
return errs
## 行序违例域:当前行集键曾出现且 ≠ 上一行行集键 = 同带键行被隔断(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)
func _validate_rowset_closed(rowset_key: String, closed_rowset: Dictionary, row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if closed_rowset.has(rowset_key):
errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]})
return errs
## 锚行尾段:每 level 必有 (battle, band 0) 锚行与 Boss 行集
func _validate_anchor_rows(anchor_ok: Dictionary, boss_ok: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "EnemyTable level %d 缺 (battle, band 0) 锚行" % L})
@@ -158,3 +207,11 @@ static func band_of(band_keys: Array, D: int) -> int:
# 进度键解析单点收口(全仓统走
# ConfigDbResolver.resolve_enemy_rowset 带寻址行集数组:D 维经本表 band_of 落带(偏难一侧),
# 禁双算法并存〔状态记录 312〕;L 维 ceil/clamp 与越顶 clamp 语义归 resolver)。
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 带寻址行集解析委托(消费面 db.combat.enemy.resolve_rowset——D 维 band_of 落带语义归 ConfigDbResolver)
func resolve_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]:
return _resolver.combat.enemy.resolve_rowset(L, D, row_kind)
+118 -60
View File
@@ -45,66 +45,101 @@ func validate() -> Array:
for row in routes: # 预扫:route_id→kind 全集(前置反查需前向引用——轻甲行在长枪行之后)
kind_by_id[String(row.get("route_id", ""))] = String(row.get("kind", ""))
for row in routes:
var rid := String(row.get("route_id", ""))
if not ROUTE_IDS.has(rid):
errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]})
if seen.has(rid):
errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid})
seen[rid] = true
var kind := String(row.get("kind", ""))
if not ["weapon", "armor"].has(kind):
errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]})
var head_out := _validate_route_head(row, seen, type_seen)
errs.append_array(head_out["errs"])
if head_out["skip"]:
continue
# 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一)
var axis := String(row.get("type_axis", ""))
var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES
if not domain.has(axis):
errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]})
var type_key := "%s|%s" % [kind, axis]
if type_seen.has(type_key):
errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]})
type_seen[type_key] = true
# 等级/增量/价格/基础 AC 值域
var cap_v: int = int(row.get("cap", -1))
if cap_v < 1 or cap_v > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]})
var gain: int = int(row.get("per_level_gain", -1))
if gain < 0 or gain > 9:
errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]})
var base_ac: int = int(row.get("base_ac", -1))
if kind == "armor":
if base_ac < 0 or base_ac > 999:
errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]})
elif base_ac != 0:
errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]})
var base_price: int = int(row.get("base_price", -1))
if base_price < 1 or base_price > 999:
errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]})
var ratio: int = int(row.get("price_ratio_pct", -1))
if ratio < 100 or ratio > 500:
errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]})
var unlock_price: int = int(row.get("unlock_price", -1))
if unlock_price < 0 or unlock_price > 999:
errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]})
# 解锁通道(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置
var initial: bool = bool(row.get("initial_owned", false))
var prereq := String(row.get("prereq_route", ""))
if initial:
if prereq != "":
errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]})
if unlock_price != 0:
errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]})
initial_ids[rid] = kind
else:
if unlock_price < 1:
errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]})
if not ROUTE_IDS.has(prereq):
errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]})
elif prereq == rid:
errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid})
elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind:
errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]})
# 初始持有恰两行且一武器一甲(长剑/轻甲)
var rid := String(row.get("route_id", ""))
errs.append_array(_validate_route_values(row, rid))
errs.append_array(_validate_route_unlock(row, rid, kind_by_id, initial_ids))
errs.append_array(_validate_initial_coverage(initial_ids))
return errs
## 行头域:route_id 词表与查重/kind 出域(skip=跳过行剩余判据)/类型轴域与每型恰一路线
func _validate_route_head(row: Dictionary, seen: Dictionary, type_seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var skip := false
var rid := String(row.get("route_id", ""))
if not ROUTE_IDS.has(rid):
errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]})
if seen.has(rid):
errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid})
seen[rid] = true
var kind := String(row.get("kind", ""))
if not ["weapon", "armor"].has(kind):
errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]})
skip = true
return {"errs": errs, "skip": skip}
# 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一)
var axis := String(row.get("type_axis", ""))
var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES
if not domain.has(axis):
errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]})
var type_key := "%s|%s" % [kind, axis]
if type_seen.has(type_key):
errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]})
type_seen[type_key] = true
return {"errs": errs, "skip": skip}
## 数值域:等级/增量/价格/基础 AC 值域(甲型基础 AC 只属护甲路线)
func _validate_route_values(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var kind := String(row.get("kind", ""))
var cap_v: int = int(row.get("cap", -1))
if cap_v < 1 or cap_v > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]})
var gain: int = int(row.get("per_level_gain", -1))
if gain < 0 or gain > 9:
errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]})
var base_ac: int = int(row.get("base_ac", -1))
if kind == "armor":
if base_ac < 0 or base_ac > 999:
errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]})
elif base_ac != 0:
errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]})
var base_price: int = int(row.get("base_price", -1))
if base_price < 1 or base_price > 999:
errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]})
var ratio: int = int(row.get("price_ratio_pct", -1))
if ratio < 100 or ratio > 500:
errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]})
var unlock_price: int = int(row.get("unlock_price", -1))
if unlock_price < 0 or unlock_price > 999:
errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]})
return errs
## 解锁通道域(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置(武器←武器/甲←甲)
func _validate_route_unlock(row: Dictionary, rid: String, kind_by_id: Dictionary, initial_ids: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var kind := String(row.get("kind", ""))
var unlock_price: int = int(row.get("unlock_price", -1))
var initial: bool = bool(row.get("initial_owned", false))
var prereq := String(row.get("prereq_route", ""))
if initial:
if prereq != "":
errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]})
if unlock_price != 0:
errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]})
initial_ids[rid] = kind
else:
if unlock_price < 1:
errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]})
if not ROUTE_IDS.has(prereq):
errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]})
elif prereq == rid:
errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid})
elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind:
errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]})
return errs
## 初始持有覆盖域(两尾段):初始持有恰两行且一武器一甲(长剑/轻甲)+
## 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲)
func _validate_initial_coverage(initial_ids: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var init_weapons := 0
var init_armors := 0
for k in initial_ids.keys():
@@ -114,7 +149,6 @@ func validate() -> Array:
init_armors += 1
if init_weapons != 1 or init_armors != 1:
errs.append({"category": "coverage", "message": "初始持有须恰一武器一甲(现 sword/light): 武器 %d / 甲 %d" % [init_weapons, init_armors]})
# 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲)
if init_weapons == 1 and init_armors == 1:
for row in routes:
if not bool(row.get("initial_owned", false)):
@@ -124,3 +158,27 @@ func validate() -> Array:
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) != default_armor_type:
errs.append({"category": "cross_field", "message": "default_armor_type 须=初始甲路线类型轴(裸体态轻甲): %s vs %s" % [default_armor_type, String(row.get("type_axis", ""))]})
return errs
# ---- 行族访问器(自 ConfigDB 归位;消费面 db.crossrun.forge.<m> 直连)——
# routes 属性在册故行族读取命名 route_rows(避开成员名冲突)----
## 六路线行族(副本——调用方禁改)
func route_rows() -> Array:
return routes.duplicate()
## 路线行单点(键 route_id;缺行=空字典)
func route(route_id: String) -> Dictionary:
for row in routes:
if String(row.get("route_id", "")) == route_id:
return row
return {}
## 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口)
func armor_route_by_type(armor_type: String) -> Dictionary:
for row in routes:
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type:
return row
return {}
+69 -44
View File
@@ -1,5 +1,5 @@
## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.level_gen_params_row() 读取;生成器只吃参数档、不认关卡号(契约 6)。
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。
## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、
## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。
class_name LevelGenParams
@@ -17,50 +17,13 @@ func validate() -> Array:
var seen := {}
var layers_by_level := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var key := "level %d" % L
if seen.has(L):
errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L})
seen[L] = true
if L < 1:
errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key})
var ks := (row as Dictionary).keys()
ks.sort()
var expect := ROW_KEYS.duplicate()
expect.sort()
if ks != expect:
errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
var layers: int = int(row.get("layers", 0))
if layers < 6:
errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]})
layers_by_level[L] = layers
var bd: float = float(row.get("branch_density", -1.0))
if bd < 0.0 or bd > 1.0:
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
var ratio_sum := 0.0
for rk in RATIO_KEYS:
var v: float = float(row.get(rk, -1.0))
if v < 0.0 or v > 1.0:
errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]})
ratio_sum += v
if not is_equal_approx(ratio_sum, 1.0):
errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]})
var entry_count: int = int(row.get("entry_count", 0))
if entry_count < 1:
errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]})
var exit_count: int = int(row.get("exit_count", 0))
if exit_count < 1 or exit_count > 3:
errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]})
if L == 1 and exit_count != 1:
errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]})
var bf: int = int(row.get("battle_floor", -1))
if bf < 0:
errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]})
var fmin: int = int(row.get("fork_ways_min", 0))
var fmax: int = int(row.get("fork_ways_max", 0))
if fmin != 2 or fmax != 3:
errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]})
errs.append_array(_validate_row_shape(row, head["L"], head["key"], layers_by_level))
errs.append_array(_validate_row_counts(row, head["L"], head["key"]))
# 层深递进尾段(L1<L2<L3 且增幅变大——层深 L1≥6 递增增幅变大已锁)
if layers_by_level.has(1) and layers_by_level.has(2) and layers_by_level.has(3):
var d12: int = int(layers_by_level[2]) - int(layers_by_level[1])
var d23: int = int(layers_by_level[3]) - int(layers_by_level[2])
@@ -72,6 +35,68 @@ func validate() -> Array:
return errs
## 行头域:level_index 查重/关卡序号越域/字段键集闭集(ok=跳过行剩余判据)
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var key := "level %d" % L
if seen.has(L):
errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L})
seen[L] = true
if L < 1:
errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key})
var ks := (row as Dictionary).keys()
ks.sort()
var expect := ROW_KEYS.duplicate()
expect.sort()
if ks != expect:
errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key})
return {"errs": errs, "L": L, "key": key, "ok": false}
return {"errs": errs, "L": L, "key": key, "ok": true}
## 行形态域:层深下限(layers_by_level 引用登记——层深递进尾段取数)/分支密度/配比四类合计 1
func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var layers: int = int(row.get("layers", 0))
if layers < 6:
errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]})
layers_by_level[L] = layers
var bd: float = float(row.get("branch_density", -1.0))
if bd < 0.0 or bd > 1.0:
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
var ratio_sum := 0.0
for rk in RATIO_KEYS:
var v: float = float(row.get(rk, -1.0))
if v < 0.0 or v > 1.0:
errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]})
ratio_sum += v
if not is_equal_approx(ratio_sum, 1.0):
errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]})
return errs
## 行计数域:入口/出口(L1 恒 1)/战斗保底/岔口去路数锁值
func _validate_row_counts(row: Dictionary, L: int, key: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var entry_count: int = int(row.get("entry_count", 0))
if entry_count < 1:
errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]})
var exit_count: int = int(row.get("exit_count", 0))
if exit_count < 1 or exit_count > 3:
errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]})
if L == 1 and exit_count != 1:
errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]})
var bf: int = int(row.get("battle_floor", -1))
if bf < 0:
errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]})
var fmin: int = int(row.get("fork_ways_min", 0))
var fmax: int = int(row.get("fork_ways_max", 0))
if fmin != 2 or fmax != 3:
errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]})
return errs
# 参数档单点读取(键 = level_index,缺行返回空字典——沿 level_row() 体例)
func row_by_level_index(level_index: int) -> Dictionary:
for row in rows:
+5
View File
@@ -170,3 +170,8 @@ static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> v
if (outs[nid] as Array).has(cur) and not seen.has(int(nid)):
seen[int(nid)] = true
queue.append(int(nid))
## 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径)
func level_count() -> int:
return rows.size()
+40 -5
View File
@@ -18,7 +18,15 @@ func validate() -> Array:
for family in ["boss_keystone", "conversion", "attune_refresh_upgrade"]:
if get(family).is_empty():
errs.append({"category": "empty_table", "message": "MetaTable 行族为空: %s" % family})
# boss_keystone:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定)
errs.append_array(_validate_boss_keystone())
errs.append_array(_validate_conversion())
errs.append_array(_validate_attune_refresh())
return errs
## boss_keystone 行族域:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定)
func _validate_boss_keystone() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_li := {}
for row in boss_keystone:
var li: int = int(row.get("level_index", 0))
@@ -36,9 +44,13 @@ func validate() -> Array:
if li_at != i + 1:
errs.append({"category": "structural", "message": "boss_keystone level_index 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, li_at]})
break
# 无 workshop_items 行族(解锁混合制——条件谓词挂 treasure_items 行内;
# 本表不校验秘宝解锁,单点=TreasureItemTable.validate)
# conversion:attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100]
return errs
## conversion 行族域(属性换算):attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100]
## (无 workshop_items 行族——解锁混合制:条件谓词挂 treasure_items 行内;本表不校验秘宝解锁,单点=TreasureItemTable.validate)
func _validate_conversion() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_conv := {}
for row in conversion:
var cattr: String = row.get("attr", "")
@@ -54,7 +66,12 @@ func validate() -> Array:
for conv_key in FORGE_ATTRS:
if not seen_conv.has(conv_key):
errs.append({"category": "coverage", "message": "conversion 缺属性行: %s(三行与 forge 词表一致)" % conv_key})
# attune_refresh_upgrade:level 唯一 + 连续从 1 + charges/cost 域
return errs
## attune_refresh_upgrade 行族域(Y 刷新升级):level 唯一 + 连续从 1 + charges/cost 域
func _validate_attune_refresh() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_al := {}
for row in attune_refresh_upgrade:
var al: int = int(row.get("level", -1))
@@ -73,3 +90,21 @@ func validate() -> Array:
errs.append({"category": "structural", "message": "attune_refresh_upgrade level 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, al_at]})
break
return errs
# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.crossrun.meta.<m> 直连)----
## Boss 钥石两档行单点(键 level_index;缺行=空字典)
func boss_keystone_row(level_index: int) -> Dictionary:
for row in boss_keystone:
if int(row.get("level_index", -1)) == level_index:
return row
return {}
## Y 刷新升级行单点(键 = 等级,缺行返回空字典=未购/已满级合法态)
func attune_refresh_row(level: int) -> Dictionary:
for row in attune_refresh_upgrade:
if int(row.get("level", -1)) == level:
return row
return {}
+94 -53
View File
@@ -39,57 +39,105 @@ func validate() -> Array:
errs.append({"category": "dup_key", "message": "node_id 重复: %s" % nid})
continue
seen[nid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % nid})
if not VEINS.has(String(row.get("vein", ""))):
errs.append({"category": "enum", "message": "%s vein 出域" % nid})
var kind := String(row.get("kind", ""))
if not KINDS.has(kind):
errs.append({"category": "enum", "message": "%s kind 出域" % nid})
if not CURRENCIES.has(String(row.get("currency", ""))):
errs.append({"category": "enum", "message": "%s currency 出域" % nid})
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]})
var cap := int(row.get("cap", 1))
if cap < 1 or cap > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]})
if kind in ["unlock", "socket", "fork", "easter"] and cap != 1:
errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]})
var price := int(row.get("price", 0))
if price < 0 or price > 9999:
errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]})
errs.append_array(_validate_row_head(row, nid))
errs.append_array(_validate_row_numbers(row, nid))
errs.append_array(_validate_row_effects(row, nid))
errs.append_array(_validate_row_fork(row, nid, fork_seen))
errs.append_array(_validate_requires_dangling(rows, seen))
errs.append_array(_validate_requires_level(rows, seen))
# 彩蛋 ≤3
var easter := 0
for row in rows:
if String(row.get("kind", "")) == "easter":
easter += 1
if easter > 3:
errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter})
return errs
## 行头域:命名与枚举判据(display_name/vein/kind/currency/行键白名单)
func _validate_row_head(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % nid})
if not VEINS.has(String(row.get("vein", ""))):
errs.append({"category": "enum", "message": "%s vein 出域" % nid})
var kind := String(row.get("kind", ""))
if not KINDS.has(kind):
errs.append({"category": "enum", "message": "%s kind 出域" % nid})
if not CURRENCIES.has(String(row.get("currency", ""))):
errs.append({"category": "enum", "message": "%s currency 出域" % nid})
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]})
return errs
## 数值域:cap 越域/kind×cap 交叉/price 越域
func _validate_row_numbers(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var kind := String(row.get("kind", ""))
var cap := int(row.get("cap", 1))
if cap < 1 or cap > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]})
if kind in ["unlock", "socket", "fork", "easter"] and cap != 1:
errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]})
var price := int(row.get("price", 0))
if price < 0 or price > 9999:
errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]})
return errs
## 效果域:effects 键词表封闭与局外树层级纪律(archetype 禁入)
func _validate_row_effects(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
for ek in fx.keys():
if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))):
errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]})
# 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命)
if fx.has("archetype"):
errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid})
return errs
## Fork 结构域:一节点双态结构判据(fork_group 词表/组唯一/主 effects 须空/a/b 双键规则面)
func _validate_row_fork(row: Dictionary, nid: String, fork_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var kind := String(row.get("kind", ""))
if kind == "fork":
var fg := String(row.get("fork_group", ""))
if not FORK_GROUPS.has(fg):
errs.append({"category": "enum", "message": "%s fork_group 出域" % nid})
if fork_seen.has(fg):
errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]})
fork_seen[fg] = true
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
for ek in fx.keys():
if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))):
errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]})
# 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命)
if fx.has("archetype"):
errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid})
# Fork 一节点双态结构:fork 行必带 fork_group(每组恰一节点);主 effects 须空,效果只放
# a/b 双键且键 ⊆ RULE_KEYS(A/B 只放规则面——层级纪律)
if kind == "fork":
var fg := String(row.get("fork_group", ""))
if not FORK_GROUPS.has(fg):
errs.append({"category": "enum", "message": "%s fork_group 出域" % nid})
if fork_seen.has(fg):
errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]})
fork_seen[fg] = true
if not fx.is_empty():
errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid})
for skey in ["effects_a", "effects_b"]:
var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary
if sd.is_empty():
errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]})
for k in sd.keys():
if not RULE_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]})
# requires 悬空引用
if not fx.is_empty():
errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid})
for skey in ["effects_a", "effects_b"]:
var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary
if sd.is_empty():
errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]})
for k in sd.keys():
if not RULE_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]})
return errs
## requires 悬空引用域
func _validate_requires_dangling(rows: Array, seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
for row in rows:
for req in (row.get("requires", []) as Array):
if not seen.has(String(req)):
errs.append({"category": "dangling", "message": "%s requires 悬空: %s" % [String(row.get("node_id", "")), String(req)]})
# requires_level 等级阈值前置(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒)
return errs
## requires_level 等级阈值前置域(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒)
func _validate_requires_level(rows: Array, seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
for row in rows:
var nid2: String = String(row.get("node_id", ""))
var rl_v: Variant = row.get("requires_level", {})
@@ -101,13 +149,6 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "%s requires_level 阈值须 ≥1: %s=%d" % [nid2, String(req_id), int(rl_v[req_id])]})
elif row.has("requires_level"):
errs.append({"category": "domain", "message": "%s requires_level 须为 Dictionary {node_id: n}" % nid2})
# 彩蛋 ≤3
var easter := 0
for row in rows:
if String(row.get("kind", "")) == "easter":
easter += 1
if easter > 3:
errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter})
return errs
+114 -69
View File
@@ -30,84 +30,129 @@ func validate() -> Array:
if seen.has(rid):
errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid})
seen[rid] = true
var ctype: String = row.get("cell_type", "")
if not CELL_TYPES.has(ctype):
errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]})
var pol: String = row.get("polarity", "")
if not POLARITIES.has(pol):
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var atk_type: String = String(row.get("attack_type", ""))
if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type):
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]})
# aux 投放行专属校验:极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
# 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable)
if ctype == TREASURE_ITEM_TYPE:
# 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底,
# effect_id 空/power 0/weight 0 不入池)
if pol == "negative":
if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0:
errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid})
continue
if String(row.get("effect_id", "")) == "":
errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid})
if int(row.get("power", 0)) != 0 or row.has("power_min"):
errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid})
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
var power: int = int(row.get("power", 0))
var weight: int = int(row.get("weight", -1))
var tier: int = int(row.get("tier", 0))
if weight < 0 or weight > 999:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var stacks: int = int(row.get("stacks", 1))
if stacks < 1 or stacks > 16:
errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]})
# 跨字段:effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。
# flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)。
var eff: String = row.get("effect_id", "")
if power < 0 or power > 999:
errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]})
if eff == "" and power < 1:
errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid})
# 区间配对(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid})
elif has_min:
var pmin: int = int(row.get("power_min", 0))
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if eff != "":
errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
errs.append_array(_validate_row_axes(row, rid))
var aux_out := _validate_row_aux(row, rid)
errs.append_array(aux_out["errs"])
if aux_out["skip"]:
continue
errs.append_array(_validate_row_pool_tag(row, rid))
errs.append_array(_validate_row_values(row, rid))
errs.append_array(_validate_row_power_range(row, rid))
# 字典完整性 / 池护栏累计(cell 池行)
if pool == "cell" and CELL_TYPES.has(ctype) and POLARITIES.has(pol):
cell_dict["%s|%s" % [ctype, pol]] = true
if pol == "positive" and weight > 0:
if String(row.get("pool_tag", "")) == "cell" and CELL_TYPES.has(String(row.get("cell_type", ""))) and POLARITIES.has(String(row.get("polarity", ""))):
cell_dict["%s|%s" % [String(row.get("cell_type", "")), String(row.get("polarity", ""))]] = true
if String(row.get("polarity", "")) == "positive" and int(row.get("weight", -1)) > 0:
cell_positive_live += 1
if pool == "clear_default" and eff == "" and power >= 1:
if String(row.get("pool_tag", "")) == "clear_default" and String(row.get("effect_id", "")) == "" and int(row.get("power", 0)) >= 1:
clear_value_ok = true
# ① 字典完整性:五型 × 正负两行齐备(缺任一即致命)
errs.append_array(_validate_table_closure(cell_dict, cell_positive_live, clear_value_ok))
return errs
## 轴域:cell_type 五型封闭/polarity 两极/attack_type 可省略枚举
func _validate_row_axes(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var ctype: String = row.get("cell_type", "")
if not CELL_TYPES.has(ctype):
errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]})
var pol: String = row.get("polarity", "")
if not POLARITIES.has(pol):
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var atk_type: String = String(row.get("attack_type", ""))
if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type):
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]})
return errs
## aux 投放行域(掉落投放行):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
## 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable);
## 负占位行返回 skip=true(跳过行剩余判据——原 continue 语义)
func _validate_row_aux(row: Dictionary, rid: String) -> Dictionary:
var errs: Array[Dictionary] = []
var skip := false
if String(row.get("cell_type", "")) == TREASURE_ITEM_TYPE:
# 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底,
# effect_id 空/power 0/weight 0 不入池)
if String(row.get("polarity", "")) == "negative":
if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0:
errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid})
skip = true
return {"errs": errs, "skip": skip}
if String(row.get("effect_id", "")) == "":
errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid})
if int(row.get("power", 0)) != 0 or row.has("power_min"):
errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid})
return {"errs": errs, "skip": skip}
## 池标签域:pool_tag 词表与 clear_<biome> 命名空间预留拒绝/display_name 非空
func _validate_row_pool_tag(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
return errs
## 数值域:weight/tier/stacks/power 越域与 flat 行 power ≥ 1
## (effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。
## flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级))
func _validate_row_values(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var power: int = int(row.get("power", 0))
var weight: int = int(row.get("weight", -1))
var tier: int = int(row.get("tier", 0))
if weight < 0 or weight > 999:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var stacks: int = int(row.get("stacks", 1))
if stacks < 1 or stacks > 16:
errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]})
var eff: String = row.get("effect_id", "")
if power < 0 or power > 999:
errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]})
if eff == "" and power < 1:
errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid})
return errs
## 区间配对域(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚
func _validate_row_power_range(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var power: int = int(row.get("power", 0))
var eff: String = row.get("effect_id", "")
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid})
elif has_min:
var pmin: int = int(row.get("power_min", 0))
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if eff != "":
errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
return errs
## 表闭合域(三尾段):①九型字典完整性(类型×极性齐备)②池空护栏 ③clear_default 恒正当量
func _validate_table_closure(cell_dict: Dictionary, cell_positive_live: int, clear_value_ok: bool) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
for ctype in CELL_TYPE_KEYS:
for pol in POLARITIES:
if not cell_dict.has("%s|%s" % [ctype, pol]):
errs.append({"category": "cross_field", "message": "九型字典缺行: %s %s——(类型, 极性) 齐备为加载前提" % [ctype, pol]})
# ② 池空护栏:cell 池 ≥1 正向行 weight>0(摇号池非空)
if cell_positive_live < 1:
errs.append({"category": "cross_field", "message": "cell 池零活正向行(weight>0)——摇号池空"})
# ③ clear_default 恒正当量(行动经济 条款④——清图奖励 > 0 的行级兑现口径)
if not clear_value_ok:
errs.append({"category": "cross_field", "message": "clear_default 池无正当量活行(清图奖励 > 0 违例)"})
return errs
+52
View File
@@ -133,3 +133,55 @@ func _validate_price_factors(errs: Array[Dictionary]) -> void:
errs.append({"category": "domain", "message": "relic_base_price 越域 [1.0,99.0]: %f" % relic_base_price})
if spend_discount <= 0.0 or spend_discount > 1.0:
errs.append({"category": "domain", "message": "spend_discount 越域 (0.0,1.0]: %f" % spend_discount})
# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.run.shop.<m> 直连)----
## GP 掉落行单点(键 tier;缺行=空字典——掉落按 0 兑付的兜底在调用方)
func gp_drop_row(tier: String) -> Dictionary:
for row in gp_drops:
if String(row.get("tier", "")) == tier:
return row
return {}
## 关卡 GP 加成单点(键 level_index;缺行=0)
func level_gp_bonus_at(level_index: int) -> int:
for row in level_gp_bonus:
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
## 价格关卡加成单点(键 level_index;缺行=0)
func price_level_bonus_at(level_index: int) -> int:
for row in price_level_bonus:
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
## 商品行族({product_id, display_name, attr, flat_value, base_price, price_increment})
func product_rows() -> Array:
return products
## 商品行单点(键 product_id;缺行=空字典)
func product_row(product_id: String) -> Dictionary:
for row in products:
if String(row.get("product_id", "")) == product_id:
return row
return {}
## 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序)
func pool_rows() -> Array:
return pool_shelf
## 货架行单点(键 kind_id;缺行=空字典)
func pool_row(kind_id: String) -> Dictionary:
for row in pool_shelf:
if String(row.get("kind_id", "")) == kind_id:
return row
return {}
+92 -57
View File
@@ -31,73 +31,108 @@ func validate() -> Array:
var seen := {}
var has_basic := false
for row in rows:
var sid: String = row.get("skill_id", "")
if sid == "":
errs.append({"category": "domain", "message": "skill_id 缺失/空"})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(sid):
errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid})
seen[sid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % sid})
var type: String = row.get("type", "")
if not SKILL_TYPES.has(type):
errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]})
continue
_validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动)
if type == "basic" and row.has("trigger"):
errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid})
# 冷却域(全类型):施放后连续 N 拍不可摇中
var cd: int = int(row.get("cooldown_beats", -1))
if cd < 0 or cd > 99:
errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]})
# 普通攻击两条域校验锁死特殊性:倍率恒 1、冷却恒 0——想配的就是伤害技(proposal §2.2)
var sid: String = head["sid"]
var type: String = head["type"]
_validate_common_fields(sid, row, type, errs)
if type == "basic":
has_basic = true
_validate_basic_row(sid, row, cd, errs)
# 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘);
# 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2)
_validate_basic_row(sid, row, int(row.get("cooldown_beats", -1)), errs)
if type == "damage":
if not row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid})
else:
var m: float = float(row.damage_mult)
if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX:
errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid})
if row.has("rider_mult"):
var rm: float = float(row.rider_mult)
if rm <= 0.0 or rm > RIDER_MULT_MAX:
errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]})
# 增益技:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底
_validate_damage_row(sid, row, errs)
if type == "buff":
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid})
var bd: int = int(row.get("buff_duration", 0))
if bd < 1 or bd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]})
# 妨害技:0 伤害施放动作,对主角施加负面状态——
# 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道
_validate_buff_row(sid, row, errs)
if type == "debuff":
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid})
var dbd: int = int(row.get("buff_duration", 0))
if dbd < 1 or dbd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]})
_validate_debuff_row(sid, row, errs)
_validate_table_invariants(has_basic, errs)
return errs
## 行头域:skill_id 非空与查重/display_name/type 枚举(ok=跳过行剩余判据)
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var sid: String = row.get("skill_id", "")
if sid == "":
errs.append({"category": "domain", "message": "skill_id 缺失/空"})
return {"errs": errs, "sid": sid, "type": "", "ok": false}
if seen.has(sid):
errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid})
seen[sid] = true
return {"errs": errs, "sid": sid, "type": "", "ok": false}
seen[sid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % sid})
var type: String = row.get("type", "")
if not SKILL_TYPES.has(type):
errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]})
return {"errs": errs, "sid": sid, "type": type, "ok": false}
return {"errs": errs, "sid": sid, "type": type, "ok": true}
## 公共域(全类型):trigger 触发条件校验/普攻行禁配 trigger/冷却域
func _validate_common_fields(sid: String, row: Dictionary, type: String, errs: Array[Dictionary]) -> void:
_validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动)
if type == "basic" and row.has("trigger"):
errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid})
# 冷却域(全类型):施放后连续 N 拍不可摇中
var cd: int = int(row.get("cooldown_beats", -1))
if cd < 0 or cd > 99:
errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]})
## 伤害技域:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘);
## 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2)
func _validate_damage_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if not row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid})
else:
var m: float = float(row.damage_mult)
if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX:
errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid})
if row.has("rider_mult"):
var rm: float = float(row.rider_mult)
if rm <= 0.0 or rm > RIDER_MULT_MAX:
errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]})
## 增益技域:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底
func _validate_buff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid})
var bd: int = int(row.get("buff_duration", 0))
if bd < 1 or bd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]})
## 妨害技域:0 伤害施放动作,对主角施加负面状态——
## 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道
func _validate_debuff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid})
var dbd: int = int(row.get("buff_duration", 0))
if dbd < 1 or dbd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]})
## 表不变量域:普攻行存在性(池构成纪律的结构前提)与保底旋钮值域
func _validate_table_invariants(has_basic: bool, errs: Array[Dictionary]) -> void:
if not has_basic:
errs.append({"category": "structural", "message": "SkillTable 无普攻行(basic)——池构成纪律的结构前提"})
if pity_threshold < 1 or pity_threshold > 99:
errs.append({"category": "domain", "message": "pity_threshold 越域 [1,99]: %d" % pity_threshold})
return errs
# trigger 域校验(敌人系统 v1.2 触发条件:四键白名单/词表出域/类型分派——枚举事实算子限域 eq/neq
+43 -23
View File
@@ -18,30 +18,12 @@ func validate() -> Array:
return errs
var seen := {}
for row in rows:
var eid: String = row.get("effect_id", "")
if eid == "":
errs.append({"category": "domain", "message": "effect_id 缺失/空"})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(eid):
errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid})
seen[eid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
if not TARGET_SIDES.has(row.get("target_side", "")):
errs.append({"category": "enum", "message": "%s target_side 出域" % eid})
var unit: String = row.get("unit", "")
if not UNITS.has(unit):
errs.append({"category": "enum", "message": "%s unit 出域" % eid})
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
var m: int = int(row.get("magnitude", 0))
# 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1 / ac ∈ [-20,20] 且 ≠0(AC 阈值轴——幅度域独立,防呆上限 20)
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
if unit == "ac" and (m == 0 or m < -20 or m > 20):
errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]})
var eid: String = head["eid"]
errs.append_array(_validate_magnitude_domain(row, eid, head["unit"]))
var dur: int = int(row.get("duration_settlements", 0))
if dur < 1 or dur > 99:
errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]})
@@ -51,6 +33,44 @@ func validate() -> Array:
return errs
## 行头域:effect_id 非空与查重/display_name/target_side/unit/stack_policy 词表
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var eid: String = row.get("effect_id", "")
if eid == "":
errs.append({"category": "domain", "message": "effect_id 缺失/空"})
return {"errs": errs, "eid": eid, "unit": "", "ok": false}
if seen.has(eid):
errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid})
seen[eid] = true
return {"errs": errs, "eid": eid, "unit": "", "ok": false}
seen[eid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
if not TARGET_SIDES.has(row.get("target_side", "")):
errs.append({"category": "enum", "message": "%s target_side 出域" % eid})
var unit: String = row.get("unit", "")
if not UNITS.has(unit):
errs.append({"category": "enum", "message": "%s unit 出域" % eid})
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
return {"errs": errs, "eid": eid, "unit": unit, "ok": true}
## 幅度量纲域(三段量纲判据——percent/flat/ac 各自值域;
## ac = AC 阈值轴效果,非池百分比非定点值——幅度域独立,防呆上限 20)
func _validate_magnitude_domain(row: Dictionary, eid: String, unit: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var m: int = int(row.get("magnitude", 0))
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
if unit == "ac" and (m == 0 or m < -20 or m > 20):
errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]})
return errs
func row_by_id(effect_id: String) -> Dictionary:
for row in rows:
if row.get("effect_id", "") == effect_id:
+185 -126
View File
@@ -73,136 +73,195 @@ func validate() -> Array:
errs.append({"category": "dup_key", "message": "item_id 重复: %s" % iid})
continue
seen[iid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % iid})
var retired: bool = bool(row.get("retired", false))
var deferred: bool = bool(row.get("deferred", false))
# dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留)
var tags: Array[Variant] = row.get("dimension_tags", [])
if tags.is_empty():
errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid})
for tag in tags:
if not DIMENSIONS.has(String(tag)):
errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]})
var kinds: Array[Variant] = row.get("effect_kinds", [])
var target: String = String(row.get("target", "none"))
if not TARGETS.has(target):
errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]})
var phase: String = String(row.get("use_phase", "active_dig"))
if not USE_PHASES.has(phase):
errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]})
# 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项)
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]})
# triggers 事件订阅行(被动件形态;与 effect_kinds 互斥校验
# 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存
# (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例))
if row.has("triggers"):
if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)):
errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid})
EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs)
var form: String = String(row.get("form", ""))
if not FORMS.has(form):
errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]})
if retired or deferred:
errs.append_array(_validate_row_head(row, iid))
if bool(row.get("retired", false)) or bool(row.get("deferred", false)):
# 退役/失义行:免 effect_kinds 非空与承重护栏校验(登记面保留,不入投放与 F2)
continue
# ---- 活行校验 ----
if kinds.is_empty() and not row.has("triggers"):
errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid})
var kind_seen := {}
for k in kinds:
var kind: String = String(k)
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]})
if kind_seen.has(kind):
errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]})
kind_seen[kind] = true
# 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷;
# reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内)
for rv in REVEAL_KINDS:
if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"):
errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]})
# 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果
if target == "board_wide" and kind_seen.has("mine_reveal"):
errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid})
# magnitude:MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind)
var mag: int = int(row.get("magnitude", 0))
if mag < 0 or mag > 999:
errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]})
var mag_consuming := false
for k in kind_seen.keys():
if MAGNITUDE_KINDS.has(String(k)):
mag_consuming = true
if mag_consuming and mag < 1:
errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]})
if not mag_consuming and mag != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]})
# ---- 同调行约束(form 非空——装配面;过渡行 form="" 不校验)----
errs.append_array(_validate_row_active(row, iid, kind_seen))
var form: String = String(row.get("form", ""))
if form != "":
if int(row.get("slot_cost", 1)) != 1:
errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid})
# 档位必填(槽位档位约束数据面:件档≤格档判定的取数源)
var tier: String = String(row.get("tier", ""))
if not TIERS.has(tier):
errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]})
var charges: int = int(row.get("charges_per_adventure", 0))
if form == "passive" and charges != 0:
errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges < 0:
errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges == 0:
# 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限):
# 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行
var has_gate := kind_seen.has("ap_charge_safe")
if not has_gate and row.has("triggers"):
for tr_v in (row.get("triggers", []) as Array):
if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend":
has_gate = true
break
if not has_gate:
errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid})
# 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设)
var creset: String = String(row.get("charge_reset", "adventure"))
if not CHARGE_RESETS.has(creset):
errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]})
if form != "active_charged" and row.has("charge_reset") and creset != "adventure":
errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]})
# 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做)
var price: int = int(row.get("unlock_keystone_cost", 0))
var ureq_count: int = (row.get("unlock_requires", []) as Array).size()
if price != 0 and ureq_count > 0:
errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid})
if price < 0 or price > 999:
errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]})
var eff_rounds: int = int(row.get("effective_rounds", 0))
if eff_rounds < 0 or eff_rounds > 999:
errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]})
if kind_seen.has("ap_rounds_front") and eff_rounds < 1:
errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]})
if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]})
var charge_n: int = int(row.get("charge_n", 0))
if charge_n < 0 or charge_n > 999:
errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]})
if kind_seen.has("ap_charge_safe") and charge_n < 1:
errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]})
if not kind_seen.has("ap_charge_safe") and charge_n != 0:
errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]})
# unlock_requires 谓词行:空 = 已解锁(首批全解锁合法态,底稿 §2.2);非空键 ∈ 计数键封闭枚举
var ureqs: Array[Variant] = row.get("unlock_requires", [])
var ukeys := {}
for req in ureqs:
var ukey: String = String(req.get("key", ""))
if not UNLOCK_COUNTER_KEYS.has(ukey):
errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]})
if ukeys.has(ukey):
errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]})
ukeys[ukey] = true
var uth: int = int(req.get("threshold", 0))
if uth < 1 or uth > 999:
errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]})
errs.append_array(_validate_row_attunement(row, iid, kind_seen))
errs.append_array(_validate_row_unlock_costs(row, iid))
errs.append_array(_validate_row_effect_numbers(row, iid, kind_seen))
errs.append_array(_validate_row_unlock_requires(row, iid))
return errs
## 行头域:display_name/维度标签/target/use_phase/行键白名单/triggers 订阅形态/form 词表
func _validate_row_head(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % iid})
# dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留)
var tags: Array[Variant] = row.get("dimension_tags", [])
if tags.is_empty():
errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid})
for tag in tags:
if not DIMENSIONS.has(String(tag)):
errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]})
var target: String = String(row.get("target", "none"))
if not TARGETS.has(target):
errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]})
var phase: String = String(row.get("use_phase", "active_dig"))
if not USE_PHASES.has(phase):
errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]})
# 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项)
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]})
errs.append_array(_validate_row_triggers(row, iid))
var form: String = String(row.get("form", ""))
if not FORMS.has(form):
errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]})
return errs
## 订阅形态域:triggers 事件订阅行判据与行级触发词表校验(被动件形态;与 effect_kinds 互斥校验
## 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存
## (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例))
func _validate_row_triggers(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if row.has("triggers"):
if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)):
errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid})
EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs)
return errs
## 活行域:effect_kinds 非空与词表/真视窄域与 board_wide 承重护栏/magnitude 语义(kind_seen 引用填充——同调域消费)
func _validate_row_active(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var kinds: Array[Variant] = row.get("effect_kinds", [])
var target: String = String(row.get("target", "none"))
if kinds.is_empty() and not row.has("triggers"):
errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid})
for k in kinds:
var kind: String = String(k)
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]})
if kind_seen.has(kind):
errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]})
kind_seen[kind] = true
# 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷;
# reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内)
for rv in REVEAL_KINDS:
if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"):
errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]})
# 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果
if target == "board_wide" and kind_seen.has("mine_reveal"):
errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid})
errs.append_array(_validate_row_magnitude(row, iid, kind_seen))
return errs
## N 值语义域:magnitude 越域与 MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind)
func _validate_row_magnitude(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var mag: int = int(row.get("magnitude", 0))
if mag < 0 or mag > 999:
errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]})
var mag_consuming := false
for k in kind_seen.keys():
if MAGNITUDE_KINDS.has(String(k)):
mag_consuming = true
if mag_consuming and mag < 1:
errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]})
if not mag_consuming and mag != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]})
return errs
## 同调充能与档位域(form 非空——装配面;过渡行 form="" 不校验):槽位统一/档位必填/充能值域与积点制受理闸/充能重置周期
func _validate_row_attunement(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
if int(row.get("slot_cost", 1)) != 1:
errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid})
# 档位必填(槽位档位约束数据面:件档≤格档判定的取数源)
var tier: String = String(row.get("tier", ""))
if not TIERS.has(tier):
errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]})
var charges: int = int(row.get("charges_per_adventure", 0))
var form: String = String(row.get("form", ""))
if form == "passive" and charges != 0:
errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges < 0:
errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges == 0:
# 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限):
# 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行
errs.append_array(_validate_row_charge_gate(row, iid, kind_seen))
# 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设)
var creset: String = String(row.get("charge_reset", "adventure"))
if not CHARGE_RESETS.has(creset):
errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]})
if form != "active_charged" and row.has("charge_reset") and creset != "adventure":
errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]})
return errs
## 积点制受理闸域:charges=0 的 active_charged 行必须带使用闸(ap_charge_safe 滑耗或 counter_spend 受理闸)
func _validate_row_charge_gate(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var has_gate := kind_seen.has("ap_charge_safe")
if not has_gate and row.has("triggers"):
for tr_v in (row.get("triggers", []) as Array):
if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend":
has_gate = true
break
if not has_gate:
errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid})
return errs
## 解锁互斥域(form 非空行,契约 6):钥石购买价与条件谓词互斥——每件单解锁方式,混合不做
func _validate_row_unlock_costs(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
# 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做)
var price: int = int(row.get("unlock_keystone_cost", 0))
var ureq_count: int = (row.get("unlock_requires", []) as Array).size()
if price != 0 and ureq_count > 0:
errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid})
if price < 0 or price > 999:
errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]})
return errs
## 效果数值域(form 非空行):effective_rounds 与 charge_n 的值域及 kind 交叉
func _validate_row_effect_numbers(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var eff_rounds: int = int(row.get("effective_rounds", 0))
if eff_rounds < 0 or eff_rounds > 999:
errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]})
if kind_seen.has("ap_rounds_front") and eff_rounds < 1:
errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]})
if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]})
var charge_n: int = int(row.get("charge_n", 0))
if charge_n < 0 or charge_n > 999:
errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]})
if kind_seen.has("ap_charge_safe") and charge_n < 1:
errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]})
if not kind_seen.has("ap_charge_safe") and charge_n != 0:
errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]})
return errs
## 谓词行域:unlock_requires 计数键封闭枚举与阈值(空 = 已解锁——首批全解锁合法态,底稿 §2.2)
func _validate_row_unlock_requires(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var ureqs: Array[Variant] = row.get("unlock_requires", [])
var ukeys := {}
for req in ureqs:
var ukey: String = String(req.get("key", ""))
if not UNLOCK_COUNTER_KEYS.has(ukey):
errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]})
if ukeys.has(ukey):
errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]})
ukeys[ukey] = true
var uth: int = int(req.get("threshold", 0))
if uth < 1 or uth > 999:
errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]})
return errs
+109 -49
View File
@@ -26,57 +26,82 @@ func validate() -> Array:
var errs: Array[Dictionary] = []
var seen := {}
for row in rows:
var vid: String = row.get("variant_id", "")
if vid == "":
errs.append({"category": "domain", "message": "variant_id 缺失/空"})
var head := _validate_row(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(vid):
errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid})
var vid: String = head["vid"]
errs.append_array(_validate_effect_interlocks(row, vid))
errs.append_array(_validate_global_interlocks(row, vid))
return errs
## 行域:variant_id 非空与查重/display_name/unlock_level/四词表(scope/effect/trigger/placement)
func _validate_row(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var vid: String = row.get("variant_id", "")
if vid == "":
errs.append({"category": "domain", "message": "variant_id 缺失/空"})
return {"errs": errs, "vid": vid, "ok": false}
if seen.has(vid):
errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid})
seen[vid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % vid})
var ul: int = int(row.get("unlock_level", 0))
if ul < 1 or ul > 999:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not EFFECTS.has(row.get("effect", "")):
errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]})
if not TRIGGERS.has(row.get("trigger", "")):
errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]})
if not PLACEMENTS.has(row.get("placement", "")):
errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]})
# 跨字段互锁(加载期致命——枚举语义一致性立闸):
# 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数
if row.get("effect", "") == "flat_damage":
if row.get("trigger", "") != "on_open":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid})
if row.get("placement", "") != "closed_only":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid})
var ev: int = int(row.get("effect_value", 0))
if ev < 1 or ev > 9:
errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]})
# 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown
if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown":
errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid})
# 信息遮蔽型只允许落安全格(遮蔽对象是数字;危险格无数字可遮)——placement=any 时由读取方
# 呈现面按 kind=safe 自然短路,此处不设布置约束
# 全局作用域型互锁(resource_face ⟺ scope=global 三条 + 新值域;
# 判定键=effect/scope 词值,既有六 region 行(flat_damage/hide_number)不入本组——零误伤)
if row.get("effect", "") == "resource_face":
if row.get("scope", "") != "global":
errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid})
# resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用)
var gev: int = int(row.get("effect_value", 0))
if gev < 0 or gev > 50:
errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]})
if row.get("scope", "") == "global":
if row.get("effect", "") != "resource_face":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid})
if row.get("placement", "") != "any":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid})
if row.get("trigger", "") != "always":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid})
return {"errs": errs, "vid": vid, "ok": false}
seen[vid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % vid})
var ul: int = int(row.get("unlock_level", 0))
if ul < 1 or ul > 999:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not EFFECTS.has(row.get("effect", "")):
errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]})
if not TRIGGERS.has(row.get("trigger", "")):
errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]})
if not PLACEMENTS.has(row.get("placement", "")):
errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]})
return {"errs": errs, "vid": vid, "ok": true}
## 效果互锁域(加载期致命——枚举语义一致性立闸):
## 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数;
## 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown
## (信息遮蔽型只允许落安全格——遮蔽对象是数字,危险格无数字可遮;placement=any 时由读取方
## 呈现面按 kind=safe 自然短路,此处不设布置约束)
func _validate_effect_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if row.get("effect", "") == "flat_damage":
if row.get("trigger", "") != "on_open":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid})
if row.get("placement", "") != "closed_only":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid})
var ev: int = int(row.get("effect_value", 0))
if ev < 1 or ev > 9:
errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]})
if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown":
errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid})
return errs
## 全局作用域互锁域(resource_face ⟺ scope=global;判定键=effect/scope 词值,
## 既有六 region 行(flat_damage/hide_number)不入本组——零误伤);
## resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用)
func _validate_global_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if row.get("effect", "") == "resource_face":
if row.get("scope", "") != "global":
errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid})
var gev: int = int(row.get("effect_value", 0))
if gev < 0 or gev > 50:
errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]})
if row.get("scope", "") == "global":
if row.get("effect", "") != "resource_face":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid})
if row.get("placement", "") != "any":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid})
if row.get("trigger", "") != "always":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid})
return errs
@@ -85,3 +110,38 @@ func row_by_id(variant_id: String) -> Dictionary:
if row.get("variant_id", "") == variant_id:
return row
return {}
# ---- 候选池访问器(自 ConfigDB 归位;消费面 db.combat.variation.<m> 直连)----
# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池,
# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。
# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。
## 候选池门控(∅ board_types = 通配;字段名对齐 board_type 词表,无 biome_tags)
func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array:
var pool: Array[Dictionary] = []
for row in rows:
if String(row.get("scope", "region")) == "global" \
and node_type != "boss" and node_type != "elite":
continue
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array[Variant] = row.get("board_types", [])
if tags.is_empty():
pool.append(row)
continue
if tags.has(board_type):
pool.append(row)
return pool
## 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表):
## boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态,
## 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态)
func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary:
if node_type != "boss" and node_type != "elite":
return {}
for row in variant_pool(level_index, board_type, node_type):
if String(row.get("scope", "region")) == "global":
return row
return {}
+20 -24
View File
@@ -1,11 +1,12 @@
[gd_resource type="Resource" script_class="AdventureTable" load_steps=2 format=3]
[gd_resource type="Resource" script_class="AdventureTable" load_steps=5 format=3]
[ext_resource type="Script" path="res://data/schemas/adventure_table.gd" id="1"]
[ext_resource type="Script" path="res://data/schemas/adventure_quota_segment.gd" id="2"]
[ext_resource type="Script" path="res://data/schemas/adventure_value_segment.gd" id="3"]
[ext_resource type="Script" path="res://data/schemas/adventure_cap_segment.gd" id="4"]
[resource]
script = ExtResource("1")
clear_heal_pct = 20
curio_candidate_count = 3
[sub_resource type="Resource" id="AdventureTable_quota"]
script = ExtResource("2")
curio_quota_attack_common = 18
curio_quota_attack_excellent = 5
curio_quota_max_hp_common = 17
@@ -17,6 +18,9 @@ curio_quota_gp_excellent = 5
curio_quota_heal_excellent = 3
curio_quota_refresh_excellent = 2
curio_quota_ap_rare = 3
[sub_resource type="Resource" id="AdventureTable_value"]
script = ExtResource("3")
curio_value_attack_common = 1
curio_value_attack_excellent = 2
curio_value_max_hp_common = 5
@@ -28,26 +32,18 @@ curio_value_gp_excellent = 50
curio_value_heal_excellent = 6
curio_value_refresh_excellent = 1
curio_value_ap_rare = 1
[sub_resource type="Resource" id="AdventureTable_cap"]
script = ExtResource("4")
[resource]
script = ExtResource("1")
clear_heal_pct = 20
curio_candidate_count = 3
quota_segment = SubResource("AdventureTable_quota")
value_segment = SubResource("AdventureTable_value")
cap_segment = SubResource("AdventureTable_cap")
feat_ticket_chance = 20
feat_ticket_quota = 2
feat_ticket_limit = 1
feat_ticket_pity_step = 5
; 迭代 20 T2 池结构案D 换代(2026-10-11,奇物.md 池结构与抽取 v1):四级档位 70/25/7/0 = 102 枚——
; 稀有档投放三 kind=AP 件 3/储备金袋 2/时沙 2(ap_rare 显式载于本表,与 schema 缺省同值);
; 概率与上限解耦(案②令牌池+提前退役):概率位=档+令牌数、已拿上限=curio_cap_*(拿了才计);
; 曝光槽=三选一 2 格全池+1 格优秀档以上子池(子池空回退全池);凭证不进子池。
; 前史 TP0 令牌池换代(2026-10-03,node-reward v2.2 追认 b 落地):三选一池 = 固定配额令牌池——
; 洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭;
; 种类次数上限退役(池消耗即上限;货架库存同账本=牌库剩余令牌数)。
; 档位构成(现行):普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+ 优秀〔大GP/大增伤/大护甲/大生命 各 5+恢复 3+刷新件 2〕
; + 稀有〔AP 件 3/储备金袋 2/时沙 2〕。
; 数值对:小GP 20/大GP 50、小增伤 +1/大 +2、小护甲 +1/大 +2、小生命 +5/大 +10、恢复 +6(当即回血)、AP +1。
; 恢复件 = 新入池种类(优秀档)。
; 退役字段:bonus_weight_*(E-S 权重族)/ bonus_cap_*(种类次数上限族)/ BONUS_CAP_UNLIMITED 哨兵——
; 洗牌改令牌 Fisher-Yates(不放回抽 3,消费 reward_pick 封闭流)。
; 迭代 11 第②批 商店共享池注记:货架 = 令牌级抽签(同 kind 去重每 kind 至多一卡、价格公式 tier-blind
; ceil(基价×b^(关序−1)×A^visit) 维持批②裁定、购买消费在架令牌本体)。
; 末关的关卡完成不发三选一与回血(奖励只朝下一战生效)。
; 全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss;档位配额结构锚 70/25/7/0——
; schema TIER_QUOTAS,validate 硬断言,改总量须重裁)。
+1 -1
View File
@@ -73,7 +73,7 @@ static func boss_name(runner: Variant, db: ConfigDb) -> String:
if runner != null:
var node: Dictionary[Variant, Variant] = runner.current_node()
if String(node.get("type", "")) == "boss":
var rowset: Array[Variant] = db.resolve_enemy_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss")
var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss")
if not rowset.is_empty():
return String(rowset[0].get("display_name", ""))
return "遗迹守卫"
+4 -4
View File
@@ -19,8 +19,8 @@ func _init(db_ref: ConfigDb, debug_overlay_ref: DebugOverlay, settlement_getter:
# 禁表写路径(双写叠加隐患源头);
# 合成式单点在 UiKit.scaled——与调试浮窗生效值预览共用,本类不持有同式私有实现)
func debug_hero_override() -> Dictionary:
return {"atk": UiKit.scaled(int(_db.hero.atk), _debug_overlay.atk_pct()),
"max_hp": UiKit.scaled(int(_db.hero.max_hp), _debug_overlay.hp_pct())}
return {"atk": UiKit.scaled(int(_db.combat.hero.atk), _debug_overlay.atk_pct()),
"max_hp": UiKit.scaled(int(_db.combat.hero.max_hp), _debug_overlay.hp_pct())}
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音;
@@ -45,7 +45,7 @@ func variant_mask_lookup(c: Vector2i) -> bool:
if b == null:
return false
for vid in b.variant_ids(c):
var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
@@ -61,7 +61,7 @@ func variant_mark_lookup(c: Vector2i) -> String:
if b == null:
return ""
for vid in b.variant_ids(c):
var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "flat_damage":
return "flat_damage"
return ""
+8 -8
View File
@@ -59,12 +59,12 @@ func on_camp_closed() -> void:
func camp_data() -> Dictionary:
var meta = _meta_get.call()
return {
"balance": meta.keystone_balance() if meta != null else 0,
"balance": meta.meta_state.keystone_balance() if meta != null else 0,
"workshop": meta.treasure_shop_status() if meta != null else [], # 键名沿 workshop(视图消费键不变——行内容=秘宝匣 12 件解锁投影)
"forge": meta.forge_shop_status() if meta != null else [], # 锻造工坊两区六路线投影
"attune": meta.attune_camp_status() if meta != null else {}, # 同调装配面投影
"tree": meta.tree_camp_rows() if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行)
"fork_groups": meta.tree_fork_groups() if meta != null else [],
"tree": meta.tree_camp_status()["rows"] if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行)
"fork_groups": meta.tree_camp_status()["fork_groups"] if meta != null else [],
"recent": _save_get.call().recent_summaries() if _save_get.call() != null else [],
}
@@ -107,7 +107,7 @@ func on_camp_tree_buy(node_id: String) -> void:
var meta = _meta_get.call()
if meta == null:
return
var ok: bool = meta.tree_buy_fork(node_id) if String(db.tree_node(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id)
var ok: bool = meta.tree_buy_fork(node_id) if String(db.crossrun.meta_tree.row_by_id(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id)
if not ok:
toast.show_toast("购买被拒——前置/行门槛/余额不足", "warn")
return
@@ -157,8 +157,8 @@ func on_camp_forge_select(route_id: String) -> void:
var meta = _meta_get.call()
if meta == null:
return
var ok: bool = meta.forge_select_weapon(route_id) if String(db.forge_route(route_id).get("kind", "")) == "weapon" \
else meta.forge_select_armor(route_id)
var ok: bool = meta.forge_select("weapon", route_id) if String(db.crossrun.forge.route(route_id).get("kind", "")) == "weapon" \
else meta.forge_select("armor", route_id)
if not ok:
toast.show_toast("切换被拒——路线未解锁", "warn")
return
@@ -177,7 +177,7 @@ func on_shop_buy(product_id: String) -> void:
toast.show_toast("GP不足——买不起这件商品", "warn")
return
shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp))
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行
shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost())
_refresh_presentations.call()
return
@@ -199,7 +199,7 @@ func on_shop_refresh() -> void:
toast.show_toast("GP不足——刷新失败", "warn")
return
shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp))
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行
shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost())
_refresh_presentations.call()
@@ -165,9 +165,9 @@ func _curio_card_texts(candidates: Array) -> Array:
text = "专长获取
选中或买下后立刻开一次专长三选一"
elif kind == "relic":
text = Narrator.feat_card_text(db.feat_row(rid))
text = Narrator.feat_card_text(db.crossrun.feats.row_by_id(rid))
else:
text = Narrator.bonus_card_text(kind, tier, int(db.adventure.curio_value(kind, tier)))
text = Narrator.bonus_card_text(kind, tier, int(db.run.adventure.curio_value(kind, tier)))
out.append({"id": cid, "text": text})
return out
@@ -251,6 +251,6 @@ func item_name_lookup() -> Dictionary:
var lookup := {}
var meta = _meta_get.call()
if meta != null:
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
lookup[String(row.get("item_id", ""))] = String(row.get("display_name", ""))
return lookup
+15 -15
View File
@@ -79,7 +79,7 @@ static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Di
# ---- 行动经济(AP 点阵 / RAP / 弃图刷新按钮态——RAP 读数在本条) ----
static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, refresh_remaining: int) -> void:
ap_bar.set_ap(int(s.ap), int(db.action_economy.ap_per_round))
ap_bar.set_ap(int(s.ap), int(db.run.action_economy.ap_per_round))
ap_bar.set_rap(int(s.rap))
var digging := String(s.phase) == ActionEconomy.PHASE_ACTIVE
ap_bar.set_refresh_state(refresh_remaining,
@@ -92,11 +92,11 @@ static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, ref
# 无 archetype 字段,聚合形态会空键兜底实显「流派 ×N · 档一」僵尸文案;流派档位非现行机制)
static func feat_holdings_lines(db: ConfigDb, held: Array) -> Array[String]:
var rlines: Array[String] = []
if db == null or db.feats == null or (db.feats.rows as Array).is_empty() or held.is_empty():
if db == null or db.feats == null or (db.crossrun.feats.rows as Array).is_empty() or held.is_empty():
return rlines
var counts := {}
for rid in held:
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(rid))
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(rid))
var fname := String(rrow.get("display_name", String(rid)))
counts[fname] = int(counts.get(fname, 0)) + 1
for fname in counts.keys():
@@ -112,7 +112,7 @@ static func board_variant_lines(db: ConfigDb, ids: Array) -> Array[String]:
if db == null or db.variation == null:
return lines
for vid in ids:
var row: Dictionary[Variant, Variant] = db.variation.row_by_id(String(vid))
var row: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(String(vid))
if not row.is_empty():
lines.append(String(row.get("display_name", vid)))
lines.append("回复与护甲资源减少")
@@ -229,7 +229,7 @@ static func _p90(a: Array) -> float:
# (两入口开层时 runner 恒在役——状态面板卡 F3 证据链)
static func status_data(db: ConfigDb, meta: MetaProgressionCore, runner: Variant, treasure_ui: Variant) -> Dictionary:
return {
"balance": meta.keystone_balance(),
"balance": meta.meta_state.keystone_balance(),
"hero_hp": int(runner.hero_hp), # LevelRunner 属性面(_ledger 直取下标——键缺失=致命)
"max_hp": runner.effective_max_hp(),
"atk": runner.effective_atk(),
@@ -257,20 +257,20 @@ static func status_curios(treasure_ui: Variant) -> Array:
static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaProgressionCore) -> Dictionary:
var clears: Array[Dictionary] = []
var boss_first := 0
for i in db.level_count():
for i in db.run.level.level_count():
var li: int = i + 1
clears.append({"display_name": String(db.level_row(li).get("display_name", "")),
"count": save_manager.clear_count(li)})
if save_manager.boss_first_kill_marked(li):
clears.append({"display_name": String(db.run.level.row_by_level_index(li).get("display_name", "")),
"count": save_manager.meta_state.career_state().clear_counts.get(str(li), 0)})
if save_manager.meta_state.career_state().boss_first_kill.has(str(li)):
boss_first += 1
return {
"total": save_manager.adventure_total(),
"cleared": save_manager.adventure_cleared(),
"total": save_manager.meta_state.career_state().adventure_total,
"cleared": save_manager.meta_state.career_state().adventure_cleared,
"clears": clears,
"boss_first_kills": boss_first,
"boss_first_total": db.level_count(),
"boss_kills": save_manager.boss_kill_count(),
"keystone": meta.keystone_balance(),
"boss_first_total": db.run.level.level_count(),
"boss_kills": save_manager.meta_state.career_state().boss_kill_count,
"keystone": meta.meta_state.keystone_balance(),
}
@@ -280,7 +280,7 @@ static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaP
static func save_select_data(save_manager: SaveManagerCore) -> Dictionary:
var slots: Array[Dictionary] = []
for i in range(1, save_manager.SLOT_COUNT + 1):
var p := save_manager.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒)
var p := save_manager.slots.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒)
slots.append({"slot": i,
"occupied": bool(p.get("has_file", false)) and bool(p.get("readable", false)),
"keystone": int(p.get("keystone", 0)),
+9 -9
View File
@@ -312,7 +312,7 @@ func _on_confirm_accepted() -> void:
_close_modal(confirm_dialog) # 模态栈单口
match _confirm_consumer:
"delete_slot":
save_manager.delete_slot(_confirm_consumer_slot)
save_manager.slots.delete_slot(_confirm_consumer_slot)
save_select_overlay.open_save_select(HudPresenter.save_select_data(save_manager)) # 就地刷新(该槽回空态)
_confirm_consumer = ""
@@ -366,9 +366,9 @@ func _wire_adventure() -> void:
static func build_adventure_params(save_manager_ref: SaveManagerCore, meta_ref: MetaProgressionCore, extra: Dictionary = {}) -> Dictionary:
var params: Dictionary[Variant, Variant] = {
"hero_override": meta_ref.hero_override() if meta_ref != null else {},
"attack_type": meta_ref.hero_attack_type() if meta_ref != null else "",
"armor_type": meta_ref.hero_armor_type() if meta_ref != null else "",
"attunement": save_manager_ref.attunement_equipped() if save_manager_ref != null else [],
"attack_type": meta_ref.hero_type_axes()["attack"] if meta_ref != null else "",
"armor_type": meta_ref.hero_type_axes()["armor"] if meta_ref != null else "",
"attunement": save_manager_ref.meta_state.attune_state().equipped if save_manager_ref != null else [],
"attune_refresh_y": meta_ref.attune_refresh_charges() if meta_ref != null else 0,
"tree_rules": meta_ref.tree_rule_keys() if meta_ref != null else [],
}
@@ -447,7 +447,7 @@ func _on_battle_finished(payload: Dictionary) -> void:
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)]
int(decision.layer), int(decision.carry_hp), int(db.combat.hero.max_hp)]
_inter_overlay.visible = true
_refresh_presentations()
return
@@ -772,11 +772,11 @@ func _on_refresh_confirmed() -> void:
logger.log_action("refresh", Vector2i(-1, -1), ok)
if ok:
logger.log_ux("ux_refresh_attempt", {"result": "accepted", "round_refresh_index": _refresh_used_snapshot()})
board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.action_economy.rap_refresh_cost), "gold")
board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.run.action_economy.rap_refresh_cost), "gold")
else:
# 资源面/上限拒绝(EC13 合法拒绝可见;行动经济 无载荷——客户端按上下文推断)
var rap: int = int(run_scope.settlement.presentation_snapshot().get("rap", 0)) if run_scope.settlement != null else 0
var cost: int = int(db.action_economy.rap_refresh_cost)
var cost: int = int(db.run.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_snapshot() + 1})
if result == "rejected_rap":
@@ -811,7 +811,7 @@ func _refresh_used_snapshot() -> int:
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_cap_per_round) - _refresh_used_snapshot())
var refresh_remaining: int = maxi(0, int(db.run.action_economy.refresh_cap_per_round) - _refresh_used_snapshot())
HudPresenter.refresh(stage, info_panel, ap_bar, run_scope.settlement, run_scope.runner, run_scope.level_active, db, refresh_remaining)
_treasure_ui.refresh_slots() # C 区秘宝槽重查(试炼镐积点进度随挖格刷新悬停浮窗)
_sync_settlement_visuals()
@@ -906,6 +906,6 @@ func _seed_value() -> int:
# 封闭流、禁 randomize()/自建 RNG——组合根建种子属引擎熵豁免面,不进玩法 RNG 消费)
if _seed_edit.text.is_valid_int():
return int(_seed_edit.text)
if save_manager.is_first_adventure_done():
if save_manager.meta_state.career_state().first_adventure_done:
return randi() # rng-seed-exempt(非首局空框引擎熵——组合根建种子不进玩法 RNG 消费,static_scan S1/S2 标记豁免)
return 42
+1 -1
View File
@@ -80,7 +80,7 @@ static func _on_runner_node(root: Variant, node: Dictionary, info: Dictionary) -
root._open_modal(root.shop_overlay)
if root.run_scope.runner.is_shop_pool():
root.shop_overlay.open_shelf(root.run_scope.runner.shop_shelf(), int(root.run_scope.runner.gp))
root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.shop_price_a())) # 爬价规则行
root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.run.shop.price_a)) # 爬价规则行
root.shop_overlay.set_refresh_display(root.run_scope.runner.shop_refresh_free_left(), root.run_scope.runner.shop_refresh_cost(), int(root.run_scope.runner.gp) >= root.run_scope.runner.shop_refresh_cost()) # MC-1
else:
root.shop_overlay.open_shop(root.run_scope.runner.shop_products(), int(root.run_scope.runner.gp))
+2 -2
View File
@@ -68,14 +68,14 @@ static func _on_save_select_closed(root: Variant) -> void:
# 空槽新档链(Q3 裁乙暂行落点=现役 CampOverlay;卡④ 重立卡后改挂场景图):卡① 创建=激活=白板落盘,
# 落点开层。动线序(禁先关后开):先落点开层(开始层保持隐藏),选档层被模态栈遮挡隐藏、不关闭。
static func _on_save_select_new(root: Variant, slot: int) -> void:
root.save_manager.create_slot(slot)
root.save_manager.slots.create_slot(slot)
root._camp_pres.open_camp()
# 有档槽进档链(契约 8 进档即激活):卡① 激活+读盘(兜底/拒读链按槽成立)→暂行落点 CampOverlay。
static func _on_save_select_load(root: Variant, slot: int) -> void:
root.save_manager.activate_slot(slot)
root.save_manager.slots.activate_slot(slot)
root._camp_pres.open_camp()
+3 -3
View File
@@ -81,7 +81,7 @@ static func _build_battle_wiring(root: Variant) -> void:
root.board_zone.chord_attempted.connect(root._on_chord_attempted)
root.logger = PlaytestLogger.new()
root.logger.ap_per_round = int(root.db.action_economy.ap_per_round)
root.logger.ap_per_round = int(root.db.run.action_economy.ap_per_round)
static func _build_overlays(root: Variant) -> void:
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局
@@ -193,7 +193,7 @@ static func _build_debug_and_resolvers(root: Variant) -> void:
# 诊断转储入口在本浮窗,玩家画面零常驻调试件)。
# 树序在开始层与确认弹窗之后 = 浮于菜单之上的 Dialog(不隐藏开始层——与确认弹窗同款形态);
# 开窗唯一入口 = _show_debug_overlay(C6 单口——角标/探针/启动路径全收口)
root.debug_overlay = DebugOverlay.new(root.db, int(root.db.hero.atk), int(root.db.hero.max_hp))
root.debug_overlay = DebugOverlay.new(root.db, int(root.db.combat.hero.atk), int(root.db.combat.hero.max_hp))
root.add_child(root.debug_overlay)
root._seed_edit = root.debug_overlay.seed_edit # 种子读取面在浮窗(_seed_value 唯一消费点不变)
root.debug_overlay.closed.connect(root._on_modal_closed.bind(root.debug_overlay))
@@ -235,7 +235,7 @@ static func _build_modal_flow(root: Variant) -> void:
root._modals = root._modal_flow.stack # 探针 _list 登记序断言缝(ui_struct 登记序=z 序纪律断言)
# 视觉层合同装配(唯一事实源=LAYER_CONTRACT:树序+登记序同源派生——改序只改合同表)
root._apply_layer_contract()
root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.hero.max_hp))
root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.combat.hero.max_hp))
static func fatal_screen(root: Variant, msg: String) -> void:
var l := UiKit.label(msg,UiKit.FS_SUB, true, UiKit.RED_ALERT)
@@ -44,9 +44,9 @@ func unlocked_set() -> Dictionary:
var out := {}
if meta == null:
return out
for row in db.treasure_transitional_rows():
for row in db.crossrun.treasure_items.transitional_rows():
var iid := String(row.get("item_id", ""))
if meta.is_item_unlocked(iid):
if meta.meta_state.item_unlocked(iid):
out[iid] = true
return out
@@ -79,7 +79,7 @@ func on_slot_clicked(slot_handle: int) -> void:
break
if rec.is_empty() or bool(rec.get("used", false)):
return
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", "")))
if String(arow.get("target", "none")) == "none":
attempt_use(slot_handle) # AP 类:显式无目标即用(不再伪造哨兵坐标)
return
@@ -106,7 +106,7 @@ func attempt_use(handle: int, cell: Variant = null) -> void:
refresh_slots()
refresh_overlays()
board_view.refresh() # 移除格即时落图(无移除态的件 diff 零变化幂等)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.get("item_id", "")))
toast.show_toast("已使用 %s" % String(arow.get("display_name", "")), "info")
@@ -144,7 +144,7 @@ func inventory_projection() -> Array:
# 库存投影行(字形=名首字;usable=整图零信息预判回调接排雷玩法只读面)+
# 积点进度浮窗行(自动兑付件 C 区悬停 tooltip 补 N/M 进度;件 id 锁定=装配行唯一)
func _projection_row(rec: Dictionary, cs: Dictionary) -> Dictionary:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", "")))
var dn := String(arow.get("display_name", ""))
var note := ""
if bool(cs.get("assembled", false)) and String(cs.get("item_id", "")) == String(rec.get("item_id", "")):
+1 -1
View File
@@ -38,7 +38,7 @@ func run(n: int) -> void:
# 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/关卡完成全量 JSONL;
# 一文件多局,run 行携带种子切分局次)
var logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(SEED_BASE)
print("[tune_sim] 模拟日志: %s" % log_path)
panel.set_sim_running(true)
+2 -2
View File
@@ -32,7 +32,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary:
var board_zone := BoardZoneView.new()
bottom.add_child(board_zone)
board_zone.ap_bar.set_refresh_cost(int(db.action_economy.rap_refresh_cost)) # RAP 读数表值驱动
board_zone.ap_bar.set_refresh_cost(int(db.run.action_economy.rap_refresh_cost)) # RAP 读数表值驱动
# 结算演出覆盖层(A–D 之上——z 序 = addChild 序,模态覆盖层组在 build_overlays 中更晚挂入)
var settlement_fx := SettlementFxView.new()
@@ -65,7 +65,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary:
# 本组不含明细回看 overlay——冒险中回看归「生涯」页;
# 本组不含营地覆盖层——在 camp_screen 屏壳构建)
static func build_overlays(host: Control, db: ConfigDb) -> Dictionary:
var legend_overlay := LegendOverlay.new(int(db.action_economy.rap_refresh_cost)) # RAP 行表值注入
var legend_overlay := LegendOverlay.new(int(db.run.action_economy.rap_refresh_cost)) # RAP 行表值注入
host.add_child(legend_overlay)
var node_overlay := NodeOverlay.new()
+2 -2
View File
@@ -133,7 +133,7 @@ func _flush_pending_agg() -> void:
func _source_label(src_key: String) -> String:
if src_key == "":
return ""
return String(_db.settlement.core.source_label_map.get(src_key, src_key))
return String(_db.combat.settlement.core.source_label_map.get(src_key, src_key))
func _gold_frame(text: String) -> String:
@@ -171,7 +171,7 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
if row.get("roll", null) != null:
roll_txt = "〔掷 %d vs 护甲 %d" % [int(row.roll), int(row.ac)]
if bool(row.get("crit", false)):
roll_txt += " · 暴击×%d" % int(_db.settlement.damage_roll.crit_multiplier)
roll_txt += " · 暴击×%d" % int(_db.combat.settlement.damage_roll.crit_multiplier)
elif int(row.tier) < 100:
roll_txt += " · ×%d%%" % int(row.tier)
roll_txt += "〕"
+5 -5
View File
@@ -104,13 +104,13 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.mark_first_adventure_done()
iso.meta_state.mark_first_adventure_done()
# --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
if swap_save:
sm.attach_path(save_path)
sm.slots.attach_path(save_path)
if mark_first_done:
sm.mark_first_adventure_done()
sm.meta_state.mark_first_adventure_done()
iso = sm
else:
main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动
@@ -123,10 +123,10 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
static func open_camp_via_savesel(main, tag: String) -> bool:
var sm = main.get_node("/root/SaveManager")
sm.attach_slot_template("user://%s_slot_%%d.json" % tag)
sm.slots.attach_slot_template("user://%s_slot_%%d.json" % tag)
for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席)
for suf in ["", ".tmp", ".corrupt.bak"]:
var sp: String = sm.slot_path(i) + suf
var sp: String = sm.slots.slot_path(i) + suf
if FileAccess.file_exists(sp):
DirAccess.remove_absolute(sp)
main._open_save_select()
+1 -1
View File
@@ -132,7 +132,7 @@ func _step_camp_back_to_menu() -> void:
# 返回钮真实信号路径关闭→ESC 语境不响应(卡⑤ 契约 2 范围外——零实现即断言)。----
func _step_save_select_open_back() -> void:
ProbeUiUtil.isolate_save(main, "smoke_screen_flow_savesel", false, false, true)
main.save_manager.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json")
main.save_manager.slots.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json")
var esb: Button = ProbeUiUtil.find_button(main._start_overlay, "进入存档", "prefix")
if esb == null:
_problems.append("[savesel] mn3 进入存档钮缺失(卡② 交付物)")
+14 -14
View File
@@ -71,7 +71,7 @@ func _ready() -> void:
_problems.append("树淬炼柜台购买钮缺失(点击路径——四柜台重排后树=主界面)")
else:
# 树钮句柄用后即取(refresh_camp 随刷重建树行板——旧钮 queue_free,跨刷新句柄必失效)
iso_c.add_keystone(9999)
iso_c.meta_state._add_keystone(9999)
main.camp_overlay.refresh_camp(main._camp_data())
await get_tree().process_frame
var buy_btn: Button = null
@@ -81,15 +81,15 @@ func _ready() -> void:
if buy_btn == null:
_problems.append("脊柱生命行缺失(点击路径)")
else:
var bal_before: int = iso_c.keystone_balance()
var price: int = int(main.db.tree_node("sp_hp").get("price", 0))
var bal_before: int = iso_c.meta_state.keystone_balance()
var price: int = int(main.db.crossrun.meta_tree.row_by_id("sp_hp").get("price", 0))
buy_btn.pressed.emit() # 合成购买点击 → _on_camp_tree_buy → meta.tree_buy(写调用唯一归组合根)
await get_tree().process_frame
if int(iso_c.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price])
if int(iso_c.meta_state.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.meta_state.keystone_balance(), price])
var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json")
iso_chk.load_from_disk()
if int(iso_chk.tree_level("sp_hp")) != 1:
if int(iso_chk.meta_state.tree_state().nodes.get("sp_hp", 0)) != 1:
_problems.append("营地树购买未持久(reload 后级 != 1)")
# Fork 双态切换点击路径(V20——购=解锁默认 A,钮切 B;前置=同调位 1,先点亮)
var socket_btn: Button = null
@@ -123,7 +123,7 @@ func _ready() -> void:
await get_tree().process_frame
var iso_fk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json")
iso_fk.load_from_disk()
if String(iso_fk.fork_side("fg_posture")) != "b":
if String(iso_fk.meta_state.tree_state().fork_sides.get("fg_posture", "")) != "b":
_problems.append("Fork 切态未持久(reload 后激活态 != b)")
# ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label,
# font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)----
@@ -189,7 +189,7 @@ func _ready() -> void:
if FileAccess.file_exists(cp):
DirAccess.remove_absolute(cp)
ProbeUiUtil.isolate_save(main, "smoke_click_probe_savesel", true, false, true)
main.save_manager.attach_slot_template(csel_tpl)
main.save_manager.slots.attach_slot_template(csel_tpl)
var f_c2 := FileAccess.open(csel_tpl % 2, FileAccess.WRITE)
f_c2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 100,
"adventure_total": 3, "adventure_cleared": 1, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
@@ -215,8 +215,8 @@ func _ready() -> void:
_problems.append("[savesel] sl12 动线序违约:选档隐藏帧营地层未可见(先关后开=回闪形态)")
elif samples[0][2]:
_problems.append("[savesel] sl12 动线序违约:开始层在落点帧恢复(回闪一帧)")
if main.save_manager.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.active_slot())
if main.save_manager.slots.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.slots.active_slot())
var occluded: Array = main._modals._occluded.get(main.camp_overlay, [])
if not occluded.has(main.save_select_overlay):
_problems.append("[savesel] sl12 选档层被关闭而非栈记账挂起(先关后开形态——遮挡挂起中间态钉死)")
@@ -233,8 +233,8 @@ func _ready() -> void:
else:
load_btn.pressed.emit()
await get_tree().process_frame
if main.save_manager.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.active_slot())
if main.save_manager.slots.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.slots.active_slot())
if not main.camp_overlay.visible or main.save_select_overlay.visible:
_problems.append("[savesel] sl6 落点未开营地层或选档未隐藏")
main._on_camp_closed()
@@ -834,7 +834,7 @@ func _run_modal_probes_once() -> void:
var oracle: Array = []
for rec in rnr.treasure_inventory.query_inventory(Callable()):
var rr: Dictionary = rec
oracle.append({"name": String(main.db.treasure_item_row(String(rr.get("item_id", ""))).get("display_name", "")),
oracle.append({"name": String(main.db.crossrun.treasure_items.row_by_id(String(rr.get("item_id", ""))).get("display_name", "")),
"used": bool(rr.get("used", false))})
var sd_curios: Array = (sd as Dictionary).get("curios", [])
var curios_ok: bool = (sd_curios as Array).size() == (oracle as Array).size()
@@ -896,7 +896,7 @@ func _run_modal_probes_once() -> void:
var used_now: int = int(main.run_scope.settlement.presentation_snapshot().get("refresh_used", 0))
if used_now != 1:
_problems.append("刷新轮内计数错误(%d)" % used_now)
var remain_expect: int = maxi(0, int(main.db.action_economy.refresh_cap_per_round) - used_now)
var remain_expect: int = maxi(0, int(main.db.run.action_economy.refresh_cap_per_round) - used_now)
if not main.ap_bar.refresh_remaining_text().contains(str(remain_expect)):
_problems.append("HUD 刷新余量与快照推导失符(HUD='%s' 应含 %d)" % [main.ap_bar.refresh_remaining_text(), remain_expect])
+2 -2
View File
@@ -475,7 +475,7 @@ func _ready() -> void:
# attune_param_sum 只读出口→set_fog_glow_bonus〕→视野 +4 生效与卸装复原。
# settlement.attunement 手动注入=模拟 begin_battle enc 注入后的运行态〔注入面本体归
# 冒烟+单测覆盖〕;不重启冒险——case⑯ 起既有板面继续用)
main.save_manager.set_attunement_equipped(["wide_scout_band"])
main.save_manager.meta_state.set_attunement_equipped(["wide_scout_band"])
var p_lantern: Dictionary = main.build_adventure_params(main.save_manager, main.meta)
if not (p_lantern.get("attunement", []) as Array).has("wide_scout_band"):
_problems.append("case⑮ 装配未进开局参数面(attunement=%s 应含 wide_scout_band)" % str(p_lantern.get("attunement", [])))
@@ -491,7 +491,7 @@ func _ready() -> void:
_problems.append("case⑮ 卸装后视野未复原(lantern 应=0——契约复原语义显式重算)")
if int(bv.cells_probe()[ring_cell].fog_state) == 0:
_problems.append("case⑮ 卸装后 dist==R+1 格未复原遮蔽")
main.save_manager.set_attunement_equipped([])
main.save_manager.meta_state.set_attunement_equipped([])
# ---- case⑯ 状态面板输入锁语义(R3——开层锁棋盘、关层恢复无残留;真实 push_input
# 路径:入口角标本体 press+release 真点击 → 状态面板开;锁期挖掘点击零穿透;「继续」关层后
# 同格挖掘重新到达——锁语义与既有模态同款,无状态残留。置于全 case 之尾:取格若需前沿移位
+1 -1
View File
@@ -99,7 +99,7 @@ func _ready() -> void:
problems.append("B 区轮次·阶段指示缺失(%s)" % main.stage.round_label.text)
if main.stage.enemy_box.get_child_count() < 1:
problems.append("B 区敌方站桩未构建(%d 个)" % main.stage.enemy_box.get_child_count())
if main.ap_bar._dots.get_child_count() != int(main.db.action_economy.ap_per_round):
if main.ap_bar._dots.get_child_count() != int(main.db.run.action_economy.ap_per_round):
problems.append("D 区 AP 点阵数量错误(%d)" % main.ap_bar._dots.get_child_count())
if main.ap_bar.refresh_remaining_text() == "":
problems.append("D 区弃图刷新按钮文案缺失")
+4 -4
View File
@@ -81,7 +81,7 @@ func _ready() -> void:
if FileAccess.file_exists(spp):
DirAccess.remove_absolute(spp)
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true)
main.save_manager.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
main.save_manager.slots.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE)
f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555,
"adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
@@ -89,7 +89,7 @@ func _ready() -> void:
var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE)
f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现)
f_s3.close()
if main.save_manager.active_slot() != 0:
if main.save_manager.slots.active_slot() != 0:
problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)")
main._open_save_select()
_settle()
@@ -201,8 +201,8 @@ func _ready() -> void:
], 20)
_settle()
var ticket_row: Dictionary = main.shop_overlay._shelf_rows[0]
if String(main.db.shop_pool_row("feat_ticket").get("display_name", "")) != "专长获取":
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.shop_pool_row("feat_ticket").get("display_name", "")))
if String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")) != "专长获取":
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")))
if ticket_row.card.name_l.text != "专长获取":
problems.append("[shop_term] 专长凭证件名未用物品名「专长获取」(现=%s)" % ticket_row.card.name_l.text)
if ticket_row.btn.get_parent() != ticket_row.card.body or ticket_row.btn.get_index() >= ticket_row.card.foot_l.get_index():
+6 -6
View File
@@ -36,8 +36,8 @@ func _ready() -> void:
await get_tree().process_frame
var problems: Array = []
# 基线读表(表值驱动——不写字面量)
var base_atk := int(main.db.hero.atk)
var base_hp := int(main.db.hero.max_hp)
var base_atk := int(main.db.combat.hero.atk)
var base_hp := int(main.db.combat.hero.max_hp)
var boosted_atk := base_atk * 2 # 攻击滑条 100 → ×2
var boosted_hp := int(round(base_hp * 1.5)) # 生命滑条 50 → +50%
# ① 全局浮层角标开窗(调试入口=UI 最顶层浮层,任何 UI 不遮挡——
@@ -86,8 +86,8 @@ func _ready() -> void:
if main.debug_overlay.visible or not main._start_overlay.visible:
problems.append("关闭后层级状态错误")
# ⑦ hero 表运行期不可变(E4 收口回归断言:任何代码 reintroduce 表写在此被抓获——原"表值残留"断言语义升级)
if int(main.db.hero.atk) != base_atk or int(main.db.hero.max_hp) != base_hp:
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])
if int(main.db.combat.hero.atk) != base_atk or int(main.db.combat.hero.max_hp) != base_hp:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.combat.hero.atk), str(main.db.combat.hero.max_hp), base_atk, base_hp])
# ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成"
# (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获
# 存档隔离(停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
@@ -165,11 +165,11 @@ func _ready() -> void:
# SaveManager 是 autoload 单例——换注走原地 attach(首局态挂载+恢复隔离档);
# --script 模式无裸 autoload 标识符——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
sm.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面)
sm.slots.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面)
main.debug_overlay.seed_edit.text = ""
if main._seed_value() != 42:
problems.append("首局空框种子 != 42(P5 甲失守——教学态可复现破约)")
sm.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位)
sm.slots.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位)
var draws: Array[int] = []
for i in range(4):
draws.append(main._seed_value())
+2 -2
View File
@@ -78,7 +78,7 @@ func _initialize() -> void:
var slots: int = 3
var passives: Array = []
var actives: Array = []
for r in db.treasure_attunement_rows():
for r in db.crossrun.treasure_items.attunement_rows():
if String(r.get("form", "")) == "passive":
passives.append(String(r.get("item_id", "")))
elif String(r.get("form", "")) == "active_charged":
@@ -102,7 +102,7 @@ func _initialize() -> void:
for policy in policies:
# 每策略一个共享日志会话(全量 JSONL 体量随局数增长,分文件便于定位)
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(seed0)
var tally := {}
for i in range(n):
+1 -1
View File
@@ -24,7 +24,7 @@ func _initialize() -> void:
var gen = load("res://core/board_generator.gd").new()
print("=== 板形统计:N=%d/行 · 种子基 %d ===" % [n, seed0])
print("%-4s %-4s %-8s %-8s %-10s %-10s %-10s %-10s %-10s %-10s" % ["L", "D", "宽x高", "类型", "|M|均", "|M|min", "|M|max", "|S|均", "雷密度实测", "达标率"])
for row in db.board_gen.rows:
for row in db.combat.board_gen.rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
+8 -4
View File
@@ -9,9 +9,13 @@ extends SceneTree
class FakeDB:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
class _FakeBoardGen:
var row: Dictionary
func resolve_row(_L: int, _D: int) -> Dictionary:
return row
class _FakeCombat:
var board_gen := _FakeBoardGen.new()
var combat := _FakeCombat.new()
func status_effect_row(_id: String) -> Dictionary:
return {}
@@ -28,7 +32,7 @@ func _initialize() -> void:
return
var gen = load("res://core/board_generator.gd").new()
print("level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,r0_sd,r0_p90,r0_max,inf_mean,cap_rej_mean")
for row in db.board_gen.rows:
for row in db.combat.board_gen.rows:
var rd := {
"level_index": int(row.level_index), "node_depth": int(row.node_depth),
"board_width": int(row.board_width), "board_height": int(row.board_height),
+16 -16
View File
@@ -53,17 +53,17 @@ func _initialize() -> void:
func _probe_boss(db, seed_val: int) -> void:
var math = load("res://core/damage_math.gd")
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi)
var hero_def_negative := 0
for eff_id in db.settlement.core.effect_pool_map.keys():
if String(db.settlement.core.effect_pool_map[eff_id]) != "hero_defense":
for eff_id in db.combat.settlement.core.effect_pool_map.keys():
if String(db.combat.settlement.core.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = db.status_effect_row(eff_id)
var row: Dictionary = db.combat.status_effects.row_by_id(eff_id)
if not row.is_empty() and String(row.get("unit", "")) == "percent":
hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0)))
print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [
hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_roll.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))])
hero_def_negative, clamp_lo, clamp_hi, int(db.combat.settlement.damage_roll.damage_floor), int(db.combat.hero.armor_class), int(db.combat.status_effects.row_by_id("ac_up").get("max_stack", 0))])
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
@@ -92,7 +92,7 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
settlement.step_row.connect(func(row): cap["rows"].append(row))
settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s))
settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", "")))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.combat.hero.max_hp), "node_type": "boss"}):
cap["result"] = "begin_failed"
return cap
var rounds := 0
@@ -175,11 +175,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
# 池归属表驱动(settlement.effect_pool_map),percent 池加算后 clamp(战斗结算 F2 同口径)
var pools := {"hero_defense": 0, "enemy_output": 0, "hero_offense": 0, "flat_attach": 0}
for eid in last_pool:
var pool_name: String = String(db.settlement.core.effect_pool_map.get(eid, ""))
var pool_name: String = String(db.combat.settlement.core.effect_pool_map.get(eid, ""))
if pools.has(pool_name):
pools[pool_name] += int(last_pool[eid])
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi)
var pd: int = clampi(int(pools["hero_defense"]), clamp_lo, clamp_hi)
var pe: int = clampi(int(pools["enemy_output"]), clamp_lo, clamp_hi)
var lines: Array = []
@@ -194,11 +194,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
# 掷点口径重建:基线按行留档档位打折(暴击 ×倍率),附加必中
var tier: int = int(row.get("tier", 100))
var crit: bool = bool(row.get("crit", false))
var base_factor: float = float(tier) * (float(db.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0)
var base_factor: float = float(tier) * (float(db.combat.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0)
var rolled_base := int(floor(float(baseline) * base_factor / 100.0))
var raw: int = rolled_base + attach
var tick: int = int(row.get("value", 0))
var expected: int = maxi(int(db.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe))
var expected: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe))
var roll_txt := "掷%s" % str(int(row.get("roll", -1)))
var zeroed := "✓" if tick == expected else "✗ 算术不符!"
lines.append("拍=%s %s/档%d%s 基线%d→掷后%d 附加%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
@@ -266,7 +266,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary:
if t != "battle" and t != "boss":
continue
var depth := int(node.get("node_depth", -1))
var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
var rolled := lo
@@ -280,7 +280,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary:
func _level_row(db, level_index: int) -> Dictionary:
for row in db.level.rows:
for row in db.run.level.rows:
if int(row.level_index) == level_index:
return row
return {}
@@ -415,11 +415,11 @@ func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary:
var kind: String = String(dep.get("kind", "battle"))
total += 1
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.combat.hero.max_hp)}):
bad += 1
continue
var n: int = settlement.presentation_snapshot().enemies.size()
var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
if n < 1 or n < lo or n > hi:
+1 -1
View File
@@ -19,7 +19,7 @@ func _initialize() -> void:
level = int(OS.get_cmdline_user_args()[0])
if OS.get_cmdline_user_args().size() > 1:
rng.set_master_seed(int(OS.get_cmdline_user_args()[1]))
settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.hero.max_hp})
settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.combat.hero.max_hp})
print("=== 棋盘摘要 L%d ===" % level)
var bd = settlement.minesweeper.current_board()
print("尺寸 %dx%d · 雷 %d · 安全格 %d · R₀ %d" % [bd.width, bd.height, bd.mines.size(), bd.safe_cells().size(), bd.metrics.r0_count])
+8 -4
View File
@@ -56,13 +56,17 @@ func _params_from(row: Dictionary) -> Dictionary:
}
class FakeDB:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
class _FakeBoardGen:
var row: Dictionary
func resolve_row(_L: int, _D: int) -> Dictionary:
return row
class _FakeCombat:
var board_gen := _FakeBoardGen.new()
var combat := _FakeCombat.new()
func status_effect_row(_id: String) -> Dictionary:
return {}
func _db(_real, row: Dictionary):
var f := FakeDB.new()
f.row = row
f.combat.board_gen.row = row
return f
+2 -2
View File
@@ -39,7 +39,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
@@ -117,7 +117,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct,
"policy": policy, "route": route, "hero_override": hero_override})
var rng = load("res://core/rng_service.gd").new()
+2 -2
View File
@@ -44,7 +44,7 @@ func _run_encounter(db, L: int) -> void:
print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)])
print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.combat.hero.max_hp)])
print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)])
print(" 清图(换板) 均值 %.2f 中位 %d" % [_mean(clears), _median(clears)])
print(" 攻防buff授予 均值 %.2f" % [_mean(buff_grants)])
@@ -65,7 +65,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
out.safe_opened += 1)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}):
return out
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
+1 -1
View File
@@ -19,7 +19,7 @@ func _initialize() -> void:
quit(1)
return
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(seed0)
print("节奏批日志: %s" % log_path)
var autoplay = load("res://sim/level_autoplay.gd")
+2 -2
View File
@@ -14,7 +14,7 @@ func _initialize() -> void:
db.load_tables("res://data/tables", false)
# ① 表侧(行集数组取首行,战斗节点查 battle 键域)
var row = db.resolve_enemy_rowset(1, 1, "battle")[0]
var row = db.combat.enemy.resolve_rowset(1, 1, "battle")[0]
print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"),
" pool=", row.get("pool"))
@@ -50,7 +50,7 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di
if row.type == "damage" and row.source_class == "enemy_action":
var sid: String = str(row.get("skill_id", "?"))
casts[sid] = casts.get(sid, 0) + 1)
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.combat.hero.max_hp)}):
return out
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
+9 -9
View File
@@ -39,7 +39,7 @@ func _initialize() -> void:
quit(1)
return
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(opt.seed0)
print("模拟日志: %s" % log_path)
var autoplay = load("res://sim/level_autoplay.gd")
@@ -154,7 +154,7 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) ->
for r in range(int(opt.k)):
var seed_val := _next_seed(int(opt.seed0))
var ov: Dictionary = meta.hero_override()
var keystone_before: int = meta.keystone_balance()
var keystone_before: int = meta.meta_state.keystone_balance()
var st: Dictionary = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, "min", ov)
var row := _adventure_row(st, policy, seed_val)
row["seg"] = "campaign"
@@ -171,15 +171,15 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) ->
var settle: Dictionary = meta.settle_adventure(st.summary)
row["keystone_income"] = int(settle.total)
row["purchases"] = _spend(meta, String(opt.spend))
row["keystone_after"] = meta.keystone_balance()
row["tree"] = {"sp_hp": meta.save.tree_level("sp_hp"), "sp_hit": meta.save.tree_level("sp_hit")}
row["keystone_after"] = meta.meta_state.keystone_balance()
row["tree"] = {"sp_hp": meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), "sp_hit": meta.save.meta_state.tree_state().nodes.get("sp_hit", 0)}
row["new_unlocks"] = settle.new_unlocks
_emit(row)
counts.campaign += 1
if (r + 1) % 10 == 0:
print("进度 campaign %s %d/%d(树 血%d/命中%d · 钥石 %d)" % [policy, r + 1, int(opt.k),
meta.save.tree_level("sp_hp"), meta.save.tree_level("sp_hit"),
meta.keystone_balance()])
meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), meta.save.meta_state.tree_state().nodes.get("sp_hit", 0),
meta.meta_state.keystone_balance()])
_remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档)
@@ -193,9 +193,9 @@ func _spend(meta, policy_name: String) -> Array:
guard -= 1
var bought := false
for nid in order:
var price: int = int(meta.db.tree_node(nid).get("price", 0))
if price > 0 and meta.keystone_balance() >= price and meta.tree_buy(nid):
out.append({"node_id": nid, "level": meta.save.tree_level(nid), "price": price})
var price: int = int(meta.db.crossrun.meta_tree.row_by_id(nid).get("price", 0))
if price > 0 and meta.meta_state.keystone_balance() >= price and meta.tree_buy(nid):
out.append({"node_id": nid, "level": meta.save.meta_state.tree_state().nodes.get(nid, 0), "price": price})
bought = true
break
if not bought:
+12 -68
View File
@@ -43,72 +43,17 @@ tests/
> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = 单元测试;
> 需要两个以上真实系统协同并走落盘/落库 = 集成测试;需要完整项目引导与场景树 = 冒烟/系统/UI 探针。
**能力清单 v1**(2026-10-28 立——验证与清单体系整改阶段 1 的覆盖索引:一行=一个玩家或系统可观测的能力,代表用例只给指针、不复制断言细节;原「集成旅程清单」五条跨系统旅程已并入本表。首期覆盖核心四域(29 行)+跨域条件闸 1 行,共 30 行;其余域按同一形态扩面):
**集成旅程清单**(2026-10-28 立——跨域旅程用例的登记处:一个行为穿过多个领域的对外方法=旅程,验收落本层;缺口旅程由属主批/工程债批按批落用例后回填本表):
> **与验证集清单的关系**:`tests/验证集清单.md` 已于阶段 2「存量断言精简」完成后删除——冻结对账基准(基点 `b9260138`/1587 条)与精简对账结果(33 站点并为 7、1587→1561,合回 `87d241c1`)留 git 历史。本表是该清单退役后的唯一覆盖索引(维护锚=test-conventions §8.3)。本表不是闸,不替代改动—验收对照表、体验验证清单与静态纪律;每行的唯一事实源仍是 `game/tests/` 下的测试代码。
>
> **代表用例口径**:写 `文件:函数`,每行 2–3 个;函数内可含 `Kit.check_quiet` 静默通道(逐断言提取器口径外,test-conventions §5.2 已备案)——本表以函数为锚,不受该工具局限影响。
>
> **维护规则(每批收口核对本批受影响行)**:本批改到哪个能力,收口时核对该行代表用例可 grep、在本批真实跑批中执行、真实入口未绕过改动点,并把「最后验证批次」刷新为本批回执;新增能力加行,能力退役删行。全表核对频率与抽样规则归阶段 3。首期未覆盖:排雷循环(棋盘生成/排雷玩法/行动经济)、表现与支撑(HUD/主题/字体/地形瓦片/模态)、数据配置与跨系统契约——按同一形态后续扩面。
>
> **旅程补录方法**:新旅程=加一行,「系统」列写涉及边界;源=architecture「API 边界」表+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例。
>
> 「最后验证批次」列记回执 commit;多数行初值 `20c13c3a`=存量断言精简 A+B 首批的全量域闸回执,RNG 行随 C 档批刷新为 `fd6cf968`。
| 旅程 | 涉及边界 | 现行覆盖 | 缺口 | 登记批 |
|---|---|---|---|---|
| 存档⇄RNG 快照往返保真 | SaveManager×AdventureRunner×RngService | `save_rng_roundtrip_test.gd` 用例一(闸 02) | — | 迭代 29 T3 |
| 旧档迁移后断点续玩零跳号 | SaveManager 迁移链×AdventureRunner×BoardGenerator | `save_rng_roundtrip_test.gd` 用例二(闸 02) | — | 迭代 29 T3 |
| 断点附随件与事件日志会话 | AdventureRunner×SaveManager×日志面 | `cross_system_flow_test.gd`(闸 02) | 盘点候选 | 断言迁移批(2026-10-23) |
| 关卡图断点重生成同图/生成图确定性 | LevelRunner×节点图生成器×AdventureRunner | `node_map_determinism_test.gd`(闸 02) | — | 断言迁移批(2026-10-23,迭代 35 卡②随动换代) |
| 回放确定性(同种子逐位) | 全玩法链 | `parity_anchor.py`(闸 11 条件闸) | — | 迭代 29 T3 收编为条件闸件 |
**旅程段一:开机与存档**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 存档写盘⇄读回往返保真 | 存档序列化 | `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_manager_domain_test.gd:_smoke_save` | `20c13c3a` | — |
| 三槽位隔离与激活失败回滚 | 存档序列化 | `SaveManagerCore.activate_slot()`/`create_slot()`/`delete_slot()` | `save_slot_isolation_test.gd:_test_three_slot_roundtrip_and_isolation`;`save_slot_isolation_test.gd:_test_activate_failure_restores_previous_slot` | `20c13c3a` | — |
| 槽位摘要窥视与坏槽三态 | 存档序列化 | `SaveManagerCore.peek_slot_summary()` | `save_slot_summary_test.gd:_test_peek_three_slots_distinct_values`;`save_slot_summary_test.gd:_test_peek_empty_and_unreadable_states`;`save_slot_summary_test.gd:_test_peek_zero_side_effect` | `20c13c3a` | — |
| 启动态无激活不写盘 | 存档序列化 | `SaveManagerCore.has_write_target()`/`write_to_disk()` | `save_boot_no_activate_test.gd:_test_write_rejected_without_target`;`save_boot_no_activate_test.gd:_test_out_of_range_slots_rejected` | `20c13c3a` | — |
| 旧档迁移链与断点续玩零跳号(集成) | 存档序列化×冒险结构 | `SaveManagerCore.load_from_disk()`(内置迁移链)× `AdventureRunner.resume_adventure()` | `save_rng_roundtrip_test.gd:_test_old_version_save_migrates_and_resumes` | `20c13c3a` | — |
| 存档⇄RNG 快照往返保真(集成) | 存档序列化×RNG 服务×冒险结构 | `AdventureRunner.start_adventure()` → `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_rng_roundtrip_test.gd:_test_disk_roundtrip_preserves_breakpoint` | `20c13c3a` | — |
| RNG 统一流派生与取号账本 | RNG 服务 | `RngServiceCore.derive_at()`/`open_stream()`/`counter()` | `rng_service_test.gd:_test_derive_determinism`;`rng_service_test.gd:_test_stream_draw_determinism`;`rng_stream_boot_test.gd:_smoke_initialize` | `fd6cf968` | — |
| 断点片段往返与续玩幂等 | 冒险结构×关卡节点地图 | `LevelRunner.breakpoint_fragment()`/`restore_fragment()`;`AdventureRunner.resume_adventure()` | `breakpoint_resume_test.gd:_init` | `20c13c3a` | — |
| 跨系统附随面:事件日志落盘与断点附随件(集成) | RNG 服务×存档序列化×冒险结构 | `RngServiceCore.new_adventure()`(开事件日志会话);`SaveManagerCore.breakpoint_snapshot()`(断点附随键) | `cross_system_flow_test.gd:_rng_event_log_session`;`cross_system_flow_test.gd:_feats_breakpoint_roundtrip` | `20c13c3a` | 日志面仅此一条会话覆盖 |
**旅程段二:冒险开局与选路**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 冒险生命周期:开局受理/三关序列/败北线 | 冒险结构 | `AdventureRunner.start_adventure()`/`continue_to_next_level()` | `adventure_feats_test.gd:_smoke_adventure_api`;`adventure_feats_test.gd:_smoke_adventure` | `20c13c3a` | — |
| 专长(奇物)授予与凭证保底 | 奇物 | `AdventureRunner.present_node_curio()`/`feats_held()` × 效果引擎兑付 | `adventure_feats_test.gd:_smoke_feats`;`feat_vouchers_test.gd:_init` | `20c13c3a` | — |
| 节点图生成合法性与同种子复现 | 关卡节点图生成 | `NodeMapGenerator.generate()`/`validate_output()` | `node_map_generator_test.gd:_test_output_ok`;`node_map_generator_test.gd:_test_reproducible`;`node_map_generator_test.gd:_test_exit_structure` | `20c13c3a` | — |
| 节点图生长递增与无解 fail-fast | 关卡节点图生成 | `NodeMapGenerator.generate()`/`is_fatal()` | `node_map_growth_test.gd:_test_growth`;`node_map_growth_test.gd:_test_fatal_subprocess` | `20c13c3a` | — |
| 起关/选路/断点重生成同图(集成) | 关卡节点地图×关卡节点图生成×冒险结构 | `LevelRunner.start_level()`/`start_level_external()`/`pick_next_node()`;`AdventureRunner.resume_adventure()` | `node_map_determinism_test.gd:_smoke_generated_map_determinism` | `20c13c3a` | — |
| 信息迷雾类型可见性 | 关卡节点地图 | `LevelRunner.type_visible_ids()` | `level_map_type_vis_test.gd:_test_layer_distance`;`level_map_type_vis_test.gd:_test_boss_exempt`;`level_map_type_vis_test.gd:_test_resume_rebuild` | `20c13c3a` | — |
| 关卡表消费侧校验与关卡推进 | 关卡节点地图×数据配置 | `LevelTable.validate_graph()`;`LevelRunner.start_level()` | `level_table_validate_test.gd:_test_positive`;`level_table_validate_test.gd:_test_negative`;`level_shop_economy_test.gd:_smoke_level` | `20c13c3a` | — |
**旅程段三:战斗结算**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 伤害公式与掷点乘区 | 战斗结算 | `DamageMath.pool_damage()`/`roll_discount_percent()`/`rolled_component()` | `damage_math_test.gd:_test_pool_damage`;`damage_math_test.gd:_test_roll_discount_percent`;`roll_multiplier_test.gd:_smoke_armor_roll` | `20c13c3a` | — |
| 伤害单据编排与回放 | 战斗结算 | `DamageController.submit()`/`open_ticket()`/`flush()` | `damage_controller_test.gd:_run_recipe_sequence`;`damage_controller_test.gd:_test_five_recipes_and_replay` | `20c13c3a` | — |
| 轮内流与结算五段全序 | 战斗结算 | `BattleOrchestrator.begin_battle()` | `round_flow_skill_pool_test.gd:_smoke_full_battle`;`round_flow_skill_pool_test.gd:_smoke_settlement_segments` | `20c13c3a` | — |
| 技能池数据面与回放独立 | 敌人系统 | `BattleOrchestrator.begin_battle()`(技能池抽号/校验) | `round_flow_skill_pool_test.gd:_smoke_skill_pool_data`;`round_flow_skill_pool_test.gd:_smoke_skill_pool_replay_independence` | `20c13c3a` | — |
| Boss 半血解锁技门控 | 敌人系统 | `BattleOrchestrator.begin_battle()`(Boss 行) | `skill_unlock_test.gd:_test_gate_predicate`;`skill_unlock_test.gd:_test_latch_irreversible` | `20c13c3a` | — |
| 封印与极性路由/不可逆 | 地图战斗资源×战斗结算 | 效果引擎 × `BattleOrchestrator.begin_battle()` | `seal_polarity_test.gd:_smoke_seal_ap_exhausted`;`seal_polarity_test.gd:_smoke_seal_irreversible_quadrants`;`seal_polarity_test.gd:_smoke_polarity_routing` | `20c13c3a` | — |
| 变异布置门控 | 地形变异 | `BattleOrchestrator.begin_battle()`(变异布置段) | `variant_placement_test.gd:_smoke_variant_placement`;`variant_placement_test.gd:_smoke_global_variant` | `20c13c3a` | — |
**旅程段四:奖励、经济与局外成长**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 清图奖励双份与零路径 | 地图战斗资源 | `BattleOrchestrator.begin_battle()` → 步骤流行(`source_class=clear_reward`) | `seal_polarity_test.gd:_smoke_clear_reward_chain`;`seal_polarity_test.gd:_smoke_clear_reward_zero_paths` | `20c13c3a` | — |
| 奖励三选一候选/刷新付费/呈现 | 奇物×节点奖励屏 | `AdventureRunner.present_node_curio()`(数据面);奖励节点覆盖层场景(呈现面) | `curio_refresh_paid_test.gd:_init`;`reward_overlay_test.gd:_run` | `20c13c3a` | — |
| GP 钱包/escrow/商店收口/共享池 | 地图战斗资源×商店 | `LevelRunner.buy_product()`/`player_request_refresh()`/`close_wallet()`/`set_shop_pool()` | `level_shop_economy_test.gd:_smoke_gp_wallet`;`level_shop_economy_test.gd:_smoke_shop_room`;`level_shop_economy_test.gd:_smoke_shop_pool` | `20c13c3a` | — |
| 元进度入账与锻造两轴 | 元进度×锻造工坊 | `SaveManagerCore.add_keystone()`/`spend_keystone()`/`set_forge_route_level()` | `meta_progression_forge_test.gd:_meta_pricing_and_buy`;`meta_progression_forge_test.gd:_smoke_forge_axes` | `20c13c3a` | — |
| 天赋树购买与六键合成 | 天赋树 | `SaveManagerCore.tree_nodes()`/`set_tree_level()` | `meta_progression_forge_test.gd:_smoke_tree`;`meta_progression_forge_test.gd:_smoke_tree_rules`;`tree_rules_v11_test.gd:_init` | `20c13c3a` | — |
| 秘宝结构冻结与 12 件效果 | 秘宝 | `TreasureItems.add_item()`/`query_inventory()`/`validate_use()` | `treasure_items_test.gd:_init`;`treasure_effects_test.gd:_init` | `20c13c3a` | — |
**跨域条件闸**
| 能力 | 系统 | 真实入口 | 代表用例 | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 回放确定性(同种子三变体逐位) | 全玩法链 | `tools/parity_anchor.py`(闸 11 条件闸,按批发起) | `tools/parity_anchor.py`(整件为条件闸) | —(条件闸) | 条件闸未随本批执行 |
候选旅程盘点方法:源=architecture「API 边界」表逐行+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例;新旅程先登记「缺口」列,用例按批落地后回填「现行覆盖」。
## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正)
@@ -125,7 +70,7 @@ tests/
|---|---|---|---|
| `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 |
| `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下);每套件子进程带截止时间守护——超时先 taskkill /T 整树回收再判该套件 FAIL、批继续(默认 120s,环境变量 `RUN_ALL_SUITE_TIMEOUT_SEC` 可覆盖) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) |
| `tools/probe_gate.sh`(场景探针,`--click`/`--full` 两模式) | UI 可达性、可见性、状态——真实实例化场景+真实输入路径 | 探针自检+退出码 | 新增 UI 区域的配套探针直跑与断言复核 |
| `tools/render_gate.sh`(渲染 golden) | 画面基线——截图与基线图逐像素容差比对 | golden 比对脚本(依赖 PIL) | 主题/素材/排版类改动;窗口化运行,需开显示器 |
| `game/tests/static_scan.sh`(S 域) | 项目纪律+第三方质量规则:RNG 单点/core 纯度/类型标注/文件头/注释禁词/旧符号残留;S13=gdstyle 质量规则(重复 load、循环内 await、循环内分配、不可达代码等真缺陷类;基线键=文件|规则|函数名(类/文件级=文件|规则,无数字条目带消息),数字漂移不假红,同键指标恶化超基线登记值即红) | 退出码(逐规则 FAIL 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 |
@@ -151,9 +96,8 @@ python tools/run_system_test.py
# 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位)
godot --headless --path . res://probe/<probe>.tscn
# 挂死防护内建于 run_all.gd(引擎无死锁探测,超时靠外部判):每套件子进程带截止时间——
# 默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖;超时先 taskkill /T 整树回收
#(cmd wrapper 连引擎孙进程一起杀,不留孤儿),该套件判 FAIL、批继续——单套件挂死不拖死整闸
# 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置)
timeout 120s godot --headless --script tests/run_all.gd
```
> 套件 manifest 现况:`_suites()` 登记 **64 条**(61 单元+3 集成,2026-10-28 实数;口径=manifest 条目数,
@@ -112,24 +112,24 @@ func _save_summary_integration() -> void:
# ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持
var st9: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400)
var f9 = sm_script.new(path9)
f9.apply_adventure_summary(st9.get("summary", {}))
f9.meta_state.apply_adventure_summary(st9.get("summary", {}))
var ks_run := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
for li in range(1, int(_db.run.level.rows.size()) + 1):
ks_run += int(_db.crossrun.meta.boss_keystone_row(li).get("first_kill", 0))
Kit.check("摘要集成:冒险驱动满配冒险成功(seed 42)", String(st9.get("result", "")) == "completed")
Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance(), f9.keystone_balance() == ks_run)
Kit.check("集成关卡完成次数表全 1", f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1)
Kit.check("集成三关首杀标记齐", f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3))
Kit.check("集成 recent 1 条且 seed 42", f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42)
Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.meta_state.keystone_balance(), f9.meta_state.keystone_balance() == ks_run)
Kit.check("集成关卡完成次数表全 1", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 1)
Kit.check("集成三关首杀标记齐", f9.meta_state.career_state().boss_first_kill.has(str(1)) and f9.meta_state.career_state().boss_first_kill.has(str(2)) and f9.meta_state.career_state().boss_first_kill.has(str(3)))
Kit.check("集成 recent 1 条且 seed 42", f9.meta_state.career_state().recent_summaries.size() == 1 and int(f9.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 42)
var st9b: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400)
f9.apply_adventure_summary(st9b.get("summary", {}))
Kit.check("第二局后关卡完成次数全 2", f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2)
Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.keystone_balance() == 2 * ks_run)
Kit.check("集成 recent 长度 2", f9.recent_summaries().size() == 2)
f9.meta_state.apply_adventure_summary(st9b.get("summary", {}))
Kit.check("第二局后关卡完成次数全 2", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 2)
Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.meta_state.keystone_balance() == 2 * ks_run)
Kit.check("集成 recent 长度 2", f9.meta_state.career_state().recent_summaries.size() == 2)
Kit.check("集成写盘成功", f9.write_to_disk())
var r9 = sm_script.new(path9)
Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3))
Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.meta_state.keystone_balance() == 2 * ks_run and r9.meta_state.career_state().clear_counts.get(str(3), 0) == 2
and r9.meta_state.career_state().recent_summaries.size() == 2 and r9.meta_state.career_state().boss_first_kill.has(str(3)))
if FileAccess.file_exists(path9): # 段尾清理(与段首清场对称——源 _smoke_save 同款)
DirAccess.remove_absolute(path9)
@@ -147,7 +147,7 @@ func _smoke_playtest_logger() -> void:
var snaps: Array = []
settlement.settlement_snapshot.connect(func(snap): snaps.append(snap))
Kit.check("会话文件开启(%s)" % path, path.contains("playtest_20260912"))
Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}))
Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}))
var minesweeper = settlement.minesweeper
for i in range(2):
minesweeper.settle_batch([{"action": "stop"}])
@@ -159,7 +159,7 @@ func _smoke_playtest_logger() -> void:
if not s1.has(k):
missing.append(k)
Kit.check("快照字段齐备(缺 %s)" % str(missing), missing.is_empty())
Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"))
Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.combat.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"))
# spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→
# unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位
Kit.check("快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))],
@@ -35,7 +35,7 @@ func _init() -> void:
func _smoke_generated_map_determinism() -> void:
# ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图
var gen = NodeMapGenerator.new(_fresh_rng(910001))
var params2: Dictionary = _db.level_gen_params_row(2)
var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(2)
var g_a: Dictionary = gen.generate(params2, 910002)
var g_b: Dictionary = gen.generate(params2, 910002)
var g_c: Dictionary = gen.generate(params2, 910003)
@@ -60,7 +60,7 @@ func _smoke_generated_map_determinism() -> void:
Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3,
Callable(self, "_smoke_rest_stub"), true))
Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before)
var params3: Dictionary = _db.level_gen_params_row(3)
var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(3)
var entry_count: int = int(params3.get("entry_count", -1))
Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count,
entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count)
@@ -81,7 +81,7 @@ func _smoke_generated_map_determinism() -> void:
Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕",
adr_x.runner().nodes() == adr_y.runner().nodes())
Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)",
adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", []))
adr_x.runner().nodes() == (gen.generate(_db.run.level_gen_params.row_by_level_index(1), 42) as Dictionary).get("nodes", []))
# ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照)
Kit.check("停点断点落盘在档", sv_w.has_breakpoint())
var bp: Dictionary = sv_w.breakpoint_snapshot()
@@ -96,21 +96,21 @@ func _smoke_generated_map_determinism() -> void:
adr_old.resume_adventure(bp_old, {}) == false)
# ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段)
var tpl_free := true
for li in range(1, _db.level_count() + 1):
if (_db.level_row(li) as Dictionary).has("templates"):
for li in range(1, _db.run.level.level_count() + 1):
if (_db.run.level.row_by_level_index(li) as Dictionary).has("templates"):
tpl_free = false
Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕",
tpl_free and _db.level_count() == 3)
tpl_free and _db.run.level.level_count() == 3)
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
sv.load_from_disk()
Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)",
sv.is_first_adventure_done() == false)
sv.meta_state.career_state().first_adventure_done == false)
var mp = load("res://core/meta_progression.gd").new(_db, sv)
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true)
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.meta_state.career_state().first_adventure_done == true)
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true)
Kit.check("置位幂等(重复收束不翻转)", sv.meta_state.career_state().first_adventure_done == true)
DirAccess.remove_absolute(_bp_path)
DirAccess.remove_absolute(_bp_path + ".flag.json")
@@ -103,7 +103,7 @@ func _test_old_version_save_migrates_and_resumes() -> void:
var sm_old := SaveManagerScript.new(SAVE_PATH)
Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载")
Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉")
var forge: Dictionary = sm_old.forge_routes_snapshot()
var forge: Dictionary = sm_old.meta_state.forge_state()
Kit.check_quiet(str(forge.get("weapon", "")) == "sword",
"旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)")
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more