From 856326a7661d62d3b8f463d5a19ee86a21cbd61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=9F=BA=E5=B2=A9=EF=BC=88engineering-lead?= =?UTF-8?q?=EF=BC=89?= Date: Mon, 19 Oct 2026 15:13:10 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20W1=20=E9=A1=B9=E7=9B=AE=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=20=E2=80=94=20=E5=9F=BA=E7=BA=BF=20v1.0=20=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E8=90=BD=E7=9B=98=EF=BC=88Godot=204.7.2=20/=20gl=5Fco?= =?UTF-8?q?mpatibility=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构基线: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 侧) --- .gitattributes | 18 + .github/workflows/ci.yml | 99 +++ .gitignore | 21 + CLAUDE.md | 74 ++ README.md | 93 +++ assets/fonts/.gitkeep | 1 + assets/luts/.gitkeep | 1 + assets/shaders/.gitkeep | 1 + assets/sprites/units/.gitkeep | 1 + assets/sprites/vfx/.gitkeep | 1 + assets/themes/.gitkeep | 1 + data/build/generators.json | 149 ++++ data/schema/generators.schema.json | 84 +++ data/sheets/generators.csv | 13 + export_presets.cfg | 46 ++ icon.svg | 6 + pipeline/README.md | 48 ++ pipeline/maxscript/.gitkeep | 1 + project.godot | 37 + src/core/economy/.gitkeep | 1 + src/core/numbers/.gitkeep | 1 + src/core/save/.gitkeep | 1 + src/core/sim/.gitkeep | 1 + src/game/.gitkeep | 1 + src/platform/.gitkeep | 1 + src/presentation/.gitkeep | 1 + src/presentation/hud/.gitkeep | 1 + src/presentation/modals/.gitkeep | 1 + src/presentation/panels/.gitkeep | 1 + src/presentation/post/.gitkeep | 1 + src/presentation/stage/.gitkeep | 1 + src/tools/import_main.gd | 51 ++ src/tools/sheet_importer.gd | 636 ++++++++++++++++++ tests/probe_p1_screen_texture/README.md | 14 + tests/probe_p1_screen_texture/probe_p1.gd | 183 +++++ .../probe_p1_screen_texture/probe_p1.gdshader | 26 + tests/probe_p1_screen_texture/probe_p1.tscn | 6 + tests/probe_p3_noise_tiling/README.md | 16 + tests/probe_p3_noise_tiling/probe_p3.gd | 167 +++++ tests/probe_p3_noise_tiling/probe_p3.tscn | 6 + tests/sim_reports/.gitkeep | 1 + tests/unit/.gitkeep | 1 + 42 files changed, 1814 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 assets/fonts/.gitkeep create mode 100644 assets/luts/.gitkeep create mode 100644 assets/shaders/.gitkeep create mode 100644 assets/sprites/units/.gitkeep create mode 100644 assets/sprites/vfx/.gitkeep create mode 100644 assets/themes/.gitkeep create mode 100644 data/build/generators.json create mode 100644 data/schema/generators.schema.json create mode 100644 data/sheets/generators.csv create mode 100644 export_presets.cfg create mode 100644 icon.svg create mode 100644 pipeline/README.md create mode 100644 pipeline/maxscript/.gitkeep create mode 100644 project.godot create mode 100644 src/core/economy/.gitkeep create mode 100644 src/core/numbers/.gitkeep create mode 100644 src/core/save/.gitkeep create mode 100644 src/core/sim/.gitkeep create mode 100644 src/game/.gitkeep create mode 100644 src/platform/.gitkeep create mode 100644 src/presentation/.gitkeep create mode 100644 src/presentation/hud/.gitkeep create mode 100644 src/presentation/modals/.gitkeep create mode 100644 src/presentation/panels/.gitkeep create mode 100644 src/presentation/post/.gitkeep create mode 100644 src/presentation/stage/.gitkeep create mode 100644 src/tools/import_main.gd create mode 100644 src/tools/sheet_importer.gd create mode 100644 tests/probe_p1_screen_texture/README.md create mode 100644 tests/probe_p1_screen_texture/probe_p1.gd create mode 100644 tests/probe_p1_screen_texture/probe_p1.gdshader create mode 100644 tests/probe_p1_screen_texture/probe_p1.tscn create mode 100644 tests/probe_p3_noise_tiling/README.md create mode 100644 tests/probe_p3_noise_tiling/probe_p3.gd create mode 100644 tests/probe_p3_noise_tiling/probe_p3.tscn create mode 100644 tests/sim_reports/.gitkeep create mode 100644 tests/unit/.gitkeep diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7bf796f --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bbf4c7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ } + # --------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b5829e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ce9ee2 --- /dev/null +++ b/CLAUDE.md @@ -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 流程,不擅自挪目录。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..193818e --- /dev/null +++ b/README.md @@ -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 骨架、军列带纸面原型 diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000..0a0dbc0 --- /dev/null +++ b/assets/fonts/.gitkeep @@ -0,0 +1 @@ +# assets/fonts —— 子集化 OFL 字体(LXGW WenKai/Ma Shan Zheng 标题 + Noto Sans SC 正文 + JetBrains Mono 数字)。P6 许可复核后落盘;子集脚本 pipeline/fonts/(架构 §7.4)。 diff --git a/assets/luts/.gitkeep b/assets/luts/.gitkeep new file mode 100644 index 0000000..52cc2ff --- /dev/null +++ b/assets/luts/.gitkeep @@ -0,0 +1 @@ +# assets/luts —— per-hall LUT PNG(tools/lut_generator.gd 产物,64×1)。W4 起(架构 §5.3)。 diff --git a/assets/shaders/.gitkeep b/assets/shaders/.gitkeep new file mode 100644 index 0000000..3b1da82 --- /dev/null +++ b/assets/shaders/.gitkeep @@ -0,0 +1 @@ +# assets/shaders —— 后处理 fused shader / 母题 shader / 单位描边兜底。探针 P1 通过后落盘(架构 §5.2)。 diff --git a/assets/sprites/units/.gitkeep b/assets/sprites/units/.gitkeep new file mode 100644 index 0000000..48e033e --- /dev/null +++ b/assets/sprites/units/.gitkeep @@ -0,0 +1 @@ +# assets/sprites/units —— billboard 精灵:2 朝向×2 帧 + units_atlas.png(MultiMesh)。Max 侧批量渲染产物(pipeline/ 契约);look-dev 游魂 W1-2 首片。 diff --git a/assets/sprites/vfx/.gitkeep b/assets/sprites/vfx/.gitkeep new file mode 100644 index 0000000..d20f808 --- /dev/null +++ b/assets/sprites/vfx/.gitkeep @@ -0,0 +1 @@ +# assets/sprites/vfx —— 墨点/笔触/斩弧(管线报告 §1.2)。W6 juice 最小集。 diff --git a/assets/themes/.gitkeep b/assets/themes/.gitkeep new file mode 100644 index 0000000..08ed541 --- /dev/null +++ b/assets/themes/.gitkeep @@ -0,0 +1 @@ +# assets/themes —— 水墨 Theme(界格/卷轴/印章语言;禁圆角卡片/默认组件蓝)。W4-W6(GDD §7.2④)。 diff --git a/data/build/generators.json b/data/build/generators.json new file mode 100644 index 0000000..17aba54 --- /dev/null +++ b/data/build/generators.json @@ -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" + } + ] +} diff --git a/data/schema/generators.schema.json b/data/schema/generators.schema.json new file mode 100644 index 0000000..fc35727 --- /dev/null +++ b/data/schema/generators.schema.json @@ -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 侧命名 __.png)" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/data/sheets/generators.csv b/data/sheets/generators.csv new file mode 100644 index 0000000..ea5963a --- /dev/null +++ b/data/sheets/generators.csv @@ -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 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..94a24c5 --- /dev/null +++ b/export_presets.cfg @@ -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 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..4736879 --- /dev/null +++ b/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/pipeline/README.md b/pipeline/README.md new file mode 100644 index 0000000..3a26735 --- /dev/null +++ b/pipeline/README.md @@ -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` 对齐) + +``` +_<朝向>_<帧>.png +``` + +- `` = `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 路径即)。 diff --git a/pipeline/maxscript/.gitkeep b/pipeline/maxscript/.gitkeep new file mode 100644 index 0000000..206e58e --- /dev/null +++ b/pipeline/maxscript/.gitkeep @@ -0,0 +1 @@ +# pipeline/maxscript —— batch_render.ms + asset_manifest.csv 属用户(3ds Max 侧)任务;本目录在 W1 留空。契约见 ../README.md(架构 §7.3)。 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..7a9fcd9 --- /dev/null +++ b/project.godot @@ -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 diff --git a/src/core/economy/.gitkeep b/src/core/economy/.gitkeep new file mode 100644 index 0000000..c0fcea6 --- /dev/null +++ b/src/core/economy/.gitkeep @@ -0,0 +1 @@ +# src/core/economy —— 权威经济核心:EconomyState.tick() 10Hz 确定性;GeneratorDef/UpgradeDef Resource 数据行;MultiplierSet 三乘区;PrestigeModel;OfflineModel。W3 落盘(架构 §4.2)。 diff --git a/src/core/numbers/.gitkeep b/src/core/numbers/.gitkeep new file mode 100644 index 0000000..c10e31d --- /dev/null +++ b/src/core/numbers/.gitkeep @@ -0,0 +1 @@ +# src/core/numbers —— BigNumber(double 封装 + ≤1e14 断言) / number_format(K/M/B/T + tabular)。W3 落盘(架构 §2.1/§4.2)。纪律:core 无 Node 依赖、零热路径分配。 diff --git a/src/core/save/.gitkeep b/src/core/save/.gitkeep new file mode 100644 index 0000000..9eccde6 --- /dev/null +++ b/src/core/save/.gitkeep @@ -0,0 +1 @@ +# src/core/save —— save_game.gd(SAVE_VERSION + read/write)+ migration_registry.gd(版本链迁移,每迁移函数配单测)。W5 落盘(架构 §3.5/§6)。 diff --git a/src/core/sim/.gitkeep b/src/core/sim/.gitkeep new file mode 100644 index 0000000..47b9431 --- /dev/null +++ b/src/core/sim/.gitkeep @@ -0,0 +1 @@ +# src/core/sim —— 无头平衡模拟器:sim_main.gd(extends SceneTree)+ simulator.gd + strategies/ + gates.gd(14 门)。W2 骨架 / W4 首轮(架构 §10.3)。 diff --git a/src/game/.gitkeep b/src/game/.gitkeep new file mode 100644 index 0000000..ea481e4 --- /dev/null +++ b/src/game/.gitkeep @@ -0,0 +1 @@ +# src/game —— 编排层(唯一权威状态):GameState / DataRegistry / EventDirector / UnlockDirector / BossBattle。W2-W6 落盘(架构 §2.3/§4)。 diff --git a/src/platform/.gitkeep b/src/platform/.gitkeep new file mode 100644 index 0000000..e924a2c --- /dev/null +++ b/src/platform/.gitkeep @@ -0,0 +1 @@ +# src/platform —— SteamService:GodotSteam v4.23.1 唯一封装。game/ 禁止直调 Steam.*。W5 落盘(架构 §6.4)。 diff --git a/src/presentation/.gitkeep b/src/presentation/.gitkeep new file mode 100644 index 0000000..e6263b6 --- /dev/null +++ b/src/presentation/.gitkeep @@ -0,0 +1 @@ +# src/presentation —— 表现层:只读快照 + 发命令;UI 不持有游戏状态。stage/hud/panels/modals/post 子域。W2 起落盘(架构 §5)。 diff --git a/src/presentation/hud/.gitkeep b/src/presentation/hud/.gitkeep new file mode 100644 index 0000000..446c52a --- /dev/null +++ b/src/presentation/hud/.gitkeep @@ -0,0 +1 @@ +# src/presentation/hud —— HUD 条(阴德/秒、十殿小地图、下一目标进度条)。W4-W6(架构 §7.1/GDD §7.1)。 diff --git a/src/presentation/modals/.gitkeep b/src/presentation/modals/.gitkeep new file mode 100644 index 0000000..af496a0 --- /dev/null +++ b/src/presentation/modals/.gitkeep @@ -0,0 +1 @@ +# src/presentation/modals —— 绘卷语言模态:冤魂申诉竖卷 / 殿王战过场 / 转世 / 敕封。W6(架构 §7.1)。 diff --git a/src/presentation/panels/.gitkeep b/src/presentation/panels/.gitkeep new file mode 100644 index 0000000..6099e24 --- /dev/null +++ b/src/presentation/panels/.gitkeep @@ -0,0 +1 @@ +# src/presentation/panels —— 右滑抽屉:升级树(5 页签)/ 成就殿堂 / 冤案卡册。W4-W6(架构 §7.1)。 diff --git a/src/presentation/post/.gitkeep b/src/presentation/post/.gitkeep new file mode 100644 index 0000000..6c72e5b --- /dev/null +++ b/src/presentation/post/.gitkeep @@ -0,0 +1 @@ +# src/presentation/post —— post_process_rect.gd:单趟 fused 后处理载体(posterize→LUT→纸纹→暗角;UI 层其上)。P1 过后 W3-W4 落盘(架构 §5.2)。 diff --git a/src/presentation/stage/.gitkeep b/src/presentation/stage/.gitkeep new file mode 100644 index 0000000..c316f24 --- /dev/null +++ b/src/presentation/stage/.gitkeep @@ -0,0 +1 @@ +# src/presentation/stage —— stage_root.tscn / camera_rig.gd / hall_band.gd / army_band.gd / boss_arena.gd。W3 起落盘(架构 §4.3)。 diff --git a/src/tools/import_main.gd b/src/tools/import_main.gd new file mode 100644 index 0000000..f496a0c --- /dev/null +++ b/src/tools/import_main.gd @@ -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 diff --git a/src/tools/sheet_importer.gd b/src/tools/sheet_importer.gd new file mode 100644 index 0000000..b4b5417 --- /dev/null +++ b/src/tools/sheet_importer.gd @@ -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, "")) + 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 diff --git a/tests/probe_p1_screen_texture/README.md b/tests/probe_p1_screen_texture/README.md new file mode 100644 index 0000000..878c69a --- /dev/null +++ b/tests/probe_p1_screen_texture/README.md @@ -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)。 diff --git a/tests/probe_p1_screen_texture/probe_p1.gd b/tests/probe_p1_screen_texture/probe_p1.gd new file mode 100644 index 0000000..bdd0d72 --- /dev/null +++ b/tests/probe_p1_screen_texture/probe_p1.gd @@ -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 退出 ===") diff --git a/tests/probe_p1_screen_texture/probe_p1.gdshader b/tests/probe_p1_screen_texture/probe_p1.gdshader new file mode 100644 index 0000000..d8a5173 --- /dev/null +++ b/tests/probe_p1_screen_texture/probe_p1.gdshader @@ -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> 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) diff --git a/tests/probe_p3_noise_tiling/probe_p3.tscn b/tests/probe_p3_noise_tiling/probe_p3.tscn new file mode 100644 index 0000000..2038dff --- /dev/null +++ b/tests/probe_p3_noise_tiling/probe_p3.tscn @@ -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") diff --git a/tests/sim_reports/.gitkeep b/tests/sim_reports/.gitkeep new file mode 100644 index 0000000..0c526fe --- /dev/null +++ b/tests/sim_reports/.gitkeep @@ -0,0 +1 @@ +# tests/sim_reports —— 模拟器 14 门报告 report-.json(git 跟踪,月度对账证据)。W4 首轮(架构 §9.3/§10.3)。 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..68a04b0 --- /dev/null +++ b/tests/unit/.gitkeep @@ -0,0 +1 @@ +# tests/unit —— GUT 单测(core 全覆盖:公式/迁移/格式化)。W3 激活;GUT addon 待合入(CI W3 占位)。