chore: W1 项目骨架 — 基线 v1.0 架构落盘(Godot 4.7.2 / gl_compatibility)

架构基线:docs/architecture/2026-10-19-F-architecture.md(v1.0)
范围:WRATH OF THE DEAD《怨返》单人挂机 · GDScript · 1280×720 · 12 班底数据驱动

- 骨架:project.godot(4.7 + GL Compatibility)、架构 §2.1 完整目录树(含占位说明)、
  .gitignore/.gitattributes(LF 纪律)、README.md、CLAUDE.md、export_presets.cfg 占位、
  pipeline/README.md(3ds Max 侧契约)
- 数据管线段 1:data/sheets/generators.csv(GDD §3.1 十二班底逐值抄录)
  + data/schema/generators.schema.json(v0)+ src/tools/sheet_importer.gd
  (import_all/import_sheet/validate + ImportReport + .tres stub + 行号/列名/原因失败策略)
  + src/tools/import_main.gd(CI CLI 入口)+ data/build/generators.json(字节级确定性产物)
- 探针:P1(SCREEN_TEXTURE/CanvasLayer 排序)与 P3(NoiseTexture2D seamless)场景代码与
  截图/统计工具;实测手册与结论模板在工作区 docs/engineering/(随仓库外文档树)
- CI:数据门激活(import_all + schema validate + build 漂移门);单测/sim/导出 W3+ 注释占位;
  4.7.2 headless CLI 行为标注待本机验证

待用户侧:P1/P3 实测回填结论;P6 字体许可 / P7 GPU 实测;look-dev 游魂初版(Max 侧)
This commit is contained in:
程基岩(engineering-lead) committed 2026-10-19 15:13:10 +08:00
commit 856326a766
42 files changed
+1814

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# 行尾纪律:CI 的 build/*.json 漂移门依赖稳定行尾;文本文件一律 LF 入库
*.gd text eol=lf
*.gdshader text eol=lf
*.tscn text eol=lf
*.tres text eol=lf
*.json text eol=lf
*.csv text eol=lf
*.cfg text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.svg text eol=lf
.gitattributes text eol=lf
.gitignore text eol=lf
.gitkeep text eol=lf
project.godot text eol=lf
*.txt text eol=lf
*.ms text eol=lf
*.py text eol=lf
+99
View File
@@ -0,0 +1,99 @@
# ============================================================================
# CI —— F《怨返》数据门(架构 §7.2;W1 激活部分)
#
# W1 激活:checkout → setup Godot 4.7.2 → headless import → SheetImporter
# import_all → schema validate → build 漂移门(git diff)
# W3 激活:GUT 单测(需先合入 gd-unit-testing addon) —— 见下方注释块
# W4 激活:headless sim 14 门报告(artifact) —— 见下方注释块
# W5 激活:Windows 导出 → artifact(templates + export_presets 补全)
#
# 待本机验证(4.7.x 超出训练数据):
# - `godot --headless --path . --import` 的 4.7.2 实际行为(若失效改 -e --quit-after 200)
# - setup-godot@v1 对 4.7.2 版本串/模板的支持
# 首跑后按实际更新本文件与 README §5。
# ============================================================================
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
data-gate:
name: data-gate(schema v0 数据门)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Godot 4.7.2
uses: chickensoft-games/setup-godot@v1
with:
version: 4.7.2
export-templates: false # W5 导出步骤再置 true
- name: Godot version sanity
run: godot --headless --version
# 首次 import:生成全局类缓存 + 资源导入(SheetImporter 才能被解析)
- name: Headless import
run: godot --headless --path . --import
# 数据门 1:CSV → build/*.json(schema 违规 = 中止 + 行号/列名/原因,退出码 1)
- name: SheetImporter import_all
run: godot --headless --path . -s res://src/tools/import_main.gd
# 数据门 2:build/*.json 对 schema 合规性
- name: Schema validate
run: godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only
# 数据门 3:漂移门——改了 CSV 没重新生成提交 build = 红("CI 生成并提交"纪律)
# 行尾由 .gitattributes 钉 LF,避免格式性误报
- name: Build drift check
run: git diff --exit-code -- data/build/
shell: bash
# ---------------------------------------------------------------------------
# W3 激活(前置:合入 gd-unit-testing addon 到 addons/gut/):
# unit-tests:
# runs-on: windows-latest
# steps:
# - uses: actions/checkout@v4
# - uses: chickensoft-games/setup-godot@v1
# with: { version: "4.7.2", export-templates: false }
# - run: godot --headless --path . --import
# - run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd
# -gtest=res://tests/unit -gexit
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# W4 激活(sim 14 门;W6 起门 #5/#10 设硬门,此前 informational):
# sim-gates:
# runs-on: windows-latest
# steps:
# - uses: actions/checkout@v4
# - uses: chickensoft-games/setup-godot@v1
# with: { version: "4.7.2", export-templates: false }
# - run: godot --headless --path . --import
# - run: godot --headless --path . -s res://src/core/sim/sim_main.gd
# - uses: actions/upload-artifact@v4
# with: { name: sim-report, path: tests/sim_reports/ }
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# W5 激活(release tag:Windows 导出 → artifact → 人工上传 Steamworks):
# export-windows:
# if: startsWith(github.ref, 'refs/tags/')
# runs-on: windows-latest
# steps:
# - uses: actions/checkout@v4
# - uses: chickensoft-games/setup-godot@v1
# with: { version: "4.7.2", export-templates: true }
# - run: godot --headless --path . --import
# - run: godot --headless --path . --export-release "Windows Desktop" build/wrath-of-the-dead.exe
# - uses: actions/upload-artifact@v4
# with: { name: windows-build, path: build/ }
# ---------------------------------------------------------------------------
+21
View File
@@ -0,0 +1,21 @@
# ---- Godot 4 ----
.godot/
/android/
.import/
# ---- 导出产物(架构 §2.1:data/build 是数据产物不受影响,见下方排除只锚根目录)----
/build/
*.exe
*.pck
*.dmg
*.app
*.zip
# ---- 系统垃圾 ----
.DS_Store
Thumbs.db
desktop.ini
# ---- 编辑器临时 ----
*.tmp
*.bak
+74
View File
@@ -0,0 +1,74 @@
# CLAUDE.md —— F《WRATH OF THE DEAD / 怨返》工程偏好
> 由 `docs/architecture/2026-10-19-F-architecture.md`(v1.0)派生 · 2026-10-19 · W1
> 本文件为工程宪法摘要;**冲突时以架构文档为准**;架构更新须两边同步。目标 ≤100 行。
## 引擎版本纪律
- **钉 Godot 4.7.x 补丁线,当前 4.7.2**(`project.godot` config/features);小版本补丁可升,**跨 minor(4.8)上线后不追**;
- GDScript + GodotSteam **v4.23.1**(GDExtension 构建必须与 4.7.x 匹配;升级引擎 = 重新获取匹配构建);
- renderer:`gl_compatibility`(2D 合成 + 核显底线;改 renderer = ADR 级动作);
- ⚠ **知识缺口**:4.7.x 超出训练数据。API 不确定 → 先查官方文档/探针实测,**不臆造 API**;缺口持续标注,`docs/engine-reference/` 待补(架构 §11 P0)。
## 路径作用域编码标准(架构 §2.2)
| 作用域 | 纪律 |
|---|---|
| `src/core/` | 纯逻辑;禁 `extends Node`/访问场景树;`tick()` 零分配;100% 被 sim 与 GUT 无头复用 |
| `src/game/` | 唯一权威状态持有者;系统在此 tick;不直连 Steam(走 platform/);不碰渲染 |
| `src/presentation/` | **只读快照 + 发命令**;UI 不持有游戏状态(仅视图本地态);禁反向写 core |
| `src/platform/` | Steam API 唯一出口;`game/` 禁直调 `Steam.*` |
| `src/tools/` | `@tool` 标注;CI 可 headless;运行时场景禁引用 |
| `data/` | 零代码;稳定字符串 ID;**不重编号**;schema 违规 = CI 红 |
| `pipeline/` | 契约 = 命名 + 导出参数;唯一逐资产代码 = sprite 路径 |
| network | **不适用**:单人买断无网络代码 |
依赖方向:`presentation → game → core`;`game → platform`;`tools → data`。禁反向、禁跨层。
## 数据驱动红线(架构 §9.2 + ADR-3)
1. 零硬编码:一切数值进 `data/sheets/` → JSON → Resource;
2. **新资产 = 加行**;唯一逐资产代码 = `GeneratorDef.sprite_row`;
3. 新增资产若需改代码 = **设计失败,回退重做**;
4. 稳定 ID 永不重编号/复用;schema 违规 = CI 红(行号+列名+原因,禁跳过坏行);
5. 数值列禁千分位;`_cn/_en` 本地化后缀;引用列填目标 ID;
6. "AI 让加一个班底变便宜了"禁用作加范围理由。
## ADR 一句话(架构 §8,均已接受)
- **ADR-1**:预渲染 2 朝向 billboard,不用实时 3D(用户比较优势在批渲染);
- **ADR-2**:单趟 fused 全屏后处理 shader(posterize→LUT→纸纹→暗角),UI 层在其上;
- **ADR-3**:美术数据行纪律(新资产=加行;唯一逐资产代码 = sprite 路径);
- **ADR-4**:AI 仅内部使用(mood board/概念),不进包体、不出厂、商店页披露填 No。
## 探针状态(W1)
| 探针 | 状态 | 手册 / 结论 |
|---|---|---|
| P1 SCREEN_TEXTURE/CanvasLayer | ⏳ 待用户实测 | `docs/engineering/2026-10-19-f-w1-probe-p1-manual.md` → `docs/engineering/probes/P1-result.md` |
| P3 noise seamless 平铺 | ⏳ 待用户实测 | `docs/engineering/2026-10-19-f-w1-probe-p3-manual.md` → `docs/engineering/probes/P3-result.md` |
| P6 字体许可 / P7 用户 GPU | ⏳ 用户侧任务(W1) | 不在本仓库;结论回填 probes/ |
| P2/P4/P5/P8-P11 | 未启动 | 架构 §11 排期(W2-W5) |
**不过的回退**:P1 → SubViewport+BackBufferCopy(+2-4h);P3 → 脚本预烘焙无缝纹理(+0.5-1h)。
## 常用命令
```bash
# 数据门(导入 + 校验)
godot --headless --path . --import
godot --headless --path . -s res://src/tools/import_main.gd # import_all
godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only
# 运行:编辑器 F5(main scene W2 起);探针:打开 tests/probe_* 场景 F6
```
## 数据管线速查
`data/sheets/*.csv` →(SheetImporter,本仓库 `src/tools/`)→ `data/build/*.json`(提交)→(W3 `.tres`)→ DataRegistry。
单向流动:游戏永不写 `data/`;sim 与游戏读同一份 build。契约见 `README.md` + 架构 §3。
## 纪律提醒
- 写代码前**先写测试**(GUT W3 起);每个 Story 留测试证据路径;
- 高影响动作(提交/删除/上线)须用户审批;本仓库 `git` 提交信息写架构基线;
- 目录微调 = 架构 §2.1 变更 = ADR 流程,不擅自挪目录。
+93
View File
@@ -0,0 +1,93 @@
# WRATH OF THE DEAD / 怨返(项目代号 F)
> 含冤而死,那就招一支阴兵,杀回人间。
> Wrongfully dead? Raise a ghost army and fight your way back to the living world.
单人挂机(idle)· Godot 4.7.2 + GDScript · Zad Archery 式横版 · 水墨绘卷视觉
架构基线:`docs/architecture/2026-10-19-F-architecture.md`(v1.0,2026-10-19)
工程宪法摘要:`CLAUDE.md`(必读)
## 1. 环境要求
| 项 | 要求 | 备注 |
|---|---|---|
| 引擎 | **Godot 4.7.2**(.NET 不需要,纯 GDScript) | 钉 4.7.x 补丁线;跨 minor 不追 |
| 导出模板 | 4.7.2 stable 模板(W5 CI 打包前安装) | Editor → Manage Export Templates |
| Steam | GodotSteam v4.23.1(W5 接入,W1 不依赖) | addon 二进制暂未入库 |
| GPU | 核显可跑(性能底线 30fps) | 用户 GPU 实测定预算(探针 P7) |
## 2. 快速开始
```bash
# 1) 编辑器打开本目录(project.godot)→ 等待首次资源导入
# 2) 数据门验证(headless)
godot --headless --path . --import
godot --headless --path . -s res://src/tools/import_main.gd # import_all(期望 RESULT: PASS)
godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only
# 3) 跑探针:编辑器打开 tests/probe_p1_screen_texture/probe_p1.tscn → F6
# (main scene 自 W2 起由 game/ 编排层提供,F5 直接运行)
git status # import_all 后 data/build 应无 diff(提交物已校准)
```
## 3. 仓库地图(架构 §2.1)
```
project.godot / export_presets.cfg / icon.svg / CLAUDE.md
data/sheets/*.csv 内容唯一源(策划维护;一行=一资产/一参数)
data/schema/*.json 每张表一份 JSON Schema(CI 数据门)
data/build/*.json SheetImporter 产物(提交;sim 与游戏共用)
src/core/ 纯逻辑:numbers / economy / save / sim(W3 起)
src/game/ 编排层:GameState / DataRegistry / 各 Director
src/platform/ SteamService(W5)
src/presentation/ 表现层:stage / hud / panels / modals / post
src/tools/ @tool:sheet_importer.gd + import_main.gd(CI 入口)
assets/ sprites / luts / shaders / themes / fonts
tests/ probe_*(W1)/ unit(W3 起)/ sim_reports(W4 起)
pipeline/ 3ds Max 侧契约(README.md;Max 脚本用户主场)
.github/workflows/ CI 数据门(W3+ 激活单测/sim/导出)
```
## 4. 数据管线("年更=加行"的地基,架构 §3)
```
data/sheets/*.csv ──SheetImporter(src/tools/,@tool/CI headless)──► data/build/*.json
│ schema 校验(违规=中止+行号/列名/原因)
▼(W3)
*.tres Resource ──► DataRegistry ──► 游戏 + sim(同一份,零漂移)
```
- 三条铁律:① 单向流动(游戏永不写 `data/`);② sim 与游戏读同一份 build;③ 数值改动**只改 CSV 不动代码**;
- CSV 规范:稳定字符串 ID(不重编号);`_cn/_en` 后缀;数值列禁千分位;引用列填目标 ID;
- build JSON 由导入器的确定性序列化器生成(字节稳定)——**改 CSV 后必须重跑 import 并提交 build**,否则 CI 漂移门红;
- **W1 已知**:`display_en` 为工程侧临时译名(待文策渊按 GDD §8.4 glossary 确认);`unlock_hall/unlock_gate` 引用的 halls/upgrades 表 W2 落盘后激活跨表引用校验。
## 5. CI(架构 §7.2)
`.github/workflows/ci.yml`:push/PR → checkout → setup Godot 4.7.2 → headless import → `import_all` → schema `validate` → build 漂移门(git diff)。
单元测试(W3,GUT)、sim 14 门(W4)、Windows 导出(W5)为注释占位,标注了激活条件。
**待本机验证**:4.7.2 的 `--headless --import` CLI 行为(备选 `-e --quit-after 200`)——首跑后按实际更新本 README。
## 6. W1 探针(用户侧实测,30min/项)
| 探针 | 手册(工作区 docs/engineering/) | 回填 |
|---|---|---|
| P1 后处理纹理语义 | `2026-10-19-f-w1-probe-p1-manual.md` | `docs/engineering/probes/P1-result.md` |
| P3 noise 平铺 | `2026-10-19-f-w1-probe-p3-manual.md` | `docs/engineering/probes/P3-result.md` |
P6(字体许可)/P7(GPU)为用户侧任务,结论同样回填 probes/。
## 7. 纪律速览(详见 CLAUDE.md / 架构 §9)
- look-dev 冻结(W2 生死门):冻结前不加新资产类型;Max 侧不回炉;调色只在 Godot 侧;
- 水墨四色面积纪律:墨 ~65 / 骨白 ~25 / 朱砂 ≤8% / 靛 ≤7%;
- 终局 ≤1e14;三乘区加法优先禁乘性深度叠加;
- network 作用域不适用(单人无网络代码);AI 不进包体(ADR-4)。
## 8. W1 状态与待办
- [x] 项目骨架 + git 首提交(本 README 同步日期)
- [x] 数据管线段 1(generators.csv 12 行 + schema v0 + SheetImporter v0.1 + build 提交)
- [x] 探针 P1/P3 代码 + 实测手册 + 结论模板
- [ ] 用户:跑 P1/P3 实测并回填结论;P6/P7 用户侧任务
- [ ] 用户:look-dev 游魂初版(Max 侧)→ 导入验证 sprite_row 路径
- [ ] W2 计划:管线贯通 demo(CSV→12 槽 UI)、sim 骨架、军列带纸面原型
+1
View File
@@ -0,0 +1 @@
# assets/fonts —— 子集化 OFL 字体(LXGW WenKai/Ma Shan Zheng 标题 + Noto Sans SC 正文 + JetBrains Mono 数字)。P6 许可复核后落盘;子集脚本 pipeline/fonts/(架构 §7.4)。
+1
View File
@@ -0,0 +1 @@
# assets/luts —— per-hall LUT PNG(tools/lut_generator.gd 产物,64×1)。W4 起(架构 §5.3)。
+1
View File
@@ -0,0 +1 @@
# assets/shaders —— 后处理 fused shader / 母题 shader / 单位描边兜底。探针 P1 通过后落盘(架构 §5.2)。
+1
View File
@@ -0,0 +1 @@
# assets/sprites/units —— billboard 精灵:2 朝向×2 帧 + units_atlas.png(MultiMesh)。Max 侧批量渲染产物(pipeline/ 契约);look-dev 游魂 W1-2 首片。
+1
View File
@@ -0,0 +1 @@
# assets/sprites/vfx —— 墨点/笔触/斩弧(管线报告 §1.2)。W6 juice 最小集。
+1
View File
@@ -0,0 +1 @@
# assets/themes —— 水墨 Theme(界格/卷轴/印章语言;禁圆角卡片/默认组件蓝)。W4-W6(GDD §7.2④)。
+149
View File
@@ -0,0 +1,149 @@
{
"sheet": "generators",
"schema_version": 0,
"columns": [
"id",
"display_cn",
"display_en",
"base_cost",
"cost_ratio",
"base_rate",
"unlock_hall",
"unlock_gate",
"sprite_row"
],
"rows": [
{
"id": "gen_01_youhun",
"display_cn": "游魂",
"display_en": "Wandering Souls",
"base_cost": 15,
"cost_ratio": 1.13,
"base_rate": 0.1,
"unlock_hall": "none",
"unlock_gate": "none",
"sprite_row": "res://assets/sprites/units/gen_01_youhun"
},
{
"id": "gen_02_yinbing",
"display_cn": "阴兵",
"display_en": "Ghost Soldiers",
"base_cost": 120,
"cost_ratio": 1.14,
"base_rate": 1,
"unlock_hall": "none",
"unlock_gate": "none",
"sprite_row": "res://assets/sprites/units/gen_02_yinbing"
},
{
"id": "gen_03_guizu",
"display_cn": "鬼卒班",
"display_en": "Ghost Constables",
"base_cost": 1200,
"cost_ratio": 1.15,
"base_rate": 8,
"unlock_hall": "hall_01_qinguang",
"unlock_gate": "up_gate_guizu",
"sprite_row": "res://assets/sprites/units/gen_03_guizu"
},
{
"id": "gen_04_biliao",
"display_cn": "勾魂笔僚",
"display_en": "Death-Scribe Clerks",
"base_cost": 12000,
"cost_ratio": 1.15,
"base_rate": 50,
"unlock_hall": "hall_02_chujiang",
"unlock_gate": "up_gate_biliao",
"sprite_row": "res://assets/sprites/units/gen_04_biliao"
},
{
"id": "gen_05_tangpu",
"display_cn": "孟婆汤铺",
"display_en": "Meng Po's Soup Stall",
"base_cost": 120000,
"cost_ratio": 1.16,
"base_rate": 400,
"unlock_hall": "hall_03_songdi",
"unlock_gate": "up_gate_tangpu",
"sprite_row": "res://assets/sprites/units/gen_05_tangpu"
},
{
"id": "gen_06_nanmin",
"display_cn": "枉死城难民团",
"display_en": "Refugee Camp of the Wrongfully Dead",
"base_cost": 1200000,
"cost_ratio": 1.16,
"base_rate": 3000,
"unlock_hall": "hall_04_wuguan",
"unlock_gate": "up_gate_nanmin",
"sprite_row": "res://assets/sprites/units/gen_06_nanmin"
},
{
"id": "gen_07_shuban",
"display_cn": "阴律司书办",
"display_en": "Clerks of the Nether Court",
"base_cost": 12000000,
"cost_ratio": 1.17,
"base_rate": 24000,
"unlock_hall": "hall_05_yama",
"unlock_gate": "up_gate_shuban",
"sprite_row": "res://assets/sprites/units/gen_07_shuban"
},
{
"id": "gen_08_bingfang",
"display_cn": "幽都兵坊",
"display_en": "Armory of the Dark Capital",
"base_cost": 120000000,
"cost_ratio": 1.17,
"base_rate": 180000,
"unlock_hall": "hall_06_biancheng",
"unlock_gate": "up_gate_bingfang",
"sprite_row": "res://assets/sprites/units/gen_08_bingfang"
},
{
"id": "gen_09_wodi",
"display_cn": "十殿卧底",
"display_en": "Sleeper Agents in the Ten Courts",
"base_cost": 1200000000,
"cost_ratio": 1.18,
"base_rate": 1400000,
"unlock_hall": "hall_07_taishan",
"unlock_gate": "up_gate_wodi",
"sprite_row": "res://assets/sprites/units/gen_09_wodi"
},
{
"id": "gen_10_xingzhe",
"display_cn": "轮回裂隙行者",
"display_en": "Rift Walkers of the Wheel",
"base_cost": 12000000000,
"cost_ratio": 1.18,
"base_rate": 11000000,
"unlock_hall": "hall_08_dushi",
"unlock_gate": "up_gate_xingzhe",
"sprite_row": "res://assets/sprites/units/gen_10_xingzhe"
},
{
"id": "gen_11_yinhun",
"display_cn": "六道引魂人",
"display_en": "Soul Guides of the Six Paths",
"base_cost": 120000000000,
"cost_ratio": 1.19,
"base_rate": 85000000,
"unlock_hall": "hall_09_pingdeng",
"unlock_gate": "up_gate_yinhun",
"sprite_row": "res://assets/sprites/units/gen_11_yinhun"
},
{
"id": "gen_12_tianzi",
"display_cn": "幽冥天子",
"display_en": "Emperor of the Netherworld",
"base_cost": 1200000000000,
"cost_ratio": 1.2,
"base_rate": 650000000,
"unlock_hall": "none",
"unlock_gate": "up_gate_tianzi",
"sprite_row": "res://assets/sprites/units/gen_12_tianzi"
}
]
}
+84
View File
@@ -0,0 +1,84 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "generators.schema.json",
"title": "generators 表 schema v0(十二班底发生器,数值抄录自 GDD §3.1)",
"description": "架构 §3.2 CSV 规范落盘。数值列禁千分位;引用列填目标 ID 或 none;ID 一旦分配永不重编号。v0 仅做单表校验:unlock_hall/unlock_gate 引用的 halls.csv / upgrades.csv 目标表 W2 落盘,届时激活跨表引用完整性校验。",
"x-supported-keywords": [
"type", "properties", "required", "items", "enum", "pattern",
"minimum", "maximum", "minLength", "minItems", "maxItems",
"additionalProperties", "const"
],
"x-unique": ["id"],
"type": "object",
"required": ["sheet", "schema_version", "columns", "rows"],
"properties": {
"sheet": { "const": "generators" },
"schema_version": { "const": 0 },
"columns": {
"type": "array",
"items": { "type": "string" },
"minItems": 9
},
"rows": {
"type": "array",
"minItems": 12,
"items": {
"type": "object",
"required": [
"id", "display_cn", "display_en", "base_cost",
"cost_ratio", "base_rate", "unlock_hall", "unlock_gate", "sprite_row"
],
"properties": {
"id": {
"type": "string",
"pattern": "^gen_[0-9]{2}_[a-z0-9]+$",
"description": "稳定字符串 ID;永不重编号、永不复用(年更=加行)"
},
"display_cn": {
"type": "string",
"minLength": 1,
"description": "简体名(GDD §3.1 班底名)"
},
"display_en": {
"type": "string",
"minLength": 1,
"description": "英文名——W1 工程侧临时译名,待文策渊按 GDD §8.4 glossary 口径确认后回填"
},
"base_cost": {
"type": "number",
"minimum": 1,
"description": "C0(GDD §3.1);整数写法,禁千分位与 K/M/B/T 后缀"
},
"cost_ratio": {
"type": "number",
"minimum": 1,
"maximum": 2,
"description": "r(1.13→1.20,GDD §3.1)"
},
"base_rate": {
"type": "number",
"minimum": 0,
"description": "rate/s(GDD §3.1);阴德/秒"
},
"unlock_hall": {
"type": "string",
"pattern": "^(none|hall_[0-9]{2}_[a-z]+)$",
"description": "殿门闸门 ID(halls.csv 行 ID)或 none"
},
"unlock_gate": {
"type": "string",
"pattern": "^(none|up_gate_[a-z0-9_]+)$",
"description": "路引闸门 ID(upgrades.csv 行 ID)或 none;gen_12 的敕封前置(宿慧≥60 且转世≥2)属 upgrades.csv 行内规则,W2 建模"
},
"sprite_row": {
"type": "string",
"pattern": "^res://assets/sprites/[a-z_]+/[a-z0-9_]+$",
"description": "★ ADR-3:唯一允许的逐资产代码 = sprite 路径前缀(Max 侧命名 <unit_id>_<l|r>_<frame>.png)"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
+13
View File
@@ -0,0 +1,13 @@
id,display_cn,display_en,base_cost,cost_ratio,base_rate,unlock_hall,unlock_gate,sprite_row
gen_01_youhun,游魂,Wandering Souls,15,1.13,0.1,none,none,res://assets/sprites/units/gen_01_youhun
gen_02_yinbing,阴兵,Ghost Soldiers,120,1.14,1,none,none,res://assets/sprites/units/gen_02_yinbing
gen_03_guizu,鬼卒班,Ghost Constables,1200,1.15,8,hall_01_qinguang,up_gate_guizu,res://assets/sprites/units/gen_03_guizu
gen_04_biliao,勾魂笔僚,Death-Scribe Clerks,12000,1.15,50,hall_02_chujiang,up_gate_biliao,res://assets/sprites/units/gen_04_biliao
gen_05_tangpu,孟婆汤铺,Meng Po's Soup Stall,120000,1.16,400,hall_03_songdi,up_gate_tangpu,res://assets/sprites/units/gen_05_tangpu
gen_06_nanmin,枉死城难民团,Refugee Camp of the Wrongfully Dead,1200000,1.16,3000,hall_04_wuguan,up_gate_nanmin,res://assets/sprites/units/gen_06_nanmin
gen_07_shuban,阴律司书办,Clerks of the Nether Court,12000000,1.17,24000,hall_05_yama,up_gate_shuban,res://assets/sprites/units/gen_07_shuban
gen_08_bingfang,幽都兵坊,Armory of the Dark Capital,120000000,1.17,180000,hall_06_biancheng,up_gate_bingfang,res://assets/sprites/units/gen_08_bingfang
gen_09_wodi,十殿卧底,Sleeper Agents in the Ten Courts,1200000000,1.18,1400000,hall_07_taishan,up_gate_wodi,res://assets/sprites/units/gen_09_wodi
gen_10_xingzhe,轮回裂隙行者,Rift Walkers of the Wheel,12000000000,1.18,11000000,hall_08_dushi,up_gate_xingzhe,res://assets/sprites/units/gen_10_xingzhe
gen_11_yinhun,六道引魂人,Soul Guides of the Six Paths,120000000000,1.19,85000000,hall_09_pingdeng,up_gate_yinhun,res://assets/sprites/units/gen_11_yinhun
gen_12_tianzi,幽冥天子,Emperor of the Netherworld,1200000000000,1.20,650000000,none,up_gate_tianzi,res://assets/sprites/units/gen_12_tianzi
1 id display_cn display_en base_cost cost_ratio base_rate unlock_hall unlock_gate sprite_row
2 gen_01_youhun 游魂 Wandering Souls 15 1.13 0.1 none none res://assets/sprites/units/gen_01_youhun
3 gen_02_yinbing 阴兵 Ghost Soldiers 120 1.14 1 none none res://assets/sprites/units/gen_02_yinbing
4 gen_03_guizu 鬼卒班 Ghost Constables 1200 1.15 8 hall_01_qinguang up_gate_guizu res://assets/sprites/units/gen_03_guizu
5 gen_04_biliao 勾魂笔僚 Death-Scribe Clerks 12000 1.15 50 hall_02_chujiang up_gate_biliao res://assets/sprites/units/gen_04_biliao
6 gen_05_tangpu 孟婆汤铺 Meng Po's Soup Stall 120000 1.16 400 hall_03_songdi up_gate_tangpu res://assets/sprites/units/gen_05_tangpu
7 gen_06_nanmin 枉死城难民团 Refugee Camp of the Wrongfully Dead 1200000 1.16 3000 hall_04_wuguan up_gate_nanmin res://assets/sprites/units/gen_06_nanmin
8 gen_07_shuban 阴律司书办 Clerks of the Nether Court 12000000 1.17 24000 hall_05_yama up_gate_shuban res://assets/sprites/units/gen_07_shuban
9 gen_08_bingfang 幽都兵坊 Armory of the Dark Capital 120000000 1.17 180000 hall_06_biancheng up_gate_bingfang res://assets/sprites/units/gen_08_bingfang
10 gen_09_wodi 十殿卧底 Sleeper Agents in the Ten Courts 1200000000 1.18 1400000 hall_07_taishan up_gate_wodi res://assets/sprites/units/gen_09_wodi
11 gen_10_xingzhe 轮回裂隙行者 Rift Walkers of the Wheel 12000000000 1.18 11000000 hall_08_dushi up_gate_xingzhe res://assets/sprites/units/gen_10_xingzhe
12 gen_11_yinhun 六道引魂人 Soul Guides of the Six Paths 120000000000 1.19 85000000 hall_09_pingdeng up_gate_yinhun res://assets/sprites/units/gen_11_yinhun
13 gen_12_tianzi 幽冥天子 Emperor of the Netherworld 1200000000000 1.20 650000000 none up_gate_tianzi res://assets/sprites/units/gen_12_tianzi
+46
View File
@@ -0,0 +1,46 @@
; ============================================================================
; export_presets.cfg —— W1 占位版本
; 主预设:Windows Desktop x86_64(架构 §7.1);macOS/Linux 次预设 W5 补。
; 本文件为手工最小占位;W5 CI 打包前用编辑器 Project > Export 向导重新生成校准
; (4.7 选项键以编辑器实际导出为准——4.7.x 超出训练数据,此处仅保证可解析)。
; 导出模板:Godot 4.7.2 stable 导出模板(Editor > Manage Export Templates)。
; ============================================================================
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_settings=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/wrath-of-the-dead.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
[preset.0.options]
custom_template/debug=""
custom_template/release=""
binary_format/architecture="x86_64"
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
application/modify_resources=true
application/icon=""
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name="WRATH OF THE DEAD"
application/file_description=""
application/copyright=""
application/trademarks=""
ssh_remote_deploy/enabled=false
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" fill="#1A1614"/>
<circle cx="64" cy="64" r="36" fill="none" stroke="#EDE6D8" stroke-width="7"/>
<path d="M64 22 v84 M22 64 h84" stroke="#B03A2E" stroke-width="5"/>
<circle cx="64" cy="64" r="6" fill="#B03A2E"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

+48
View File
@@ -0,0 +1,48 @@
# pipeline/ —— 3ds Max 侧协作契约(工程侧规格,用户主场)
> 架构依据:`docs/architecture/2026-10-19-F-architecture.md` §7.3 | 撰写:程基岩(W1)
> **边界纪律**:本目录是**契约**不是实现——`batch_render.ms` / `asset_manifest.csv` 由用户(3ds Max 侧)实现;工程侧只消费资产不碰 Max。Max 侧交片后不回炉(除造型错误);所有调色动作只在 Godot 侧(ADR-1 / 架构 §9.1 C2)。
## 1. 命名约定(与 `data/sheets/generators.csv` 的 `sprite_row` 对齐)
```
<unit_id>_<朝向>_<帧>.png
```
- `<unit_id>` = `generators.csv` 的 `id` 列(如 `gen_01_youhun`)——**新单位先在 CSV 加行,再出图**(ADR-3:新资产 = 加行);
- `<朝向>`:`l`(面左)/ `r`(面右)——军列向右推进时用 `r`,殿王战主角提刀用 `l`;
- `<帧>`:`0`(站立)/ `1`(行军起伏)——MVP 每朝向 2 帧(架构 §5.1);
- 示例:`gen_01_youhun_r_0.png`、`gen_03_guizu_l_1.png`;
- 交付位置:`assets/sprites/units/`(本仓库);图集打包在 W2+ 由 `tools/` 侧脚本合 `units_atlas.png` + UV manifest。
## 2. 规格(每片必守)
| 项 | 规格 | 依据 |
|---|---|---|
| 世界尺度 | 统一身高尺度:单位 256px 画布 ≈ 统一世界高度(十殿单位同尺度) | 架构 §5.1 |
| 轴心 | 统一**脚底轴心**(bottom-center) | 架构 §5.1 |
| 尺寸档 | 单位 256×256 / 殿王·套件 512×512 / 商店 hero 1024×1024 | 架构 §5.1 |
| 格式 | PNG 32-bit **直通 alpha**(不做预乘、不烘背景) | 架构 §5.1 |
| 上色 | Ink'n'Paint **3 阶平色** + 墨黑 `#1A1614` 描边**一带**(粗细一致 ±1px @256) | 架构 §9.1 冻结评审 #6 |
| 描边写入 | 描边烘焙进 sprite(主方案);P2 探针复核 alpha 边缘 halo | 架构 §11 P2 |
| 面数/灯光 | primitive 摆件 + 三灯即可;不追求写实 | 管线报告 §1.2 |
| 剪影 | 可读剪影优先(评审 #3);刑典母题走剪影 + 文字暗示(GDD §0.3 IP 红线) | GDD §0.3 |
## 3. look-dev 三件套(W1-2 生死门,架构 §10.1)
1. **一单位**:游魂 `gen_01_youhun`,2 朝向 × 2 帧,256×256;
2. **一背景**:第 1 殿秦广王 band(远景水墨 shader 层 + 中景剪影套件 3-5 件 + 近景地面)——背景层为**程序化 shader**(架构 §5.3),Max 侧只出剪影套件;
3. **一殿王**:秦广王 512×512。
完整 shader 堆栈在线后走 §10.1 十项冻结评审(签字 + 参数快照 + 截图入 `docs/engineering/`)。
## 4. `asset_manifest.csv`(用户侧维护,建议列)
`id, name, variant, size, has_left, has_right, frames, notes` —— 与 `generators.csv` 同 ID 口径; batch_render.ms 从本清单读批量出图。
## 5. W1 状态
- [x] 契约文档(本文件);
- [ ] `batch_render.ms`(用户,2-4h 一次性投入);
- [ ] `asset_manifest.csv`(用户,随首单位建立);
- [ ] look-dev 首单位 `gen_01_youhun` 初版(用户)+ 导入 Godot 可见(工程侧验收:sprite_row 路径即)。
+1
View File
@@ -0,0 +1 @@
# pipeline/maxscript —— batch_render.ms + asset_manifest.csv 属用户(3ds Max 侧)任务;本目录在 W1 留空。契约见 ../README.md(架构 §7.3)。
+37
View File
@@ -0,0 +1,37 @@
; Engine configuration file.
; ============================================================================
; F《WRATH OF THE DEAD / 怨返》—— Godot 4.7.x 补丁线(当前钉 4.7.2)
; 架构基线:docs/architecture/2026-10-19-F-architecture.md §7.1(W1 落盘)
; 纪律:小版本补丁可升;跨 minor(4.8)上线后不追。
; 知识缺口声明:4.7.x 超出训练数据范围——本文件键名均为 Godot 4.x 稳定键,
; 首次用编辑器打开时若有迁移/配置提示,记录到 docs/engineering/ 后同步更新本
; 文件与 CLAUDE.md;不要静默跨 minor。
; ============================================================================
config_version=5
[application]
config/name="WRATH OF THE DEAD"
config/description="Wrongfully dead? Raise a ghost army and fight your way back to the living world."
run/main_scene=""
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/size/window_width_override=1280
window/size/window_height_override=720
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
2d/snap/snap_2d_transforms_to_pixel=true
2d/snap/snap_2d_vertices_to_pixel=true
textures/vram_compression/import_etc2_astc=true
textures/vram_compression/import_s3tc_bptc=true
+1
View File
@@ -0,0 +1 @@
# src/core/economy —— 权威经济核心:EconomyState.tick() 10Hz 确定性;GeneratorDef/UpgradeDef Resource 数据行;MultiplierSet 三乘区;PrestigeModel;OfflineModel。W3 落盘(架构 §4.2)。
+1
View File
@@ -0,0 +1 @@
# src/core/numbers —— BigNumber(double 封装 + ≤1e14 断言) / number_format(K/M/B/T + tabular)。W3 落盘(架构 §2.1/§4.2)。纪律:core 无 Node 依赖、零热路径分配。
+1
View File
@@ -0,0 +1 @@
# src/core/save —— save_game.gd(SAVE_VERSION + read/write)+ migration_registry.gd(版本链迁移,每迁移函数配单测)。W5 落盘(架构 §3.5/§6)。
+1
View File
@@ -0,0 +1 @@
# src/core/sim —— 无头平衡模拟器:sim_main.gd(extends SceneTree)+ simulator.gd + strategies/ + gates.gd(14 门)。W2 骨架 / W4 首轮(架构 §10.3)。
+1
View File
@@ -0,0 +1 @@
# src/game —— 编排层(唯一权威状态):GameState / DataRegistry / EventDirector / UnlockDirector / BossBattle。W2-W6 落盘(架构 §2.3/§4)。
+1
View File
@@ -0,0 +1 @@
# src/platform —— SteamService:GodotSteam v4.23.1 唯一封装。game/ 禁止直调 Steam.*。W5 落盘(架构 §6.4)。
+1
View File
@@ -0,0 +1 @@
# src/presentation —— 表现层:只读快照 + 发命令;UI 不持有游戏状态。stage/hud/panels/modals/post 子域。W2 起落盘(架构 §5)。
+1
View File
@@ -0,0 +1 @@
# src/presentation/hud —— HUD 条(阴德/秒、十殿小地图、下一目标进度条)。W4-W6(架构 §7.1/GDD §7.1)。
+1
View File
@@ -0,0 +1 @@
# src/presentation/modals —— 绘卷语言模态:冤魂申诉竖卷 / 殿王战过场 / 转世 / 敕封。W6(架构 §7.1)。
+1
View File
@@ -0,0 +1 @@
# src/presentation/panels —— 右滑抽屉:升级树(5 页签)/ 成就殿堂 / 冤案卡册。W4-W6(架构 §7.1)。
+1
View File
@@ -0,0 +1 @@
# src/presentation/post —— post_process_rect.gd:单趟 fused 后处理载体(posterize→LUT→纸纹→暗角;UI 层其上)。P1 过后 W3-W4 落盘(架构 §5.2)。
+1
View File
@@ -0,0 +1 @@
# src/presentation/stage —— stage_root.tscn / camera_rig.gd / hall_band.gd / army_band.gd / boss_arena.gd。W3 起落盘(架构 §4.3)。
+51
View File
@@ -0,0 +1,51 @@
extends SceneTree
# ============================================================================
# SheetImporter CLI 入口(CI headless / 编辑器外批量导入)
# 用法:
# godot --headless --path . -s res://src/tools/import_main.gd
# godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only
# 退出码:0 = 无违规;1 = 有违规或异常(CI 数据门按此判红)
#
# 依赖:使用前先跑 `godot --headless --path . --import`(生成全局类缓存,
# 本脚本才能解析全局 class_name SheetImporter)。参见 .github/workflows/ci.yml。
#
# 注意:4.7.x 超出训练数据——`-s` + SceneTree 入口模式为 4.x 稳定模式;
# 若 4.7.2 行为变化,按实际报错修正并记录到 docs/engineering/。
# ============================================================================
func _initialize() -> void:
var user_args := OS.get_cmdline_user_args()
var all_args := OS.get_cmdline_args()
var validate_only := user_args.has("--validate-only") or all_args.has("--validate-only")
var code := 0
if validate_only:
code = _run_validate()
else:
code = _run_import()
quit(code)
func _run_import() -> int:
print("=== SheetImporter import_all:sheets → build/*.json(schema 校验 + 差异化报告)===")
var report := SheetImporter.import_all()
for s in report.sheets:
print(s.summary())
print(report.summary())
if report.ok():
print("RESULT: PASS(%d 表 / %d 行 / 0 违规)" % [report.sheets.size(), report.row_count])
return 0
push_error("RESULT: FAIL(%d 条违规;schema 违规 = 构建红,禁止跳过坏行静默导入)" % report.violations.size())
return 1
func _run_validate() -> int:
print("=== SheetImporter validate:data/build 对 schema 合规性 ===")
var violations := SheetImporter.validate(SheetImporter.BUILD_DIR)
if violations.is_empty():
print("RESULT: PASS(build 目录全部合规)")
return 0
for v in violations:
push_error(v)
push_error("RESULT: FAIL(%d 条违规)" % violations.size())
return 1
+636
View File
@@ -0,0 +1,636 @@
@tool
class_name SheetImporter
extends RefCounted
# ============================================================================
# SheetImporter v0.1 —— CSV → JSON 单向数据管线(架构 §3)
#
# 铁律(架构 §3.1/§3.2/§3.3):
# ① 单向流动:本工具只写 data/build/;游戏永不写 data/
# ② schema 违规 = 导入中止 + 违规清单(行号 + 列名 + 原因);禁止跳过坏行静默导入
# ③ 一行 = 一条数据;首列 id 稳定字符串 ID,永不重编号、永不复用(年更=加行)
# ④ 数值列禁千分位/禁单位后缀;文本列空 = 回退(schema required 已挡空值)
#
# 入口(headless/CI):src/tools/import_main.gd
# godot --headless --path . -s res://src/tools/import_main.gd
# godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only
#
# 范围声明(W1 骨架):
# - CSV → data/build/*.json:完整可用
# - → *.tres:接口在(export_tres),W3 GeneratorDef 落盘后启用(ADR-3)
# - 跨表引用完整性(halls/upgrades 等目标表):W2 落盘后激活(schema 已预留说明)
#
# 知识缺口:Godot 4.7.x 超出训练数据。本文件只用 4.x 稳定 API
# (DirAccess / FileAccess / JSON / RegEx / String)。本机首跑若有 API
# 报错,按 4.7.2 实际报错修正,并记录到 docs/engineering/。
# ============================================================================
const SHEETS_DIR := "res://data/sheets"
const SCHEMA_DIR := "res://data/schema"
const BUILD_DIR := "res://data/build"
## 本工具实现的 JSON Schema 子集白名单;schema 出现白名单外关键词 = schema 违规
const SUPPORTED_KEYWORDS: Array[String] = [
"type", "properties", "required", "items", "enum", "pattern",
"minimum", "maximum", "minLength", "minItems", "maxItems",
"additionalProperties", "const",
]
const META_KEYWORDS: Array[String] = ["$schema", "$id", "title", "description"]
class ImportReport extends RefCounted:
var sheet: String = ""
var csv_path: String = ""
var json_path: String = ""
var row_count: int = 0
var added: int = 0
var changed: int = 0
var removed: int = 0
var violations: Array[String] = []
var notes: Array[String] = []
var tres_export: Array[String] = []
var sheets: Array = [] ## import_all 聚合时的子报告
func ok() -> bool:
return violations.is_empty()
func summary() -> String:
var lines := PackedStringArray()
var head := "表 %s:%d 行(+%d 新增 / ~%d 变更 / -%d 删除)→ %s" % [
sheet, row_count, added, changed, removed,
json_path.get_file() if json_path != "" else "未写盘"
]
lines.append(head)
for v in violations:
lines.append(" [违规] " + v)
for n in notes:
lines.append(" [备注] " + n)
for t in tres_export:
lines.append(" [tres] " + t)
return "\n".join(lines)
# ----------------------------------------------------------------------------
# 公开接口(架构 §3.3 签名级)
# ----------------------------------------------------------------------------
## 全量导入:sheets → build/*.json。返回聚合报告(新增/变更/违规行)。
static func import_all() -> ImportReport:
var report := ImportReport.new()
report.sheet = "ALL"
if not DirAccess.dir_exists_absolute(SHEETS_DIR):
report.violations.append("表目录不存在:%s" % SHEETS_DIR)
return report
var csv_files := DirAccess.get_files_at(SHEETS_DIR)
csv_files.sort()
if csv_files.is_empty():
report.violations.append("表目录为空:%s" % SHEETS_DIR)
return report
var done := 0
for file in csv_files:
if not file.ends_with(".csv"):
continue
var csv_path := "%s/%s" % [SHEETS_DIR, file]
var sheet_name := file.get_basename()
var schema_path := "%s/%s.schema.json" % [SCHEMA_DIR, sheet_name]
var schema_v: Variant = _load_json_file(schema_path)
if not (schema_v is Dictionary):
report.violations.append("%s 的 schema 缺失或解析失败(每张表必须有 data/schema/<表名>.schema.json)" % file)
continue
var result: Dictionary = import_sheet(csv_path, schema_v)
done += 1
report.sheets.append(result)
report.added += int(result.get("added", 0))
report.changed += int(result.get("changed", 0))
report.removed += int(result.get("removed", 0))
var tr: Array = result.get("typed_rows", [])
report.row_count += tr.size()
var sub_v: Array = result.get("violations", [])
for v in sub_v:
report.violations.append(str(v))
var sub_n: Array = result.get("notes", [])
for n in sub_n:
report.notes.append(str(n))
var sub_t: Array = result.get("tres_export", [])
for t in sub_t:
report.tres_export.append(str(t))
if done == 0:
report.violations.append("未找到任何 CSV 表:%s" % SHEETS_DIR)
return report
## 单表导入;typed_rows 为按 schema 转换后的强类型字典数组(建表头顺序键序)。
static func import_sheet(csv_path: String, schema: Dictionary) -> Dictionary:
var out := {
"ok": false,
"sheet": _sheet_name(schema),
"csv_path": csv_path,
"json_path": "",
"columns": [],
"typed_rows": [],
"added": 0, "changed": 0, "removed": 0,
"violations": [],
"notes": [],
"tres_export": [],
}
# 0) schema 自检:关键词白名单
var kw_err := _check_schema_keywords(schema)
if kw_err != "":
out.violations.append(kw_err)
return out
# 1) 读 CSV
var text := FileAccess.get_file_as_string(csv_path)
if text == "":
out.violations.append("%s:文件为空或读取失败" % csv_path.get_file())
return out
var parsed: Dictionary = _parse_csv(text)
if parsed.has("error"):
out.violations.append(str(parsed.error))
return out
var header: Array = parsed.header
var rows: Array = parsed.rows
# 2) 表头 对 schema 列声明
var items := _rows_items(schema)
var row_props := _row_props(schema)
if row_props.is_empty() or items.is_empty():
out.violations.append("schema 缺少 rows.items.properties 定义(schema 结构错误)")
return out
var required_v: Variant = items.get("required", [])
var required: Array = required_v if required_v is Array else []
var missing := PackedStringArray()
var extra := PackedStringArray()
for col in required:
if not header.has(col):
missing.append(str(col))
for col in header:
if not row_props.has(col):
extra.append(str(col))
if not missing.is_empty():
out.violations.append("%s:表头缺少必填列 %s(CSV 规范:列名与 schema 严格一致)" % [csv_path.get_file(), str(missing)])
if not extra.is_empty():
out.violations.append("%s:表头出现 schema 未声明列 %s(新增列 = schema 版本升级,勿静默加列)" % [csv_path.get_file(), str(extra)])
if header.size() != required.size() and missing.is_empty() and extra.is_empty():
out.violations.append("%s:表头列数 %d ≠ schema 声明 %d(疑似重复列名)" % [csv_path.get_file(), header.size(), required.size()])
if not out.violations.is_empty():
return out
# 3) 行级转换 + 校验(每个错误带 CSV 行号 + 列名 + 原因)
var typed_rows: Array = []
var row_lines: Array = []
for r in rows:
var line_no: int = int(r.line)
var cells: Array = r.cells
if cells.size() != header.size():
out.violations.append("%s 第 %d 行:列数 %d ≠ 表头 %d(检查是否漏/多逗号,或单元格内逗号未用引号包裹)" % [csv_path.get_file(), line_no, cells.size(), header.size()])
continue
var row := {}
for i in header.size():
var col: String = header[i]
var raw: String = str(cells[i])
var prop: Dictionary = row_props.get(col, {})
var conv: Dictionary = _convert(raw, prop)
if conv.error != "":
out.violations.append("%s 第 %d 行 列 '%s':%s" % [csv_path.get_file(), line_no, col, str(conv.error)])
else:
var sc := _check_scalar(prop, conv.value)
if sc != "":
out.violations.append("%s 第 %d 行 列 '%s':%s(得到 '%s')" % [csv_path.get_file(), line_no, col, sc, str(conv.value)])
row[col] = conv.value
typed_rows.append(row)
row_lines.append(line_no)
# 4) 行级规则:唯一 ID(schema x-unique)+ 最少行数(GDD 锁定 12)
var unique_v: Variant = schema.get("x-unique", [])
if unique_v is Array:
for uc in unique_v:
var ucs := str(uc)
var seen := {}
var idx := 0
for row in typed_rows:
var idv := str(row.get(ucs, "<missing>"))
if seen.has(idv):
out.violations.append("%s 第 %d 行 列 '%s':重复值 '%s'(稳定 ID 永不重编号/复用;前次出现约第 %d 行)" % [
csv_path.get_file(), int(row_lines[idx]), ucs, idv, int(seen[idv])])
seen[idv] = int(row_lines[idx])
idx += 1
if items.has("minItems") and typed_rows.size() < int(items.minItems):
out.violations.append("%s:数据行 %d < schema minItems %d(GDD 锁定 12 班底;删行 = 设计动作,走 ADR 流程)" % [csv_path.get_file(), typed_rows.size(), int(items.minItems)])
if not out.violations.is_empty():
return out # 失败策略:有违规不写 build,导入中止
# 5) 组装 build 文档(确定性 canonical JSON)+ 写盘
var doc := {
"sheet": _sheet_name(schema),
"schema_version": _schema_version(schema),
"columns": header,
"rows": typed_rows,
}
var json_path := "%s/%s.json" % [BUILD_DIR, _sheet_name(schema)]
# 先读旧 build(差异化对比必须在覆盖前读)
var prev: Variant = _load_json_file(json_path)
var prev_rows: Array = []
if prev is Dictionary:
prev_rows = prev.get("rows", [])
DirAccess.make_dir_recursive_absolute(BUILD_DIR)
var f := FileAccess.open(json_path, FileAccess.WRITE)
if f == null:
out.violations.append("写入失败:%s(%s)" % [json_path, error_string(FileAccess.get_open_error())])
return out
f.store_string(_canonical_json(doc, "") + "\n")
f.close()
out.json_path = json_path
out.columns = header
out.typed_rows = typed_rows
# 6) 与旧 build 对比:新增/变更/删除(按稳定 ID)
if prev_rows.is_empty():
out.added = typed_rows.size()
out.notes.append("首次生成 %s" % json_path.get_file())
else:
var new_by_id := {}
for row in typed_rows:
new_by_id[str(row.get("id", ""))] = row
var old_by_id := {}
for row in prev_rows:
old_by_id[str(row.get("id", ""))] = row
for id in new_by_id:
if not old_by_id.has(id):
out.added += 1
elif _canonical_json(new_by_id[id], "") != _canonical_json(old_by_id[id], ""):
out.changed += 1
for id in old_by_id:
if not new_by_id.has(id):
out.removed += 1
# 7) .tres 导出(v0.1 stub:接口在,W3 启用——见 export_tres)
var tres: Array[String] = export_tres(json_path, BUILD_DIR + "/tres")
out.tres_export = tres
out.ok = true
return out
## 校验 build/*.json 对 schema 的合规性;违规列表为空 = 通过。
## 行号约定:rows[i] 报为"第 i+2 行"(对齐 CSV 物理行,表头 = 第 1 行)。
static func validate(json_dir: String) -> Array[String]:
var violations: Array[String] = []
if not DirAccess.dir_exists_absolute(json_dir):
violations.append("build 目录不存在:%s(先运行 import_all)" % json_dir)
return violations
var schema_files := DirAccess.get_files_at(SCHEMA_DIR)
schema_files.sort()
for sf in schema_files:
if not sf.ends_with(".schema.json"):
continue
var sheet_name := sf.substr(0, sf.length() - ".schema.json".length())
var schema_v: Variant = _load_json_file("%s/%s" % [SCHEMA_DIR, sf])
if not (schema_v is Dictionary):
violations.append("schema 解析失败:%s" % sf)
continue
var json_path := "%s/%s.json" % [json_dir, sheet_name]
var doc: Variant = _load_json_file(json_path)
if doc == null:
violations.append("缺少 build 产物:%s(运行 import_all 生成)" % json_path)
continue
var where := "%s → %s" % [sheet_name, json_path.get_file()]
_validate_value(doc, schema_v, where, "", violations, -1)
return violations
## ★ ADR-3:唯一允许的逐资产代码 = sprite 路径(已是 CSV 列)。
## .tres 生成 v0.1 = stub:接口定义在此,W3 GeneratorDef 数据类落盘后启用。
static func export_tres(build_json_path: String, out_dir: String) -> Array[String]:
return ["[stub] %s → %s/*.tres:W3 GeneratorDef 落盘后启用(数据行 → Resource)" % [build_json_path.get_file(), out_dir]]
# ----------------------------------------------------------------------------
# CSV 解析(RFC4180 简化版:引号包裹 + "" 转义;v0 不支持内嵌换行)
# ----------------------------------------------------------------------------
static func _parse_csv(text: String) -> Dictionary:
if text.begins_with("\ufeff"):
text = text.substr(1)
var header: Array = []
var rows: Array = []
var cells: Array = []
var cell := ""
var quoted := false
var in_quotes := false
var line := 1
var i := 0
var n := text.length()
while i < n:
var c := text[i]
if in_quotes:
if c == '"':
if i + 1 < n and text[i + 1] == '"':
cell += '"'
i += 2
continue
in_quotes = false
i += 1
continue
cell += c
i += 1
continue
if c == '"' and cell == "":
in_quotes = true
quoted = true
i += 1
continue
if c == ",":
cells.append(_cell_value(cell, quoted))
cell = ""
quoted = false
i += 1
continue
if c == "\r":
i += 1
continue
if c == "\n":
cells.append(_cell_value(cell, quoted))
_push_line(cells, line, header, rows)
cells = []
cell = ""
quoted = false
line += 1
i += 1
continue
cell += c
i += 1
if in_quotes:
return {"error": "CSV 引号未闭合(v0 规范:单元格内双引号以 \"\" 转义;不支持内嵌换行)"}
if cell != "" or quoted or not cells.is_empty():
cells.append(_cell_value(cell, quoted))
_push_line(cells, line, header, rows)
return {"header": header, "rows": rows}
static func _cell_value(cell: String, was_quoted: bool) -> String:
# v0 简化:非引号单元格去首尾空白;引号单元格原样保留(本项目无首尾空白敏感数据)
return cell if was_quoted else cell.strip_edges()
static func _push_line(cells: Array, line_no: int, header: Array, rows: Array) -> void:
if cells.size() == 1 and str(cells[0]).strip_edges() == "":
return # 跳过空行
if header.is_empty():
for c in cells:
if not (c is String):
return
header.append_array(cells)
return
rows.append({"line": line_no, "cells": cells})
# ----------------------------------------------------------------------------
# 类型转换 / 标量校验
# ----------------------------------------------------------------------------
static func _convert(raw: String, prop: Dictionary) -> Dictionary:
var t := str(prop.get("type", "string"))
match t:
"string":
return {"value": raw, "error": ""}
"integer":
if _re_match("^[+-]?[0-9]+$", raw) == false:
return {"value": null, "error": "期望 integer,得到 '%s'(数值列禁千分位/禁单位后缀;示例:1200000)" % raw}
return {"value": int(raw), "error": ""}
"number":
if _re_match("^[+-]?([0-9]+(\\.[0-9]+)?|\\.[0-9]+)$", raw) == false:
return {"value": null, "error": "期望 number,得到 '%s'(数值列禁千分位与 K/M/B/T 后缀;示例:1.2K → 1200)" % raw}
if raw.contains("."):
return {"value": float(raw), "error": ""}
return {"value": int(raw), "error": ""}
"boolean":
var low := raw.to_lower()
if low in ["true", "1", "yes"]:
return {"value": true, "error": ""}
if low in ["false", "0", "no"]:
return {"value": false, "error": ""}
return {"value": null, "error": "期望 boolean,得到 '%s'(true/false)" % raw}
_:
return {"value": null, "error": "schema 声明了本工具不支持的类型 '%s'" % t}
return {"value": raw, "error": ""}
static func _re_match(pattern: String, text: String) -> bool:
var re := RegEx.new()
if re.compile(pattern) != OK:
push_warning("RegEx 编译失败:%s" % pattern)
return false
return re.search(text) != null
static func _check_scalar(prop: Dictionary, value: Variant) -> String:
if prop.has("const"):
var cv: Variant = prop.const
if value != cv:
return "必须等于常量 %s" % str(cv)
if prop.has("enum"):
var allowed: Variant = prop.enum
if allowed is Array and not allowed.has(value):
return "取值必须为 %s 之一" % str(allowed)
if prop.has("pattern") and value is String:
if _re_match(str(prop.pattern), value) == false:
return "不匹配模式 %s" % str(prop.pattern)
if value is int or value is float:
if prop.has("minimum") and float(value) < float(prop.minimum):
return "小于下限 %s" % str(prop.minimum)
if prop.has("maximum") and float(value) > float(prop.maximum):
return "大于上限 %s" % str(prop.maximum)
if prop.has("minLength") and value is String and value.length() < int(prop.minLength):
return "长度 %d < minLength %d" % [value.length(), int(prop.minLength)]
return ""
static func _type_ok(value: Variant, t: String) -> bool:
match t:
"string":
return value is String
"integer":
if value is int:
return true
if value is float:
return is_finite(value) and is_equal_approx(value, float(int(value)))
return false
"number":
return value is int or value is float
"boolean":
return value is bool
return false
# ----------------------------------------------------------------------------
# 文档校验(build/*.json 对 schema;schema 子集白名单可查)
# ----------------------------------------------------------------------------
static func _validate_value(value: Variant, node: Dictionary, where: String, path: String, violations: Array[String], row_index: int) -> void:
var loc := _loc(where, path, row_index)
var declared := str(node.get("type", ""))
if declared != "" and not _type_ok(value, declared):
violations.append("%s类型期望 %s,得到 %s('%s')" % [loc, declared, type_string(typeof(value)), _brief(value)])
return
if not (value is Dictionary or value is Array):
var sc := _check_scalar(node, value)
if sc != "":
violations.append("%s%s(得到 '%s')" % [loc, sc, _brief(value)])
return
if value is Dictionary:
var props_v: Variant = node.get("properties", {})
var props: Dictionary = props_v if props_v is Dictionary else {}
var req_v: Variant = node.get("required", [])
if req_v is Array:
for rk in req_v:
if not value.has(rk):
violations.append("%s缺少必填列 '%s'" % [loc, str(rk)])
var add_v: Variant = node.get("additionalProperties", true)
var add_allowed := true
if add_v is bool:
add_allowed = add_v
for k in value.keys():
var ks := str(k)
if not props.has(ks):
if not add_allowed:
violations.append("%s出现 schema 未声明的列 '%s'(新增列 = schema 版本升级,勿静默加列)" % [loc, ks])
continue
_validate_value(value[k], props[ks], where, _join_path(path, ks), violations, row_index)
return
# value is Array
if node.has("minItems") and value.size() < int(node.minItems):
violations.append("%s数量 %d < minItems %d" % [loc, value.size(), int(node.minItems)])
if node.has("maxItems") and value.size() > int(node.maxItems):
violations.append("%s数量 %d > maxItems %d" % [loc, value.size(), int(node.maxItems)])
var items_v: Variant = node.get("items", null)
if items_v is Dictionary:
var idx := 0
for item in value:
var next_row := idx if path == "rows" else row_index
_validate_value(item, items_v, where, "%s[%d]" % [path, idx], violations, next_row)
idx += 1
static func _check_schema_keywords(schema: Dictionary) -> String:
var bad := PackedStringArray()
_walk_schema(schema, bad)
if bad.is_empty():
return ""
return "schema 使用了本工具未支持的关键词:%s(支持集:%s)" % [str(bad), str(SUPPORTED_KEYWORDS)]
static func _walk_schema(node: Dictionary, bad: PackedStringArray) -> void:
for k in node.keys():
var ks := str(k)
if not ks.begins_with("x-") and not SUPPORTED_KEYWORDS.has(ks) and not META_KEYWORDS.has(ks):
bad.append(ks)
for key in ["properties", "items", "additionalProperties"]:
if node.has(key):
var v: Variant = node[key]
if v is Dictionary:
_walk_schema(v, bad)
elif v is Array:
for item in v:
if item is Dictionary:
_walk_schema(item, bad)
# ----------------------------------------------------------------------------
# 确定性 canonical JSON 序列化(build 产物字节稳定 = CI 漂移门可字节比对)
# ----------------------------------------------------------------------------
static func _canonical_json(value: Variant, indent: String) -> String:
if value is Dictionary:
if value.is_empty():
return "{}"
var lines := PackedStringArray()
for k in value.keys():
lines.append("%s\t%s: %s" % [indent, JSON.stringify(str(k)), _canonical_json(value[k], indent + "\t")])
return "{\n" + ",\n".join(lines) + "\n" + indent + "}"
if value is Array:
if value.is_empty():
return "[]"
var lines := PackedStringArray()
for v in value:
lines.append("%s\t%s" % [indent, _canonical_json(v, indent + "\t")])
return "[\n" + ",\n".join(lines) + "\n" + indent + "]"
if value is String:
return JSON.stringify(value)
if value is bool:
return "true" if value else "false"
if value is int:
return str(value)
if value is float:
if is_finite(value) and is_equal_approx(value, float(int(value))) and absf(value) < 1e15:
return str(int(value))
return str(value)
return JSON.stringify(str(value))
# ----------------------------------------------------------------------------
# 小工具
# ----------------------------------------------------------------------------
static func _rows_items(schema: Dictionary) -> Dictionary:
var props_v: Variant = schema.get("properties", {})
if not (props_v is Dictionary):
return {}
var rows_v: Variant = props_v.get("rows", {})
if not (rows_v is Dictionary):
return {}
var items_v: Variant = rows_v.get("items", {})
return items_v if items_v is Dictionary else {}
static func _row_props(schema: Dictionary) -> Dictionary:
var items := _rows_items(schema)
var props_v: Variant = items.get("properties", {})
return props_v if props_v is Dictionary else {}
static func _sheet_name(schema: Dictionary) -> String:
var props_v: Variant = schema.get("properties", {})
if props_v is Dictionary:
var sheet_v: Variant = props_v.get("sheet", {})
if sheet_v is Dictionary and sheet_v.has("const"):
return str(sheet_v.const)
var title := str(schema.get("title", "unknown"))
return title.split(" ")[0] if title != "" else "unknown"
static func _schema_version(schema: Dictionary) -> int:
var props_v: Variant = schema.get("properties", {})
if props_v is Dictionary:
var ver_v: Variant = props_v.get("schema_version", {})
if ver_v is Dictionary and ver_v.has("const"):
return int(ver_v.const)
return 0
static func _load_json_file(path: String) -> Variant:
if not FileAccess.file_exists(path):
return null
var text := FileAccess.get_file_as_string(path)
if text == "":
return null
var data: Variant = JSON.parse_string(text)
if data == null:
push_warning("JSON 解析失败:%s" % path)
return null
return data
static func _loc(where: String, path: String, row_index: int) -> String:
if row_index >= 0:
if path == "":
return "%s 第 %d 行:" % [where, row_index + 2]
return "%s 第 %d 行 %s:" % [where, row_index + 2, path]
if path == "":
return "%s:" % where
return "%s %s:" % [where, path]
static func _join_path(path: String, key: String) -> String:
if path == "":
return key
return "%s.%s" % [path, key]
static func _brief(v: Variant) -> String:
var s := str(v)
if s.length() > 32:
s = s.substr(0, 32) + "…"
return s
+14
View File
@@ -0,0 +1,14 @@
# 探针 P1 —— SCREEN_TEXTURE / CanvasLayer 排序语义
验证 Godot 4.7 后处理三问(架构 §11 P1 / §5.2 ADR-2):
① 背景色是否含入 SCREEN_TEXTURE 采样;② CanvasLayer 排序(L0 世界 < L1 后处理 < L2 UI);③ UI 层是否被后处理污染。
**完整实测手册(必读,约 30 分钟)**:
`docs/engineering/2026-10-19-f-w1-probe-p1-manual.md`(本仓库外的工作区文档树)
**跑法**:编辑器打开 `tests/probe_p1_screen_texture/probe_p1.tscn` → F6 运行当前场景。
键位:`1` 直通 / `2` 反色 / `3` 红染 / `4` 纯绿 / `V` 显隐后处理层 / `S` 截图到 `user://` / `Esc` 退出。
控制台会打印引擎版本 / rendering_method / 显卡适配器——回填结论时连同截图一起归档。
**结论回填**:`docs/engineering/probes/P1-result.md`
**不过的回退**:SubViewport + BackBufferCopy(架构预算 +2-4h)。
+183
View File
@@ -0,0 +1,183 @@
extends Node
# ============================================================================
# 探针 P1 —— Godot 4.7 hint_screen_texture / SCREEN_TEXTURE 语义
# 架构依据:§11 P1 / §5.2(ADR-2 单趟 fused 后处理的 CanvasLayer 拓扑)
# 实测手册:docs/engineering/2026-10-19-f-w1-probe-p1-manual.md
# 结论回填:docs/engineering/probes/P1-result.md
#
# 三问:
# ① 背景/世界色是否含入 SCREEN_TEXTURE 采样(后处理 L1 能否看见 L0)
# ② CanvasLayer 排序语义(L1 后处理在 L0 世界之上、L2 UI 之下)
# ③ UI 层是否被后处理污染(UI 保持 crisp、不被 shader 调制)
#
# 场景在 _ready() 中代码化搭建(不手写 .tscn 降低出错面)。
# 键位:1 直通 | 2 反色 | 3 红染 | 4 纯绿 | V 显/隐后处理层 | S 截图 user:// | Esc 退出
# ============================================================================
enum PostMode { PASSTHROUGH, INVERT, RED_TINT, SOLID_GREEN }
var _post: ColorRect
var _post_visible := true
var _mode: int = PostMode.PASSTHROUGH
var _mat: ShaderMaterial
var _hud: Label
var _shot_n := 0
func _ready() -> void:
_build_world()
_build_post()
_build_ui()
_print_environment()
func _build_world() -> void:
var world := CanvasLayer.new()
world.name = "L0_World"
world.layer = 0
add_child(world)
var bg := ColorRect.new()
bg.name = "Background"
bg.color = Color("#23384F") # 已知底色:后处理里能看见它 = 世界含入采样
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
world.add_child(bg)
var marker := ColorRect.new()
marker.name = "WorldMarker"
marker.color = Color("#D08A2E")
marker.position = Vector2(400, 240)
marker.size = Vector2(480, 240)
world.add_child(marker)
var world_label := Label.new()
world_label.name = "WorldLabel"
world_label.text = "L0 World: bg #23384F + marker #D08A2E"
world_label.position = Vector2(24, 180)
world_label.add_theme_color_override("font_color", Color.WHITE)
world.add_child(world_label)
func _build_post() -> void:
var post_layer := CanvasLayer.new()
post_layer.name = "L1_PostProcess"
post_layer.layer = 1
add_child(post_layer)
var sh := load("res://tests/probe_p1_screen_texture/probe_p1.gdshader") as Shader
_mat = ShaderMaterial.new()
_mat.shader = sh
_mat.set_shader_parameter("mode", PostMode.PASSTHROUGH)
_post = ColorRect.new()
_post.name = "PostProcessRect"
_post.material = _mat
_post.mouse_filter = Control.MOUSE_FILTER_IGNORE
_post.set_anchors_preset(Control.PRESET_FULL_RECT)
post_layer.add_child(_post)
func _build_ui() -> void:
var ui_layer := CanvasLayer.new()
ui_layer.name = "L2_UI"
ui_layer.layer = 2
add_child(ui_layer)
var root := Control.new()
root.name = "UIRoot"
root.set_anchors_preset(Control.PRESET_FULL_RECT)
ui_layer.add_child(root)
# UI 样本:四色板 + 一块纯白。被后处理污染 = 颜色变了/出现着色/纸纹
var chip_colors := [
Color("#1A1614"), Color("#EDE6D8"), Color("#B03A2E"),
Color("#3A5A6B"), Color("#FFFFFF"),
]
var chip_names := ["墨 #1A1614", "骨白 #EDE6D8", "朱砂 #B03A2E", "靛 #3A5A6B", "纯白 #FFFFFF"]
for i in chip_colors.size():
var chip := ColorRect.new()
chip.name = "Chip%d" % i
chip.color = chip_colors[i]
chip.size = Vector2(200, 40)
chip.position = Vector2(960, 48 + i * 56)
root.add_child(chip)
var lbl := Label.new()
lbl.name = "ChipLabel%d" % i
lbl.text = chip_names[i]
lbl.position = Vector2(1010, 54 + i * 56)
if chip_colors[i].get_luminance() > 0.5:
lbl.add_theme_color_override("font_color", Color.BLACK)
else:
lbl.add_theme_color_override("font_color", Color.WHITE)
root.add_child(lbl)
_hud = Label.new()
_hud.name = "HUDHelp"
_hud.text = _help_text()
_hud.position = Vector2(24, 648)
_hud.add_theme_color_override("font_color", Color.WHITE)
root.add_child(_hud)
func _help_text() -> String:
var mode_names := ["PASS直通", "INVERT反色", "RED红染", "GREEN纯绿"]
var mode_name := mode_names[_mode] if _mode >= 0 and _mode < mode_names.size() else "?"
return "探针 P1 | 1 直通 2 反色 3 红染 4 纯绿 | V 显/隐后处理层(当前:%s)| S 截图到 user:// | Esc 退出 | 当前 mode=%s" % [
"显示" if _post_visible else "隐藏", mode_name]
func _input(event: InputEvent) -> void:
var key_event := event as InputEventKey
if key_event != null and key_event.pressed and not key_event.echo:
match key_event.keycode:
KEY_1:
_set_mode(PostMode.PASSTHROUGH)
KEY_2:
_set_mode(PostMode.INVERT)
KEY_3:
_set_mode(PostMode.RED_TINT)
KEY_4:
_set_mode(PostMode.SOLID_GREEN)
KEY_V:
_post_visible = not _post_visible
_post.visible = _post_visible
_hud.text = _help_text()
KEY_S:
_screenshot()
KEY_ESCAPE:
get_tree().quit()
func _set_mode(m: int) -> void:
_mode = m
_mat.set_shader_parameter("mode", m)
_hud.text = _help_text()
var mode_names := ["PASS直通", "INVERT反色", "RED红染", "GREEN纯绿"]
print("P1 mode → %d(%s)" % [m, mode_names[m] if m >= 0 and m < mode_names.size() else "?"])
func _screenshot() -> void:
var tex := get_viewport().get_texture()
if tex == null:
push_warning("P1: viewport texture 为空,回退:请用系统截图(Win+Shift+S)")
return
var img := tex.get_image()
if img == null:
push_warning("P1: get_image() 失败,回退:请用系统截图(Win+Shift+S)")
return
var path := "user://p1_mode_%d_%02d.png" % [_mode, _shot_n]
_shot_n += 1
var err := img.save_png(path)
print("P1 screenshot → ", path, " err=", err)
func _print_environment() -> void:
var v := Engine.get_version_info()
print("=== P1 probe 环境(截图请连同此段一起归档)===")
print("godot: %d.%d.%d %s" % [int(v.major), int(v.minor), int(v.patch), str(v.status)])
print("rendering_method: ", ProjectSettings.get_setting("rendering/renderer/rendering_method"))
print("adapter: ", RenderingServer.get_video_adapter_name())
print("viewport: ", get_viewport().get_visible_rect().size)
print("stretch: ", ProjectSettings.get_setting("display/window/stretch/mode"), " / ", ProjectSettings.get_setting("display/window/stretch/aspect"))
print("=== 键位:1 直通 | 2 反色 | 3 红染 | 4 纯绿 | V 显隐后处理 | S 截图 | Esc 退出 ===")
@@ -0,0 +1,26 @@
shader_type canvas_item;
// ============================================================================
// 探针 P1 shader —— SCREEN_TEXTURE 语义(架构 §11 P1 / §5.2 ADR-2)
// 由 probe_p1.gd 以 ShaderMaterial 挂到 L1 全屏 ColorRect 上。
// mode(uniform int):
// 0 直通 —— 验证"背景/世界是否含入 SCREEN_TEXTURE 采样"
// 1 反色 —— 放大世界内容差异,便于肉眼确认采样到了 L0
// 2 红色染色 —— L1 全屏染色:验证 L2 UI 是否被后处理污染(UI 应保持原色 crisp)
// 3 纯绿 —— 顺序验证:整屏变绿 = CanvasLayer 排序 L0<L1<L2 符合预期
// ============================================================================
uniform int mode = 0;
void fragment() {
vec4 screen = texture(SCREEN_TEXTURE, SCREEN_UV);
if (mode == 0) {
COLOR = screen;
} else if (mode == 1) {
COLOR = vec4(1.0 - screen.rgb, screen.a);
} else if (mode == 2) {
COLOR = vec4(screen.r, 0.0, 0.0, 1.0);
} else {
COLOR = vec4(0.0, 1.0, 0.0, 1.0);
}
}
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/probe_p1_screen_texture/probe_p1.gd" id="1"]
[node type="Node" name="ProbeP1ScreenTexture"]
script = ExtResource("1")
+16
View File
@@ -0,0 +1,16 @@
# 探针 P3 —— NoiseTexture2D / FastNoiseLite 平铺语义
验证 `seamless = true` 是否真正可平铺(架构 §11 P3;纸纹 pass 的 tileable 前提,管线报告 §6.3)。
屏上为 `seamless = true/false` 两个 2×2 平铺网格(红线 = tile 边界);按 `S` 生成边界差图 PNG(`user://p3_diff_*.png`)并打印 wrap/grad 比统计。
**完整实测手册(必读,约 30 分钟)**:
`docs/engineering/2026-10-19-f-w1-probe-p3-manual.md`(本仓库外的工作区文档树)
**跑法**:编辑器打开 `tests/probe_p3_noise_tiling/probe_p3.tscn` → F6 运行当前场景。
键位:`S` 生成检查图 + 打印统计 / `Esc` 退出。
**结论回填**:`docs/engineering/probes/P3-result.md`
**不过的回退**:脚本预烘焙无缝纹理(用 Image 环绕混合生成 256² 纸纹源,不进 NoiseTexture2D)。
**知识缺口标注**:`FastNoiseLite.TYPE_SIMPLEX` / `FRACTAL_FBM` / `NoiseTexture2D.seamless` / `seamless_blend_ratio`
为 4.x 稳定名;若 4.7.2 打开报错,按实际属性名修正并记录到 `docs/engineering/`。
+167
View File
@@ -0,0 +1,167 @@
extends Node2D
# ============================================================================
# 探针 P3 —— NoiseTexture2D / FastNoiseLite 平铺语义(架构 §11 P3)
# 依据:管线报告 §6.3;作用于 §5.2 纸纹 pass(双尺度 noise 有序抖动的 tileable 前提)
# 实测手册:docs/engineering/2026-10-19-f-w1-probe-p3-manual.md
# 结论回填:docs/engineering/probes/P3-result.md
#
# 验证:seamless = true 是否真正可平铺
# - 屏上:左右两个 2×2 tile 网格(红线 = tile 边界 @256px)肉直视读接缝
# - 按 S:生成边界差图 PNG(user://p3_diff_*.png)+ 控制台统计
# * wrap_* = |左边缘-右边缘| / |上边缘-下边缘|(平铺接缝强度)
# * grad_mean = 相邻像素平均差(自然梯度基线)
# * seamless 若有效:wrap ≈ grad(同数量级);若无效:wrap >> grad
#
# 知识缺口:FastNoiseLite/NoiseTexture2D 属性名(seamless / seamless_blend_ratio /
# TYPE_SIMPLEX / FRACTAL_FBM)为 4.x 稳定名;4.7.2 若报错按实际属性名修正并记录。
# ============================================================================
const NOISE_SIZE := 256 # 每 tile 256×256;面板 512×512 = 2×2 平铺
const PANEL_SIZE := 512
var _textures: Array = []
func _ready() -> void:
_textures = [_make_texture(true), _make_texture(false)]
var positions := [Vector2(96, 160), Vector2(672, 160)]
var titles := ["seamless = true", "seamless = false"]
for i in _textures.size():
var tr := TextureRect.new()
tr.name = "Panel%d" % i
tr.texture = _textures[i]
tr.position = positions[i]
tr.size = Vector2(PANEL_SIZE, PANEL_SIZE)
tr.stretch_mode = TextureRect.STRETCH_TILE
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
var overlay := SeamOverlay.new()
overlay.position = positions[i]
add_child(overlay)
var t := Label.new()
t.name = "PanelTitle%d" % i
t.text = "%s(红线 = tile 边界;面板内 2×2 平铺)" % titles[i]
t.position = Vector2(int(positions[i].x), 128)
t.add_theme_color_override("font_color", Color.WHITE)
add_child(t)
var hud := Label.new()
hud.name = "HUDHelp"
hud.text = "探针 P3 | 对比两侧红线处接缝(错位/亮度跳变=异常)| S 生成检查图到 user:// 并打印统计 | Esc 退出"
hud.position = Vector2(96, 648)
hud.add_theme_color_override("font_color", Color.WHITE)
add_child(hud)
_print_environment()
func _make_texture(seamless: bool) -> NoiseTexture2D:
var fnl := FastNoiseLite.new()
fnl.seed = 20261019
fnl.noise_type = FastNoiseLite.TYPE_SIMPLEX
fnl.frequency = 0.02
fnl.fractal_type = FastNoiseLite.FRACTAL_FBM
fnl.fractal_octaves = 3
fnl.fractal_lacunarity = 2.0
fnl.fractal_gain = 0.5
var nt := NoiseTexture2D.new()
nt.width = NOISE_SIZE
nt.height = NOISE_SIZE
nt.noise = fnl
nt.seamless = seamless
nt.seamless_blend_ratio = 0.25
nt.generate_mipmaps = false
return nt
func _input(event: InputEvent) -> void:
var key_event := event as InputEventKey
if key_event != null and key_event.pressed and not key_event.echo:
match key_event.keycode:
KEY_S:
_generate_check_images()
KEY_ESCAPE:
get_tree().quit()
func _generate_check_images() -> void:
var names := ["seamless", "periodic"]
print("=== P3 检查图与统计 ===")
for i in _textures.size():
var nt: NoiseTexture2D = _textures[i]
var img := nt.get_image()
if img == null:
push_warning("P3: NoiseTexture2D.get_image() 返回 null(%s)。回退:系统截图(Win+Shift+S)+ 肉眼判读。" % names[i])
continue
var w := img.get_width()
var h := img.get_height()
# 边界差图(×8 放大):wrap 接缝越亮 = 平铺越不连续
var strip_v := Image.create(w, h, false, Image.FORMAT_RGBA8)
var strip_h := Image.create(w, h, false, Image.FORMAT_RGBA8)
for y in h:
var dv := _amp(absf(img.get_pixel(0, y).r - img.get_pixel(w - 1, y).r))
for x in w:
strip_v.set_pixel(x, y, Color(dv, dv, dv, 1.0))
for x in w:
var dh := _amp(absf(img.get_pixel(x, 0).r - img.get_pixel(x, h - 1).r))
for y in h:
strip_h.set_pixel(x, y, Color(dh, dh, dh, 1.0))
strip_v.save_png("user://p3_diff_v_%s.png" % names[i])
strip_h.save_png("user://p3_diff_h_%s.png" % names[i])
var stats := _edge_stats(img)
print("%s: wrap_v mean=%.5f max=%.5f | wrap_h mean=%.5f max=%.5f | grad_mean=%.5f → wrap/grad v=%.2fx h=%.2fx" % [
names[i], stats.wrap_v_mean, stats.wrap_v_max, stats.wrap_h_mean, stats.wrap_h_max, stats.grad_mean,
stats.wrap_v_mean / maxf(stats.grad_mean, 1e-9), stats.wrap_h_mean / maxf(stats.grad_mean, 1e-9)])
print("PNG 位置:%APPDATA%/Godot/app_userdata/<项目名>/p3_diff_*.png")
print("判读:seamless 行 wrap/grad ≈ 1-3x = 可用;periodic 行 wrap/grad ≫ 1 = 对照组生效(说明指标有效)")
func _amp(d: float) -> float:
return clampf(d * 8.0, 0.0, 1.0)
func _edge_stats(img: Image) -> Dictionary:
var w := img.get_width()
var h := img.get_height()
var wrap_v := 0.0
var wrap_v_max := 0.0
for y in h:
var d := absf(img.get_pixel(0, y).r - img.get_pixel(w - 1, y).r)
wrap_v += d
wrap_v_max = maxf(wrap_v_max, d)
var wrap_h := 0.0
var wrap_h_max := 0.0
for x in w:
var d := absf(img.get_pixel(x, 0).r - img.get_pixel(x, h - 1).r)
wrap_h += d
wrap_h_max = maxf(wrap_h_max, d)
var grad := 0.0
for y in h:
for x in w - 1:
grad += absf(img.get_pixel(x, y).r - img.get_pixel(x + 1, y).r)
return {
"wrap_v_mean": wrap_v / h,
"wrap_v_max": wrap_v_max,
"wrap_h_mean": wrap_h / w,
"wrap_h_max": wrap_h_max,
"grad_mean": grad / float((w - 1) * h),
}
func _print_environment() -> void:
var v := Engine.get_version_info()
print("=== P3 probe 环境 ===")
print("godot: %d.%d.%d %s" % [int(v.major), int(v.minor), int(v.patch), str(v.status)])
print("rendering_method: ", ProjectSettings.get_setting("rendering/renderer/rendering_method"))
print("adapter: ", RenderingServer.get_video_adapter_name())
print("noise: FastNoiseLite simplex fbm oct3 f0.02 | tile 256×256 | blend_ratio 0.25")
class SeamOverlay extends Node2D:
const TILE := 256
const PANEL := 512
func _draw() -> void:
var c := Color(1.0, 0.2, 0.2, 0.6)
draw_line(Vector2(TILE, 0), Vector2(TILE, PANEL), c, 1.0)
draw_line(Vector2(0, TILE), Vector2(PANEL, TILE), c, 1.0)
draw_rect(Rect2(Vector2.ZERO, Vector2(PANEL, PANEL)), c, false, 1.0)
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/probe_p3_noise_tiling/probe_p3.gd" id="1"]
[node type="Node2D" name="ProbeP3NoiseTiling"]
script = ExtResource("1")
+1
View File
@@ -0,0 +1 @@
# tests/sim_reports —— 模拟器 14 门报告 report-<date>.json(git 跟踪,月度对账证据)。W4 首轮(架构 §9.3/§10.3)。
+1
View File
@@ -0,0 +1 @@
# tests/unit —— GUT 单测(core 全覆盖:公式/迁移/格式化)。W3 激活;GUT addon 待合入(CI W3 占位)。