docs(09-19): revise texture layout per D-84 (columns fixed, rows <=4 cropped)

This commit is contained in:
unanmed 2026-10-14 10:49:51 +08:00
parent 8f9fdd45b8
commit e3afd75898

View File

@ -28,10 +28,10 @@ estimate:
confidence: low
must_haves:
truths:
- "**本计划的第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md 硬要求,D-03 / D-76)**:Task 0 必须先汇报「本计划 = 任务 9 · 旧引擎数据基础移植」的**要做什么 / 为什么 / 大致怎么做 / 影响范围**,**必须列明:六类基础字段范围与三类判定(映射候选可确定 / §8.2 待定不移植 / §8.1 无目标字段进 unmapped 文档)、`num` 策略 A 的具体分配方案、PNG 生成可行性结论(零依赖 Node `zlib` 方案)、**48px 素材合并(U-9 / U-17 / OI-3)的技术核实结论与默认处置(默认保持 D-81 的 `npc0..5`=6 图 = 单元格统一到 64×48;备选 A 按尺寸分组=7 图、C 只出 32px)**、地图图层键口径、产出文件清单、消费接线不在本增量、静态门禁方式与 `open_items`(OI-1..OI-7)**,然后必须**停下等待**用户回复;**若回复未明确给出 OI-3 取值,视为未通过(本计划不因触发方式或 `mode: yolo` / `workflow.auto_advance` 自动放行)**。"
- "**基础字段范围(D-76):** 只移植盘点台账 §1.2 / §3.1 / §4.2 / §6.2 中「**映射候选(可确定)**」的字段:tile 的 `id` / `cls→type`(§1.1)/ `cannotOut`+`cannotIn`→`pass.outPass`+`pass.inPass`;enemy 的 `num` / `attribute`(`name`/`hp`/`atk`/`def`/`money`/`exp`/`point`)/ `special`;item 的 `num` / `category`(§4.1)/ `name` / `text` / `hideInToolbox`;map 的 `floorId` / `width` / `map` / `layerAlias`。**§8.2「待定」不当移植**(tile `trigger` / `canPass`;enemy `value`/`range`/`zoneSquare`/`add`/`n`/`bigImage`/`notBomb`/`atkValue`/`defValue`;item `itemEffect`/`itemEffectTip`/`useItemEffect`/`useItemEvent`/`canUseItemEffect`/`equip` 子字段;map `events` / `bgmap`/`fgmap`/`bg2map`/`fg2map`)。**§8.1「无目标字段」不当移植且只记入 `09-DATA-MIGRATION-UNMAPPED.md`**(tile `canBreak`/`script`/`doorInfo`/`animate`/`name`/`faceIds`/`autotileConnection`;enemy `faceIds`;item `hideInReplay`;map `title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`)。"
- "**本计划的第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md 硬要求,D-03 / D-76)**:Task 0 必须先汇报「本计划 = 任务 9 · 旧引擎数据基础移植」的**要做什么 / 为什么 / 大致怎么做 / 影响范围**,**必须列明:六类基础字段范围与三类判定(映射候选可确定 / §8.2 待定不移植 / §8.1 无目标字段进 unmapped 文档)、`num` 策略 A 的具体分配方案、PNG 生成可行性结论(零依赖 Node `zlib` 方案)、贴图布局规则(D-84:列数固定(单帧 4 列 / 多帧 = 帧数列)、行数 ≤4 且按每图实际块数裁剪、不硬编码 4 行尺寸)、48px 素材合并(U-9 / U-17 / OI-3)的技术核实结论与默认处置(默认保持 D-81 的 `npc0..5`=6 图 / 总数 42 = 单元格统一到 64×48;备选 A 按尺寸分组=`npc0..6`=7 图 / 总数 43、C 只出 32px=`npc0..3`=4 图 / 总数 40)、地图图层键口径、产出文件清单、消费接线不在本增量、静态门禁方式与 `open_items`(OI-1..OI-7)**,然后必须**停下等待**用户回复;**若回复未明确给出 OI-3 取值,视为未通过(本计划不因触发方式或 `mode: yolo` / `workflow.auto_advance` 自动放行)**。"
- "**基础字段范围(D-76):** 只移植盘点台账 §1.2 / §3.1 / §4.2 / §6.2 中「**映射候选(可确定)**」的字段:tile 的 `id` / `cls→type`(§1.1)/ `cannotOut`+`cannotIn`→`pass.outPass`+`pass.inPass`;enemy 的 `num` / `attribute`(`name`/`hp`/`atk`/`def`/`money`/`exp`/`point`)/ `special`;item 的 `num` / `category`(§4.1)/ `name` / `text` / `hideInToolbox`(**旧 `items.js` 中缺 `text` 的 13 条 `wand`/`pack`/`I331..I341` 显式写入空字符串**);map 的 `floorId` / `width` / `map` / `layerAlias`。**§8.2「待定」不当移植**(tile `trigger` / `canPass`;enemy `value`/`range`/`zoneSquare`/`add`/`n`/`bigImage`/`notBomb`/`atkValue`/`defValue`;item `itemEffect`/`itemEffectTip`/`useItemEffect`/`useItemEvent`/`canUseItemEffect`/`equip` 子字段;map `events` / `bgmap`/`fgmap`/`bg2map`/`fg2map`)。**§8.1「无目标字段」不当移植且只记入 `09-DATA-MIGRATION-UNMAPPED.md`**(tile `canBreak`/`script`/`doorInfo`/`animate`/`name`/`faceIds`/`autotileConnection`;enemy `faceIds`;item `hideInReplay`;map `title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`)。"
- "**`num` 策略 A(D-79)与跨文件一致性:** 图块 `num` 全部重新分配,按 `TileType` 分段(段序 `Terrain` / `Animate` / `Item` / `Enemy` / `Npc` / `Autotile`,段内按旧 `num` 升序),`num` 从 1 起连续(0 保留空图块),唯一约束 `id` ↔ `num` 一致;同一 `id` 的新 `num` **必须在 `tile.jsonc` / `enemy.jsonc` / `item.jsonc` / `texture/.list.jsonc` 的 `map` / `maps/*.jsonc` 图层数值五处一致**(enemy/item 的 `num` = 对应图块 `num`;地图与贴图 `map` 中旧 `num` 经「旧 `num`→`id`→新 `num`」重映射)。"
- "**贴图素材(D-72 / D-81,默认保持 `SPLIT-COUNT: 42`):** `src/content/texture/` 生成 42 张 `materials` 拆分图(A `terrains0..2` 单帧 4×4 单元格 32×32 → 每图 128×128;B `terrainsAnim0..7` 4 帧列×4 行、帧 32×32 → 每图 128×128;C `items0..3` 单帧 4×4 → 每图 128×128;D `enemy0..20` 2 帧列×4 行,32px 块 64×128 / 48px 块 128×192;E `npc0..5` 2 帧列×4 行,**默认按单元格统一**:24 块全部置于 64×48 单元格 → 每图 128×192)+ 逐字节复制 `autotiles/*.png`(4)与 `tilesets/magictower.png`(1);`animates` 并入 `terrains`、不设独立 `animates` 素材组(D-72③);`texture/.list.jsonc` 用 D-80 已批准的类型/字段(`ITextureListConfig` 及 `grid`/`rowAnimate`/`autotile`/`tileset` 条目;字段 `type`/`source`/`map`/`width`/`height`/`identifier`/`autotileType`/`cellWidth`/`cellHeight`/`textures`),且每个 `source` 文件存在、`map`/`identifier` 覆盖对应图块 `num`。**OI-3 若被用户明确另裁为 A(按尺寸分组、`npc0..6`=7 图、总数 43),以裁定为准并在 SUMMARY 记录;默认 B 下 `npc` 图数仍为 D-81 的 6。**"
- "**贴图素材(D-72 / D-81 / D-84,默认保持 `SPLIT-COUNT: 42`):** `src/content/texture/` 生成 42 张 `materials` 拆分图。**布局规则(D-84 修订 D-72⑤):列数固定**(单帧 4 列 / 多帧 = 帧数列),**行数 ≤4 且按每图实际所需裁剪**(素材不足时裁到 1–3 行);每张图 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`),**不得硬编码 4 行尺寸(如 64×128 / 128×192)**。按真实块数逐图推导:A `terrains0..2`(单帧 4 列、单元格 32×32;来源 35 块 → 16+16+3)→ `terrains0`/`terrains1` 128×128(4 行)、`terrains2` **128×32**(1 行);B `terrainsAnim0..7`(4 帧列、帧 32×32;来源 29 块 → 7×4+1)→ `terrainsAnim0..6` 128×128(4 行)、`terrainsAnim7` **128×32**(1 行);C `items0..3`(单帧 4 列、32×32;来源 62 块 → 16+16+16+14)→ 四图均 128×128(末图 14 格占满 4 行);D `enemy0..20`(2 帧列):32px 块 73 个 → `enemy0..17` 64×128(4 行)+ `enemy18` **64×32**(1 行),48px 块 8 个 → `enemy19`/`enemy20` 128×192(4 行);E `npc`(2 帧列;默认 OI-3=B 单元格统一 64×48、来源 24 块 → 6×4)→ `npc0..5` 128×192(4 行)。**OI-3 备选(同样按 D-84 逐图裁剪):A 按尺寸分组 `npc0..6`(32px 组 14 块 → `npc0..2` 64×128 + `npc3` **64×64**;48px 组 10 块 → `npc4`/`npc5` 128×192 + `npc6` **128×96**;总数 43);C 只出 32px `npc0..3`(14 块 → `npc0..2` 64×128 + `npc3` **64×64**;总数 40,48px 变体延后)。** + 逐字节复制 `autotiles/*.png`(4)与 `tilesets/magictower.png`(1);`animates` 并入 `terrains`、不设独立 `animates` 素材组(D-72③);`texture/.list.jsonc` 用 D-80 已批准的类型/字段(`ITextureListConfig` 及 `grid`/`rowAnimate`/`autotile`/`tileset` 条目;字段 `type`/`source`/`map`/`width`/`height`/`identifier`/`autotileType`/`cellWidth`/`cellHeight`/`textures`),且每个 `source` 文件存在、`map`/`identifier` 覆盖对应图块 `num`。**OI-3 以用户显式裁定为准并在 SUMMARY 记录(默认 B:`npc0..5`=6 图、总数 42)。**"
- "**地图范围(D-82):** 全部 5 楼层 `empty` / `MT0` / `sample0` / `sample1` / `sample2` 写入 `src/content/maps/{id}.jsonc`,`maps/.list.jsonc` 列出 5 个 id;每个楼层 `map` 的**每个图层数组长度 = `width` × `height`**(`mapState.fromRaw` 约束),图层内数值均为 `0` 或有效新图块 `num`。"
- "**unmapped 说明文档(D-78):** `.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md` **仅**收录 §8.1「无目标字段」项(路径/命名待 Task 0 批准);§8.2 待定项**不写入**该文档(保留在盘点台账 `09-DATA-MIGRATION-AUDIT.md`)。"
- "**消费接线不在本增量(D-70 / D-83):** 不把配置写入 store、不把 list 写入 `TextureManager`、不改 `packages*/` 与 `src/`(除 `src/content/**`)、不改 `public/project/**`;沿用 D-72③ 与「旧 `events`/`trigger` 等无对应目标字段者不移植」。"
@ -39,9 +39,9 @@ must_haves:
- path: src/content/tile.jsonc
provides: "235 条图块原始数据(`num` 策略 A、`id`、`type`(`TileType`)、`pass`(`cannotOut`/`cannotIn` 可确定映射)、`events`/`eventPass` 默认),替换现有样例"
- path: src/content/enemy.jsonc
provides: "81 条怪物原始数据(`num` 对应图块、`attribute` 七字段、`special` 数字键),替换现有样例"
provides: "81 条怪物原始数据(`num` 对应图块、`attribute` 七字段、`special` 数字键),替换现有样例;每条键集精确 = {`num`,`attribute`,`special`}"
- path: src/content/item.jsonc
provides: "62 条道具原始数据(`num` 对应图块、`category`、`name`、`text`、`hideInToolbox`),替换现有样例"
provides: "62 条道具原始数据(`num` 对应图块、`category`、`name`、`text`(旧缺 `text` 的 13 条写空字符串)、`hideInToolbox`),替换现有样例;每条键集精确 = {`num`,`category`,`name`,`text`,`hideInToolbox`}"
- path: src/content/maps/.list.jsonc
provides: "5 个楼层 id 列表(`empty`/`MT0`/`sample0`/`sample1`/`sample2`)"
- path: src/content/maps/empty.jsonc
@ -57,7 +57,7 @@ must_haves:
- path: src/content/texture/.list.jsonc
provides: "D-80 已批准的 `ITextureListConfig`,覆盖拆分图 + 4 autotile + 1 tileset"
- path: src/content/texture/*.png
provides: "42 张 D-72 拆分图(`terrains*`/`terrainsAnim*`/`items*`/`enemy0..20`/`npc0..5`;OI-3=A 时为 43 张、`npc0..6`)+ 复制的 `autotile*`(4)+ `magictower.png`(1)"
provides: "42 张 D-72/D-84 拆分图(`terrains*`/`terrainsAnim*`/`items*`/`enemy0..20`/`npc0..5`;列数固定、行数 ≤4 且按每图实际裁剪——末图可为 128×32 / 64×32 等;OI-3=A 时 43 张 `npc0..6`;OI-3=C 时 40 张 `npc0..3`)+ 复制的 `autotile*`(4)+ `magictower.png`(1)"
- path: script/migrateProjectTextures.ts
provides: "一次性零依赖 PNG 生成脚本(Node `zlib` 解码/编码;路径/命名待批准,OI-2)"
- path: .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md
@ -67,24 +67,26 @@ must_haves:
key_links:
- "`src/content/tile.jsonc` 的 `num`(策略 A)↔ `src/content/texture/.list.jsonc` 的 `map`/`identifier` ↔ `src/content/enemy.jsonc`/`src/content/item.jsonc` 的 `num` ↔ `src/content/maps/*.jsonc` 图层数值 —— 五处 `id`↔`num` 全局一致(D-79 唯一硬约束)"
- "`src/content/texture/.list.jsonc` 条目 ↔ `packages-user/client-base/src/material/types.ts`(`addGrid`:261 / `addRowAnimate`:275 / `addAutotile`:289 / `addTileset`:302 / `AutotileType`:12 / `IIndexedIdentifier`:50)—— D-80 已批准 schema 的能力依据"
- "`src/content/texture/*.png` 的单元格尺寸约束 ↔ `packages-user/client-base/src/material/manager.ts`:158-175(`addRowAnimate`:单张图内所有行共用同一 `width`/`height`,且 `source.width % width === 0`)+ `packages/render/src/assets/splitter.ts`:8-18(`TextureRowSplitter.split(tex, height)` 单一高度切行)—— OI-3 单元格统一的技术依据"
- "`src/content/texture/*.png` 的单元格尺寸约束 ↔ `packages-user/client-base/src/material/manager.ts`:158-175(`addRowAnimate`:单张图内所有行共用同一 `width`/`height`,且 `source.width % width === 0`)+ `packages/render/src/assets/splitter.ts`:8-18(`TextureRowSplitter.split(tex, height)`:`lines = ceil(height / frameH)`,故裁剪后行数须为 `height / frameH` 的整数)—— OI-3 单元格统一与 D-84 逐图裁剪的技术依据(列数固定、行数 ≤4)"
- "`src/content/{tile,enemy,item}.jsonc`/`maps/*.jsonc` 字段 ↔ `packages-user/data-common/src/store/types.ts`(`ITileRawData`:47 / `ITilePassData`:38 / `IEnemyRawData`:319 / `IItemRawData`:176 / `IMapRawData`:260)—— 基础字段判定的唯一依据"
- "`src/content/maps/*.jsonc` 图层数组长度 ↔ `packages-user/data-base/src/map/mapState.ts`:40-95(`fromRaw`:各层等长、`length % width === 0`)—— 地图结构的硬约束"
- "`src/content/texture/*.png` ↔ `public/project/materials|autotiles|tilesets/*.png`(D-72 拆分/复制来源)"
- "`src/content/*.jsonc` 解析 ↔ `packages-user/data-state/src/loader/jsoncProcessor.ts`(`// --- SYSTEM PREFIX END --- //` 之后必须是严格 JSON,不得含注释)"
assumptions:
- "**PNG 零依赖方案可行(执行日已核实):** 仓库无 `pngjs`/`sharp`/`canvas`/`jimp` 等 PNG 库(`package.json` 与 `node_modules` 均已核实);Node 内置 `zlib`(`inflateSync`/`deflateSync`)可用;源 PNG 全部为 **8 位、非隔行** —— `materials` 为颜色类型 6(RGBA),`autotiles` 为颜色类型 **3(调色板)或 6(RGBA)**(实测 `autotile.png`/`autotile1.png`/`autotile3.png` 为 type 3,`autotile2.png` 为 type 6),`tilesets/magictower.png` 为 type 6。故「解析 IHDR/IDAT + `zlib` 解压 + 反滤镜」的纯 Node 解码可行;autotile/tileset 采用**逐字节复制、不解码**。本计划**不新增任何依赖**。"
- "**48px 素材合并(U-9 / U-17 / OI-3)技术核实结论:** 实测 `npcs.png`(64×448)单元格 32×32(2 帧列 × 14 行)、`npc48.png`(128×480)单元格 64×48(2 帧列 × 10 行);`enemys.png`(64×2336)32×32、`enemy48.png`(128×384)64×48。`addRowAnimate`(`manager.ts`:158-175)要求**单张图内所有块共用同一 `width`/`height`**(`TextureRowSplitter` 按单一 `height` 切行),故 32×32 与 64×48 帧**无法共存于同一张 `rowAnimate` 图**。**按尺寸分组**会使 `npc` 由 D-81 的 6 张(`npc0..5`,`SPLIT-COUNT: 42`)变为 7 张(`npc0..6`,总数 43)。**默认处置 = 保持 D-81(6 张 / 42)**:将 `npc` 全部帧单元统一为 64×48(14 个 32×32 帧以**透明留白**嵌入 64×48 单元,不做重采样),24 块 ÷ 4 块/图 = 6 张 `npc0..5.png`(均 128×192)。该统一会改变小 NPC 的帧单元尺寸(其渲染端呈现影响不在本增量验证范围,D-83 接线延后);若用户不接受,须在 Task 0 明确改选 A(按尺寸分组、显式接受偏离 D-81)或 C(本增量只出 32px)。`enemy` 无论按尺寸分组(19+2)或整组(81÷4 向上取整)均为 21 张,与 D-81 的 `enemy0..20` 一致。"
- "**贴图布局(D-84,修订 D-72⑤):** 不再假定每张图满 4 行。拆分图**列数固定**(单帧 4 列 / 多帧 = 帧数列),**行数 ≤4 且按每图实际块数裁剪**(单帧每图上限 16 格=4 行、多帧每图上限 4 块=4 行;末图不足则裁到 1–3 行)。故末图尺寸随实际行数变化(如 `terrains2`/`terrainsAnim7` = 128×32、`enemy18` = 64×32;OI-3=A/C 下 `npc3` = 64×64、A 下 `npc6` = 128×96)。拆分图**总数不变**(`SPLIT-COUNT: 42` 默认 B):A=3、B=8、C=4、D=21、E=6;本盘点的 §2「严格 4 行」描述**由 D-84 取代**(仅本迁移受影响,盘点文档不改)。"
- "**48px 素材合并(U-9 / U-17 / OI-3)技术核实结论:** 实测 `npcs.png`(64×448)单元格 32×32(2 帧列 × 14 行 = 14 个 NPC 块)、`npc48.png`(128×480)单元格 64×48(2 帧列 × 10 行 = 10 个 NPC 块);`enemys.png`(64×2336)32×32、`enemy48.png`(128×384)64×48。`addRowAnimate`(`manager.ts`:158-175)要求**单张图内所有块共用同一 `width`/`height`**(`TextureRowSplitter` 按单一 `height` 切行),故 32×32 与 64×48 帧**无法共存于同一张 `rowAnimate` 图**。**按尺寸分组**会使 `npc` 由 D-81 的 6 张(`npc0..5`,`SPLIT-COUNT: 42`)变为 7 张(`npc0..6`,总数 43)。**默认处置 = 保持 D-81(6 张 / 42)**:将 `npc` 全部帧单元统一为 64×48(14 个 NPC 块(各 2 帧 32×32)以**透明留白**嵌入 64×48 单元,不做重采样),24 块 ÷ 4 块/图 = 6 张 `npc0..5.png`(24÷4=6 满图,均 4 行 → 均 128×192;此处置下 `npc` 每张恰为 4 行,故 128×192 成立)。该统一会改变小 NPC 的帧单元尺寸(其渲染端呈现影响不在本增量验证范围,D-83 接线延后);若用户不接受,须在 Task 0 明确改选 A(按尺寸分组、显式接受偏离 D-81)或 C(本增量只出 32px、`npc0..3`=4 图 / 总数 40)。`enemy` 无论按尺寸分组(19+2)或整组(81÷4 向上取整)均为 21 张,与 D-81 的 `enemy0..20` 一致。"
- "**地图图层键语义待确认:** `mapState.fromRaw` 显示 `IMapRawData.map` 每个键是**整张展平网格**(`length = width × height`,行主序),并非盘点 §6.2 候选所称「层键即行索引」;故本计划按「旧二维 `map` → 单张展平图层」处理,图层键取值(默认 `0`)与 `layerAlias` 记入 `open_items`。"
- "**现有样例的省略策略有效:** `src/content/{tile,enemy,item}.jsonc` 现有样例已省略部分必填字段(如 item 省略 `effect`/`equip`),说明加载/默认合并允许省略;本计划对 §8.2 待定字段沿用同样省略策略,不臆造取值。"
- "**现有样例的省略策略有效(仅适用于 §8.2 的 `effect`/`equip`):** `src/content/{tile,enemy,item}.jsonc` 现有样例已省略部分非必填字段(如 item 省略 `effect`/`equip`),说明加载/默认合并允许省略;本计划对 §8.2 待定字段沿用同样省略策略,不臆造取值。**但 `text` 例外:** 旧 `items.js` 有 13 条(`wand`/`pack`/`I331..I341`,均 `cls: items`)**无 `text`**;本计划对其**显式写入空字符串**(与 `defaults/item.jsonc` 中 `text` 的默认空串一致),**不省略** —— 以保证 Task 1 门禁的 `typeof it.text === 'string'` 成立、且数据文件自洽,而非依赖默认合并兜底。省略策略与 `text` 处置均记入 SUMMARY。"
- "**行号可能漂移:** 本计划引用行号为规划日实测;门禁以「文件存在 + 结构断言」为准,不硬编码行号。"
prohibitions:
- "不得在用户回复通过 Task 0 关卡之前执行本计划的任何任务、或派发任何执行子代理。"
- "**写入边界:** 只允许写本 `files_modified` 所列路径(`src/content/**` 数据与 PNG、`script/migrateProjectTextures.ts`、`09-DATA-MIGRATION-UNMAPPED.md`、`09-19-SUMMARY.md`);**不得**改 `packages*/**`、`src/**`(除 `src/content/**`)、`public/project/**`。"
- "**`.planning/**` 边界:** 不得改 `ROADMAP.md` / `STATE.md` / `09-CONTEXT.md` / `09-DATA-MIGRATION-AUDIT.md` / 任何既有 PLAN 或 SUMMARY;只允许新增 `09-19-PLAN.md`(规划期已产出)、`09-DATA-MIGRATION-UNMAPPED.md`(待批准)与 `09-19-SUMMARY.md`。"
- "**不移植边界:** §8.1 无目标字段**不得**出现在 `tile.jsonc`/`enemy.jsonc`/`item.jsonc`/`maps/*.jsonc` 中(只进 unmapped 文档);§8.2 待定字段**不得**移植、也**不得**进 unmapped 文档。"
- "**不移植边界:** §8.1 无目标字段**不得**出现在 `tile.jsonc`/`enemy.jsonc`/`item.jsonc`/`maps/*.jsonc` 中(只进 unmapped 文档);§8.2 待定字段**不得**移植、也**不得**进 unmapped 文档。各实体/地图条目以**精确键集断言**兜底(tile 恰 6 键 / enemy 恰 3 键 / item 恰 5 键 / map 顶层恰 5 键)。"
- "**贴图尺寸不硬编码(D-84):** 贴图门禁必须按**每图实际行数(≤4)与裁剪后尺寸**断言(`width = 列数 × 帧宽`、`height = 行数 × 帧高`,`1 ≤ 行数 ≤ 4`);**不得**假定每张图均为 4 行,**不得**硬编码 4 行尺寸(如 64×128 / 128×192)作为全体断言——仅当某图实际行数确为 4 时其 4 行尺寸才成立(如默认 OI-3=B 下 `npc0..5` 恰 4 行,128×192 成立;但 `terrains2`/`terrainsAnim7`/`enemy18` 必须按裁剪后的 1 行尺寸断言)。"
- "**命名边界(AGENTS.md / D-73 / D-04):** 任何未批准的新命名(一次性脚本路径、unmapped 文档路径/命名、`source` 路径形态、地图图层键口径、48px 处理方式)**先入 `open_items`,由 Task 0 报备待批准**;D-80 已批准的类型/字段名可直接使用。"
- "**不臆造:** 不得编造字段取值 / 映射关系 / PNG 尺寸;无法从盘点台账与类型定义确定者入 `open_items`,不得落入数据文件。"
- "**不臆造:** 不得编造字段取值 / 映射关系 / PNG 行数或尺寸;无法从盘点台账与类型定义确定者入 `open_items`,不得落入数据文件。"
- "**不新增依赖:** 不得改 `package.json`、不得安装 `pngjs`/`sharp`/`canvas`/`jimp` 等;PNG 生成必须用 Node 内置 `zlib` 零依赖方案。"
- "**不改既有注释 / jsDoc:** 不改 `src/content/**` 既有文件头 `// Type:` 注释行;所有生成文件头部 `// Type:` 前缀不得改动。"
- "**jsonc 严格性:** `// --- SYSTEM PREFIX END --- //` 之后必须是严格 JSON(无注释、无尾逗号),否则加载器 `JSON.parse` 失败。"
@ -94,22 +96,21 @@ must_haves:
open_items:
- "**OI-1(unmapped 文档路径/命名):** 提议 `.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md`(D-78 提议)。**待用户报备批准**;未批准前不得写入。"
- "**OI-2(一次性脚本路径/命名):** `dev.md` 命名规则要求**代码文件名用「小驼峰」**;故提议 `script/migrateProjectTextures.ts`(小驼峰,符合规范)。备注:迭代 1 曾提议 `script/migrate-project-textures.ts`(连字符),**违反** dev.md「代码文件名 = 小驼峰」规则,本迭代已纠正为小驼峰。**待用户报备批准**;如使用其它命名须先获批。"
- "**OI-3(48px 素材合并处理 = U-9 / U-17):** 实测 32×32 与 64×48 帧无法同图(`addRowAnimate` 单图单尺寸)。**默认 B(推荐)= 单元格统一到 64×48**:`npc0..5`(6 图,每图 128×192),保持 D-81 的 `SPLIT-COUNT: 42`(14 个 32×32 帧透明留白嵌入,不重采样;小 NPC 帧单元尺寸随之改变)。备选 **A = 按尺寸分组**:`npc0..6`(7 图,32px 组 4×64×128 + 48px 组 3×128×192,总数 43,**显式接受偏离 D-81**);备选 **C = 本增量只出 32px**(`npc0..3`,4 图,48px 变体延后)。**Task 0 须明确裁定 A/B/C(默认 B)。**"
- "**OI-3(48px 素材合并处理 = U-9 / U-17):** 实测 32×32 与 64×48 帧无法同图(`addRowAnimate` 单图单尺寸)。**默认 B(推荐)= 单元格统一到 64×48**:`npc0..5`(6 图,24 块 → 6×4 满图,每图 4 行 → 均 128×192),保持 D-81 的 `SPLIT-COUNT: 42`(14 个 NPC 块(各 2 帧 32×32)透明留白嵌入,不重采样;小 NPC 帧单元尺寸随之改变)。备选 **A = 按尺寸分组**:`npc0..6`(7 图;32px 组 14 块 4 图:`npc0..2` 4 行 64×128 + `npc3` 2 行 **64×64**;48px 组 10 块 3 图:`npc4`/`npc5` 4 行 128×192 + `npc6` 2 行 **128×96**;总数 43,**显式接受偏离 D-81**);备选 **C = 本增量只出 32px**(`npc0..3`,4 图:`npc0..2` 4 行 64×128 + `npc3` 2 行 **64×64**,总数 40,`npc48` 的 10 个 64×48 块延后)。**Task 0 须明确裁定 A/B/C(默认 B);各图行数与尺寸一律按 D-84 逐图裁剪。**"
- "**OI-4(地图图层键 / `layerAlias` 口径):** 盘点 §6.2「层键即行索引」与 `mapState.fromRaw`(整张展平网格)不符;默认「旧二维 `map` → 单张展平图层(键 `0`),`layerAlias` = `{}`」。**待用户确认**(额外图层 `bgmap`/`fgmap` 等属 §8.2 U-16b 本增量不移植)。"
- "**OI-5(`texture/.list.jsonc` 的 `source` 路径形态):** 默认用相对 `texture/` 的文件名(如 `terrains0.png`),与 `client.jsonc` 的 `textureDir: texture` 对齐。**待用户确认**是否需含目录前缀。"
- "**OI-6(autotile / tileset 参数):** `autotile1.png`(384×128)的 `AutotileType` 无法由尺寸唯一判定(U-7),`magictower.png` 的 `cellWidth`/`cellHeight` 与 `identifier.index` 待确认(U-8);默认 `autotile.png`/`autotile2.png`/`autotile3.png` 取 `Big3x4`、`autotile1.png` 暂取 `Big3x4`、`identifier` 取图块 `num`(20/151/152/153),tileset 取 `32/32`、`index: 0`、`alias: magictower`。**待用户确认。**"
- "**OI-7(写操作方式声明):** 本计划执行含**文件写入**(数据文件、PNG、脚本、文档);执行须按 AGENTS.md「修改方式须预先声明」(内联 / 派发子代理)。"
---
<objective>
Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**:把 `public/project` 的**基础字段与数据**移植到 `src/content`(jsonc 数据 + PNG 素材),替换现有样例。
**要解决的问题:** Phase 9 要在已完成的数据端之上产出最小浏览器可运行版本;当前 `src/content` 只有手写样例(`tile.jsonc` 1 条、`enemy.jsonc` 1 条、`item.jsonc` 1 条、`maps/MT0.jsonc` 1 张、`texture/.list.jsonc` 为空 `{}`),无法驱动真实的 235 图块 / 81 怪物 / 62 道具 / 5 楼层。任务 8(`09-17`/`09-18`)已产出只读盘点台账 `09-DATA-MIGRATION-AUDIT.md`,逐字段界定了「映射候选(可确定)」「§8.2 待定」「§8.1 无目标字段」三类。本计划据该台账,把**可确定的基础字段**落地为 `src/content` 数据与素材;**无对应目标字段者**(§8.1)只形成说明文档(D-78);**§8.2 待定项不移植**(D-76)。
**大致解决方案(本计划 = 09-19):**
- **Task 0(`checkpoint:decision@blocking-human`)= 预执行汇报关卡**:汇报要做什么 / 为什么 / 大致怎么做 / 影响范围(含 PNG 零依赖可行性结论、`num` 策略 A 方案、48px 合并(OI-3)技术核实与默认处置、地图图层键等待裁定项),停下等待用户回复**并明确 OI-3 取值**。
- **Task 0(`checkpoint:decision@blocking-human`)= 预执行汇报关卡**:汇报要做什么 / 为什么 / 大致怎么做 / 影响范围(含 PNG 零依赖可行性结论、贴图布局规则(D-84:列数固定、行数 ≤4 且按需裁剪)、`num` 策略 A 方案、48px 合并(OI-3)技术核实与默认处置、地图图层键等待裁定项),停下等待用户回复**并明确 OI-3 取值**。
- **Task 1 = 三类实体 jsonc**:按 `num` 策略 A 一次性决定全局 `id`↔`num`;写 `tile.jsonc`(235 条)、`enemy.jsonc`(81 条)、`item.jsonc`(62 条)——**建立跨文件唯一的 `num` 基准**。
- **Task 2 = 贴图素材 + `.list.jsonc`**:写一次性零依赖 PNG 脚本(Node `zlib`),按 D-72 拆分 42 张图(默认 OI-3=B 保持 D-81)并复制 autotile/tileset,据 Task 1 的新 `num` 写 `texture/.list.jsonc`。
- **Task 2 = 贴图素材 + `.list.jsonc`**:写一次性零依赖 PNG 脚本(Node `zlib`),按 D-72 **与 D-84** 拆分 42 张图(列数固定、行数 ≤4 且按每图实际块数裁剪;默认 OI-3=B 保持 D-81)并复制 autotile/tileset,据 Task 1 的新 `num` 写 `texture/.list.jsonc`。
- **Task 3 = 地图 + 说明文档 + 收口**:写 5 楼层 jsonc 与 `maps/.list.jsonc`(旧 `num` 重映射为新 `num`)、写 `09-DATA-MIGRATION-UNMAPPED.md`(仅 §8.1)、跑最终静态门禁并写 `09-19-SUMMARY.md`。
**为何分三个执行任务:** 数据依赖为「先定 `num`(Task 1)→ 贴图 list 复用该 `num`(Task 2)→ 地图把旧 `num` 重映射为新 `num`(Task 3)」;三类互不冲突、按依赖顺序最稳,且避免同一文件反复改写。
@ -120,7 +121,9 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
**PNG 生成可行性(本规划已核实):** 仓库**无** PNG 库(`package.json` 与 `node_modules` 均核实缺失 `pngjs`/`sharp`/`canvas`/`jimp`);Node 内置 `zlib`(`inflateSync`/`deflateSync`)可用;源 PNG 均为 **8 位非隔行**(materials 为 RGBA type 6、autotiles 为调色板 type 3 **或** RGBA type 6(`autotile2.png`)、tileset 为 type 6)。故采用**零依赖**手写 PNG 编解码(解析 IHDR/IDAT + `zlib` 解压 + 反滤镜;编码则滤镜 + `zlib` 压缩 + CRC32),**不新增任何依赖**。
**48px 合并可行性(本规划已核实):** `addRowAnimate` 要求单图内所有块共用同一帧尺寸,32×32 与 64×48 不能同图;默认按 OI-3=B 统一到 64×48 以保持 D-81 的 6 图。
**贴图布局(D-84,本规划已核实):** 拆分图**列数固定**(单帧 4 列 / 多帧 = 帧数列),**行数 ≤4 且按每图实际块数裁剪**(素材不足以填满时裁到 1–3 行);每张图 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`),门禁按**实际行数**断言,**不硬编码 4 行尺寸**。据来源块数(`terrains` 35 / `animates` 29 / `items` 62 / `enemys` 73 / `enemy48` 8 / `npcs` 14 / `npc48` 10)逐图推导,末图会裁剪(如 `terrains2`/`terrainsAnim7` = 128×32、`enemy18` = 64×32);拆分图**总数不变**(`SPLIT-COUNT` 默认仍为 42)。§2 台账的「严格 4 行」描述**由 D-84 取代**(仅本迁移受影响,盘点文档不改)。
**48px 合并可行性(本规划已核实):** `addRowAnimate` 要求单图内所有块共用同一帧尺寸,32×32 与 64×48 不能同图;默认按 OI-3=B 统一到 64×48 以保持 D-81 的 6 图(24 块 → 6×4 满图,均 4 行 → 均 128×192;备选 A=7 图 / 总数 43;备选 C=4 图 / 总数 40;A/C 下末图按 D-84 裁剪)。
**API coverage:** `detected:false`。**`No external API integration: 本计划为本地数据/素材移植,不涉及任何外部 API。`**
</objective>
@ -148,7 +151,7 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
<precondition>`09-DATA-MIGRATION-AUDIT.md`(09-17/09-18 产出)已存在且含 §1.5.1 `id` 全清单与 §8.1 / §8.2 分区;`packages-user/data-common/src/store/types.ts`、`packages-user/client-base/src/material/types.ts`、`packages-user/client-base/src/material/manager.ts`、`packages/render/src/assets/splitter.ts`、`packages-user/data-state/src/loader/jsoncProcessor.ts`、`packages-user/data-base/src/map/mapState.ts` 均存在;`public/project/{maps,enemys,items}.js` 与 `public/project/floors/*.js`、`public/project/{materials,autotiles,tilesets}/*.png` 均存在;`src/content/texture/.list.jsonc` 存在(现状 `{}`);`09-DATA-MIGRATION-UNMAPPED.md` 与 `09-19-SUMMARY.md`**尚不存在**;Node 内置 `zlib.inflateSync`/`deflateSync` 可用。</precondition>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 / D-03 / D-04 / D-76 / D-77 / D-78 / D-79 / D-80 / D-81 / D-82 / D-83)
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 / D-03 / D-04 / D-76 / D-77 / D-78 / D-79 / D-80 / D-81 / D-82 / D-83 / D-84)
- .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md(§1.2 / §1.5.1 / §2 / §3.1 / §4.2 / §5.1 / §6.2 / §7.1 / §8.1 / §8.2 / §9)
- .planning/phases/09-minimum-browser-runnable/09-19-PLAN.md(本计划全文,含映射口径、OI-1..OI-7)
- .planning/phases/09-minimum-browser-runnable/09-18-SUMMARY.md(上游摘要,只读参考)
@ -161,10 +164,10 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
① **要解决的问题:** `src/content` 目前只有样例(tile 1 条 / enemy 1 条 / item 1 条 / maps 仅 MT0 / texture list 空),无法驱动真实数据。本计划据只读台账 `09-DATA-MIGRATION-AUDIT.md`,把 `public/project` 的**基础字段**移植为 `src/content` 的 jsonc 数据与 PNG 素材,替换样例。
② **范围(六类,D-71 / D-77):** 图块 tile(235)、怪物 enemy(81)、道具 items(62)、NPC 图块(并入 tile 的 `Npc` 段)、地图 maps(5 楼层)、贴图素材 textures(42 张拆分图 + 复制 4 autotile + 1 tileset;OI-3=A 时为 43 张)。
② **范围(六类,D-71 / D-77):** 图块 tile(235)、怪物 enemy(81)、道具 items(62)、NPC 图块(并入 tile 的 `Npc` 段)、地图 maps(5 楼层)、贴图素材 textures(42 张拆分图 + 复制 4 autotile + 1 tileset;OI-3=A 时 43 张、C 时 40 张)。
③ **基础字段判定(D-76,逐类):**
- **映射候选(可确定)→ 移植:** tile 的 `id` / `cls→type`(§1.1)/ `cannotOut`+`cannotIn`→`pass.outPass`+`pass.inPass`(位掩码取反);enemy 的 `num` / `attribute`(`name`/`hp`/`atk`/`def`/`money`/`exp`/`point`)/ `special`(数字数组→数字键映射);item 的 `num` / `category`(§4.1)/ `name` / `text` / `hideInToolbox`;map 的 `floorId` / `width` / `map` / `layerAlias`。
- **映射候选(可确定)→ 移植:** tile 的 `id` / `cls→type`(§1.1)/ `cannotOut`+`cannotIn`→`pass.outPass`+`pass.inPass`(位掩码取反);enemy 的 `num` / `attribute`(`name`/`hp`/`atk`/`def`/`money`/`exp`/`point`)/ `special`(数字数组→数字键映射);item 的 `num` / `category`(§4.1)/ `name` / `text`(**旧缺 `text` 的 13 条 `wand`/`pack`/`I331..I341` 显式写 `""`**)/ `hideInToolbox`;map 的 `floorId` / `width` / `map` / `layerAlias`。
- **§8.2 待定 → 不移植:** tile `trigger` / `canPass`;enemy `value`/`range`/`zoneSquare`/`add`/`n`/`bigImage`/`notBomb`/`atkValue`/`defValue`;item `itemEffect`/`itemEffectTip`/`useItemEffect`/`useItemEvent`/`canUseItemEffect`/`equip` 子字段;map `events` / `bgmap`/`fgmap`/`bg2map`/`fg2map`。
- **§8.1 无目标字段 → 只进 unmapped 文档:** tile `canBreak`/`script`/`doorInfo`/`animate`/`name`/`faceIds`/`autotileConnection`;enemy `faceIds`;item `hideInReplay`;map `title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`。
@ -172,49 +175,51 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
⑤ **PNG 生成可行性结论:** 仓库无 PNG 库(已核实);采用**零依赖** Node `zlib` 手写 PNG 编解码(源 PNG 均 8 位非隔行;autotiles 为 type 3 或 type 6,均逐字节复制不解码);**不新增依赖**。**待裁定项:** `autotile1.png` 类型(U-7)与 `magictower.png` 切格(U-8)暂取候选值(OI-6)。
⑥ **48px 素材合并(U-9 / U-17 / OI-3)技术核实结论与默认处置:** 实测 `npcs.png` 单元格 32×32、`npc48.png` 单元格 64×48(`enemys.png` 32×32、`enemy48.png` 64×48)。`addRowAnimate`(`manager.ts`:158-175,配 `TextureRowSplitter`)要求**单张图内所有块共用同一帧 `width`/`height`**,故 32×32 与 64×48 帧**不能同图**。**按尺寸分组**会使 `npc` 由 D-81 的 6 张变 7 张(总数 43)。**默认处置 = 保持 D-81(6 张 / 42,`npc0..5`)**:将 `npc` 全部帧单元统一为 64×48(14 个 32×32 帧以**透明留白**嵌入 64×48 单元,不做重采样)→ 24 块 ÷ 4 = 6 张(均 128×192)。该统一会改变小 NPC 帧单元尺寸(渲染影响不在本增量验证范围,D-83 接线延后)。备选 A = 按尺寸分组(`npc0..6`=7 图、总数 43,显式接受偏离 D-81);备选 C = 本增量只出 32px(`npc0..3`=4 图,48px 延后)。`enemy` 无论按尺寸分组(19+2)或整组(81÷4 向上取整)均为 21 张,与 D-81 `enemy0..20` 一致。
⑥ **贴图布局规则(D-84,修订 D-72⑤):** 不再严格「单帧 4×4 / 多帧 = 帧数列×4 行」。拆分图**列数固定**(单帧 4 列 / 多帧 = 帧数列),**行数 ≤4 且按每图实际块数裁剪**(素材不足时裁到 1–3 行);每张图 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`)。据此按来源块数逐图推导,末图会裁剪:A `terrains0..2`(35 块 → 16+16+3)→ `terrains0`/`terrains1` 128×128(4 行)、`terrains2` **128×32**(1 行);B `terrainsAnim0..7`(29 块 → 7×4+1)→ `terrainsAnim0..6` 128×128、`terrainsAnim7` **128×32**;C `items0..3`(62 块 → 16+16+16+14)→ 均 128×128;D `enemy0..20`(32px 73 块 → `enemy0..17` 64×128 + `enemy18` **64×32**;48px 8 块 → `enemy19`/`enemy20` 128×192);E `npc`(默认 OI-3=B,24 块 → `npc0..5` 均 128×192)。**门禁按实际行数断言、不硬编码 4 行尺寸。** §2 台账「严格 4 行」描述**由 D-84 取代**(盘点文档不改)。
⑦ **地图图层键:** `mapState.fromRaw` 要求每层为整张展平网格(`length = width × height`),与台账 §6.2 候选「层键即行索引」不符;默认「旧二维 `map` 单张展平图层、键 `0`、`layerAlias` = `{}`」,额外图层(`bgmap` 等)属 §8.2 U-16b 不移植(OI-4)。
⑦ **48px 素材合并(U-9 / U-17 / OI-3)技术核实结论与默认处置:** 实测 `npcs.png` 单元格 32×32(14 个 NPC 块,各 2 帧 32×32)、`npc48.png` 单元格 64×48(10 个 NPC 块,各 2 帧 64×48)(`enemys.png` 32×32、`enemy48.png` 64×48)。`addRowAnimate`(`manager.ts`:158-175,配 `TextureRowSplitter`)要求**单张图内所有块共用同一帧 `width`/`height`**,故 32×32 与 64×48 帧**不能同图**。**按尺寸分组**会使 `npc` 由 D-81 的 6 张变 7 张(总数 43)。**默认处置 = 保持 D-81(6 张 / 42,`npc0..5`)**:将 `npc` 全部帧单元统一为 64×48(14 个 NPC 块(各 2 帧 32×32)以**透明留白**嵌入 64×48 单元,不做重采样)→ 24 块 ÷ 4 = 6 张(24÷4 满图,均 4 行 → 均 128×192)。该统一会改变小 NPC 帧单元尺寸(渲染影响不在本增量验证范围,D-83 接线延后)。备选 A = 按尺寸分组(`npc0..6`=7 图、总数 43,显式接受偏离 D-81;`npc0..2` 64×128 + `npc3` 64×64 + `npc4`/`npc5` 128×192 + `npc6` 128×96);备选 C = 本增量只出 32px(`npc0..3`=4 图、总数 40,`npc0..2` 64×128 + `npc3` 64×64,`npc48` 的 10 个 64×48 块延后)。`enemy` 无论按尺寸分组(19+2)或整组(81÷4 向上取整)均为 21 张,与 D-81 `enemy0..20` 一致。
⑧ **产出文件:** `src/content/tile.jsonc` / `enemy.jsonc` / `item.jsonc`(替换样例);`src/content/maps/{empty,MT0,sample0,sample1,sample2}.jsonc` + `maps/.list.jsonc`;`src/content/texture/*.png`(42 拆分图 + 5 复制素材);`src/content/texture/.list.jsonc`;`script/migrateProjectTextures.ts`(待批准,OI-2);`.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md`(待批准);`09-19-SUMMARY.md`。
⑧ **地图图层键:** `mapState.fromRaw` 要求每层为整张展平网格(`length = width × height`),与台账 §6.2 候选「层键即行索引」不符;默认「旧二维 `map` 单张展平图层、键 `0`、`layerAlias` = `{}`」,额外图层(`bgmap` 等)属 §8.2 U-16b 不移植(OI-4)。
⑨ **不在本增量(D-70 / D-83):** 不实现消费接线(写 store / 写 `TextureManager`);不改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`;不移植 §8.2 待定与 §8.1 无目标字段。
⑨ **产出文件:** `src/content/tile.jsonc` / `enemy.jsonc` / `item.jsonc`(替换样例);`src/content/maps/{empty,MT0,sample0,sample1,sample2}.jsonc` + `maps/.list.jsonc`;`src/content/texture/*.png`(42 拆分图 + 5 复制素材;A=43、C=40);`src/content/texture/.list.jsonc`;`script/migrateProjectTextures.ts`(待批准,OI-2);`.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md`(待批准);`09-19-SUMMARY.md`。
⑩ **验证方式:** 静态门禁——jsonc 可解析(`// --- SYSTEM PREFIX END --- //` 后严格 JSON);tile 235 条且 `id`/`num` 唯一、`type` 合法;enemy 81 / item 62 条且 `num` 存在于 tile 且类型匹配;`texture/.list.jsonc` 每个 `source` 文件存在、`map`/`identifier` 覆盖**每一处有贴图的图块 `num`**(集合覆盖断言);**每张生成的 PNG(含全部 `enemy*`/`npc*`)均断言签名 / 8 位 / 非隔行 / D-72 尺寸**;maps 5 楼层齐备且每层数组长度 = `width`×`height`、数值均为 `0` 或有效 `num`;unmapped 文档存在且仅含 §8.1。**不设全仓类型 / 构建门禁。**
⑩ **不在本增量(D-70 / D-83):** 不实现消费接线(写 store / 写 `TextureManager`);不改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`;不移植 §8.2 待定与 §8.1 无目标字段。
⑪ **修改方式声明(AGENTS.md):** 本计划执行含文件写入,由**派发子代理执行**(`gsd-executor`),非内联执行。
⑪ **验证方式:** 静态门禁——jsonc 可解析(`// --- SYSTEM PREFIX END --- //` 后严格 JSON);tile 235 条且键集恰 6 键、`id`/`num` 唯一、`type` 合法;enemy 81 条且键集恰 3 键(`num`/`attribute`/`special`)、`special` 形状正确;item 62 条且键集恰 5 键(`num`/`category`/`name`/`text`/`hideInToolbox`)、`text` 为字符串;`texture/.list.jsonc` 每个 `source` 文件存在、`map`/`identifier` 覆盖**每一处有贴图的图块 `num`**(集合覆盖断言);**每张生成的 PNG(含全部 `enemy*`/`npc*`)均断言签名 / 8 位 / 非隔行,并按 D-84 逐图实际行数断言 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`,末图可为 128×32 / 64×32 等,不硬编码 4 行尺寸)**,`npc` 图数 `K` 按 OI-3 取值断言 `K ∈ {4,6,7}` 且按实际行数校验各图尺寸(B:24 块满图均 128×192;A:`npc3` 64×64、`npc6` 128×96;C:`npc3` 64×64);maps 5 楼层齐备、**顶层键集恰 5 键**、每层数组长度 = `width`×`height`、数值均为 `0` 或有效 `num`;unmapped 文档存在且仅含 §8.1。**不设全仓类型 / 构建门禁。**
⑫ **开放项:** OI-1(unmapped 文档路径/命名)、OI-2(脚本路径/命名,已纠正为小驼峰 `migrateProjectTextures.ts`)、OI-3(48px 合并处理,默认 B)、OI-4(地图图层键/`layerAlias`)、OI-5(`source` 路径形态)、OI-6(autotile/tileset 参数)、OI-7(写操作方式声明)。
⑫ **修改方式声明(AGENTS.md):** 本计划执行含文件写入,由**派发子代理执行**(`gsd-executor`),非内联执行。
⑬ **关卡性质:** **无论触发方式或 `mode: yolo` / `workflow.auto_advance`,执行者 / 子代理必须停下等待用户回复;回复须明确给出 OI-3 取值(A/B/C),仅回「可以执行」而未含 OI-3 结论视为未通过;** 若用户就 OI-1..OI-6 给出指定,按指定执行。
⑬ **开放项:** OI-1(unmapped 文档路径/命名)、OI-2(脚本路径/命名,已纠正为小驼峰 `migrateProjectTextures.ts`)、OI-3(48px 合并处理,默认 B)、OI-4(地图图层键/`layerAlias`)、OI-5(`source` 路径形态)、OI-6(autotile/tileset 参数)、OI-7(写操作方式声明)。
⑭ **关卡性质:** **无论触发方式或 `mode: yolo` / `workflow.auto_advance`,执行者 / 子代理必须停下等待用户回复;回复须明确给出 OI-3 取值(A/B/C),仅回「可以执行」而未含 OI-3 结论视为未通过;** 若用户就 OI-1..OI-6 给出指定,按指定执行。
</action>
<decision>是否按本计划(09-19)执行「任务 9 · 旧引擎数据基础移植」——以 `num` 策略 A 写入 tile/enemy/item jsonc、按 D-72/D-81 生成贴图素材与 `texture/.list.jsonc`、写入 5 楼层与 `maps/.list.jsonc`、产出 unmapped 说明文档;基础字段全量、§8.2 待定与 §8.1 无目标字段不落数据文件;不实现消费接线;**并明确 48px 合并(OI-3)的处置(默认 B = 单元格统一到 64×48 以保持 D-81 `npc0..5`=6 图)**</decision>
<decision>是否按本计划(09-19)执行「任务 9 · 旧引擎数据基础移植」——以 `num` 策略 A 写入 tile/enemy/item jsonc、按 D-72/D-81/D-84 生成贴图素材与 `texture/.list.jsonc`、写入 5 楼层与 `maps/.list.jsonc`、产出 unmapped 说明文档;基础字段全量、§8.2 待定与 §8.1 无目标字段不落数据文件(各实体/地图以精确键集断言兜底);不实现消费接线;**并明确 48px 合并(OI-3)的处置(默认 B = 单元格统一到 64×48 以保持 D-81 `npc0..5`=6 图 / 总数 42)**</decision>
<context>
AGENTS.md / D-03 / D-04 要求执行前与命名先获批;`.planning/config.json` 为 `mode: yolo`,故本关卡显式 `blocking-human` 且 `autonomous: false`。执行期为**写操作**(新增/替换 `src/content/**` 数据与 PNG、新增脚本与两份文档)。OI-3 为**必须显式裁定**项:默认处置会改变小 NPC 帧单元尺寸,故不得由「可以执行」隐含放行。执行期工作树存在用户并发改动(`AGENTS.md` / `09-CONTEXT.md`,D-56),提交时须逐条核对暂存面。行号为规划日实测、可能漂移,门禁以结构断言为准。
AGENTS.md / D-03 / D-04 要求执行前与命名先获批;`.planning/config.json` 为 `mode: yolo`,故本关卡显式 `blocking-human` 且 `autonomous: false`。执行期为**写操作**(新增/替换 `src/content/**` 数据与 PNG、新增脚本与两份文档)。OI-3 为**必须显式裁定**项:默认处置会改变小 NPC 帧单元尺寸,故不得由「可以执行」隐含放行。执行期工作树存在用户并发改动(`AGENTS.md` / `09-CONTEXT.md`,D-56),提交时须逐条核对暂存面。贴图尺寸按 D-84 逐图实际行数(≤4)断言,不硬编码 4 行尺寸。行号为规划日实测、可能漂移,门禁以结构断言为准。
</context>
<options>
<option id="execute-unify">
<name>可以执行 + OI-3 = B(默认推荐:单元格统一到 64×48,保持 D-81 的 `npc0..5`=6 图 / `SPLIT-COUNT` 42)</name>
<pros>图数与 D-81 完全一致;不重采样(仅对 14 个 32×32 帧做透明留白);基础字段全量落地;零依赖 PNG 方案;跨文件 `id`↔`num` 一致</pros>
<pros>图数与 D-81 完全一致;不重采样(仅对 14 个 NPC 块(各 2 帧 32×32)做透明留白);基础字段全量落地;零依赖 PNG 方案;跨文件 `id`↔`num` 一致;本处置下 `npc` 各图恰 4 行(128×192)</pros>
<cons>小 NPC 的帧单元尺寸变为 64×48(渲染端呈现影响留待消费接线阶段确认,本增量不验证)</cons>
</option>
<option id="execute-bysize">
<name>可以执行 + OI-3 = A(按尺寸分组:`npc0..6`=7 图、总数 43;显式接受偏离 D-81 的 6 图 / 42)</name>
<pros>不改动任何帧像素 / 单元格尺寸,素材与源完全一致</pros>
<cons>`npc` 图数 7 偏离 D-81 的 6,`SPLIT-COUNT` 由 42 变 43(须用户明确接受该偏离)</cons>
<cons>`npc` 图数 7 偏离 D-81 的 6,`SPLIT-COUNT` 由 42 变 43(须用户明确接受该偏离);末图按 D-84 裁剪(`npc3` 64×64、`npc6` 128×96)</cons>
</option>
<option id="execute-defer48">
<name>可以执行 + OI-3 = C(本增量只出 32px:`npc0..3`=4 图;48px 变体延后)</name>
<name>可以执行 + OI-3 = C(本增量只出 32px:`npc0..3`=4 图、总数 40;`npc48` 的 10 个 64×48 块延后)</name>
<pros>不改像素;48px 处理留待后续增量,本增量不臆造</pros>
<cons>本增量 enemy/npc 素材不完整(缺 48px 变体),与 D-72① 合并意图暂有差距</cons>
<cons>本增量 enemy/npc 素材不完整(缺 48px 变体),与 D-72① 合并意图暂有差距;末图按 D-84 裁剪(`npc3` 64×64)</cons>
</option>
<option id="revise">
<name>修订落实方式 / `num` 分段口径 / 图层键口径后重新汇报</name>
<name>修订落实方式 / `num` 分段口径 / 图层键口径 / 布局裁剪口径后重新汇报</name>
<pros>避免带着未确认口径写数据</pros>
<cons>须修订 PLAN.md 后重新汇报</cons>
</option>
</options>
<resume-signal>回复「可以执行」**并明确 OI-3 取值(B(默认推荐)/ A / C)**;仅回「可以执行」而未含 OI-3 结论视为未通过。如需调整 OI-1..OI-6 的其它处置,请在回复中一并指明;修订意见请按 action ⑬ 处理。</resume-signal>
<resume-signal>回复「可以执行」**并明确 OI-3 取值(B(默认推荐)/ A / C)**;仅回「可以执行」而未含 OI-3 结论视为未通过。如需调整 OI-1..OI-6 的其它处置,请在回复中一并指明;修订意见请按 action ⑭ 处理。</resume-signal>
<verify>
<automated>node -e "const fs=require('fs');const z=require('zlib');const bad=[];const doc='.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md';if(!fs.existsSync(doc))bad.push('missing audit doc');for(const p of ['packages-user/data-common/src/store/types.ts','packages-user/client-base/src/material/types.ts','packages-user/client-base/src/material/manager.ts','packages/render/src/assets/splitter.ts','packages-user/data-state/src/loader/jsoncProcessor.ts','packages-user/data-base/src/map/mapState.ts','public/project/maps.js','public/project/enemys.js','public/project/items.js','src/content/texture/.list.jsonc']){if(!fs.existsSync(p))bad.push('missing ref: '+p)}for(const p of ['.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md','.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md']){if(fs.existsSync(p))bad.push('post-state already exists: '+p)}if(typeof z.inflateSync!=='function'||typeof z.deflateSync!=='function')bad.push('node zlib inflate/deflate unavailable');if(bad.length>0){console.error('PRESTATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK pre-execution state: audit doc present; unmapped+summary absent; refs present; node zlib available')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "PRESTATE FAILED"(被移植来源或参考文件缺失,或产出文件已存在,或 Node `zlib` 不可用)</fails_when>
@ -222,8 +227,8 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
</verify>
<acceptance_criteria>
- 用户回复中明确包含「可以执行」,**且明确给出 OI-3 取值(A/B/C)**
- 汇报内容涵盖:要解决的问题、六类范围、三类基础字段判定(映射候选可确定 / §8.2 待定 / §8.1 无目标字段)、`num` 策略 A 具体方案、PNG 零依赖可行性结论、48px 合并(OI-3)技术核实结论与默认处置(B)、地图图层键口径、产出文件清单、消费接线不在本增量、验证方式(静态门禁,无全仓门禁)、OI-1..OI-7
- 汇报中已原样告知:**写操作边界(除 `files_modified` 外不写;不改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`/既有计划与台账)**、**不新增依赖**、**§8.1/§8.2 不落数据文件**、**命名待批准项先入 `open_items`**
- 汇报内容涵盖:要解决的问题、六类范围、三类基础字段判定(映射候选可确定 / §8.2 待定 / §8.1 无目标字段)、`num` 策略 A 具体方案、PNG 零依赖可行性结论、贴图布局规则(D-84:列数固定、行数 ≤4 且按每图实际块数裁剪)、48px 合并(OI-3)技术核实结论与默认处置(B)、地图图层键口径、产出文件清单、消费接线不在本增量、验证方式(静态门禁,无全仓门禁)、OI-1..OI-7
- 汇报中已原样告知:**写操作边界(除 `files_modified` 外不写;不改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`/既有计划与台账)**、**不新增依赖**、**§8.1/§8.2 不落数据文件(精确键集断言兜底)**、**贴图尺寸按 D-84 逐图实际行数断言、不硬编码 4 行尺寸**、**命名待批准项先入 `open_items`**
- 汇报中已原样告知**关卡性质**(必须停下等待;不因触发方式或 `yolo` / `auto_advance` 自动放行;**回复须含 OI-3 取值**)
</acceptance_criteria>
<done>用户明确回复且给出 OI-3 取值;在此之前未执行任何任务、未修改任何文件、未派发任何执行子代理</done>
@ -236,7 +241,7 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
<read_first>
- .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md(§1.1 `cls`→`TileType`、§1.2 tile 字段分类、§1.3 `num`/`id` 全清单、§1.5.1 `id` 全清单、§3.1 enemy 字段、§3.2 enemy 全清单、§4.1 `cls`→`ItemCategory`、§4.2 item 字段、§4.4 item 全清单)
- packages-user/data-common/src/store/types.ts:6-63(`TileType`:6 / `PassBit`:27 / `ITilePassData`:38 / `ITileRawData`:47)、:125-213(`ItemCategory`:125 / `IItemRawData`:176)、:319-326(`IEnemyRawData`:319)—— 字段判定唯一依据
- src/content/defaults/tile.jsonc、src/content/defaults/enemy.jsonc、src/content/defaults/item.jsonc(必填字段 `@required` 与骨架默认)
- src/content/defaults/tile.jsonc、src/content/defaults/enemy.jsonc、src/content/defaults/item.jsonc(必填字段 `@required` 与骨架默认;item 默认含 `"text": ""`)
- src/content/tile.jsonc、src/content/enemy.jsonc、src/content/item.jsonc(现状样例,确认省略策略与 `// Type:` 前缀格式)
- public/project/maps.js(`var maps_<hash>` 字面对象)、public/project/enemys.js(`var enemys_<hash>`)、public/project/items.js(`var items_<hash>`)—— 源数据
- dev.md(命名规则、`num` 语义);AGENTS.md(不得臆造、命名先报备)
@ -247,52 +252,54 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
- 分段顺序 `Terrain → Animate → Item → Enemy → Npc → Autotile`;段内按旧 `num` 升序;从 `num=1` 起**连续**分配(`0` 保留空图块);产出 `oldNum → id → newNum` 与 `id → newNum` 两张表(内存中使用,不写额外文件)。
- **硬约束:** 新 `num` 全体唯一、`id` 全体唯一;后续所有文件(含 Task 2 的 `texture/.list.jsonc` `map`、Task 3 的 maps 数值与 enemy/item `num`)必须复用本表。
(1) **写 `src/content/tile.jsonc`(235 条,替换样例):** 保留既有头部 `// Type:` 注释与 `// --- SYSTEM PREFIX END --- //` 分隔行不动(其后必须是严格 JSON)。每条图块对象字段:
(1) **写 `src/content/tile.jsonc`(235 条,替换样例):** 保留既有头部 `// Type:` 注释与 `// --- SYSTEM PREFIX END --- //` 分隔行不动(其后必须是严格 JSON)。每条图块对象字段(**键集精确 = `num`/`id`/`events`/`type`/`eventPass`/`pass`,恰 6 键**):
- `num` = 上表新 `num`;`id` = 旧 `id`;`type` = 上表 `TileType` 数值;`events` = `{}`;`eventPass` = `false`。
- `pass`:默认 `{ "onlyEvents": true, "outPass": 15, "inPass": 15 }`;若旧图块含 `cannotOut`/`cannotIn`(§1.2 映射候选可确定),按「可离开/可进入方向 = 全方向位 & ~(被禁方向位)」计算 `outPass`/`inPass`(方向位 `Up=1`/`Right=2`/`Down=4`/`Left=8`,全方向 = 15)。
- **不写** §8.1 字段(`canBreak`/`script`/`doorInfo`/`animate`/`name`/`faceIds`/`autotileConnection`)与 §8.2 字段(`trigger`/`canPass`);不确定的 `pass` 取值沿用默认并在 SUMMARY 记 `open_items`。
(2) **写 `src/content/enemy.jsonc`(81 条,替换样例):** 保留头部注释与分隔行。每条:
(2) **写 `src/content/enemy.jsonc`(81 条,替换样例):** 保留头部注释与分隔行。每条(**键集精确 = `num`/`attribute`/`special`,恰 3 键**):
- `num` = 该怪物图块 `id`(如 `greenSlime`)在 (0) 表中的新 `num`(即 tile.jsonc 中 `Enemy` 类图块的 `num`)。
- `attribute` = `{ "name": <旧 name>, "hp": <旧 hp>, "atk": <旧 atk>, "def": <旧 def>, "money": <旧 money>, "exp": <旧 exp>, "point": <旧 point> }`(缺省按 `defaults/enemy.jsonc` 的 0 骨架)。
- `special` = 由旧 `special` 数字数组生成的对象,键为数组中每个数字、值为 `null`(如 `[16,18]` → `{ "16": null, "18": null }`;空数组 → `{}`)。
- **不写** §8.2 字段(`value`/`range`/`zoneSquare`/`add`/`n`/`bigImage`/`notBomb`/`atkValue`/`defValue`)与 §8.1 字段(`faceIds`)。
- **不写** §8.2 字段(`value`/`range`/`zoneSquare`/`add`/`n`/`bigImage`/`notBomb`/`atkValue`/`defValue`)与 §8.1 字段(`faceIds`)。**每条只写上述 3 键,多余键由门禁精确键集断言拦截。**
(3) **写 `src/content/item.jsonc`(62 条,替换样例):** 保留头部注释与分隔行。每条:
(3) **写 `src/content/item.jsonc`(62 条,替换样例):** 保留头部注释与分隔行。每条(**键集精确 = `num`/`category`/`name`/`text`/`hideInToolbox`,恰 5 键**):
- `num` = 该道具图块 `id`(如 `yellowKey` / `I331`)在 (0) 表中的新 `num`(即 tile.jsonc 中 `Item` 类图块的 `num`)。
- `category` 按 §4.1:`tools`→`Consumable`=2、`items`→`Consumable`=2、`equips`→`Equipment`=4、`constants`→`Constant`=1(剑盾 `items` 类分类待确认,默认 `Consumable`,记 `open_items`)。
- `name` / `text` / `hideInToolbox`(缺省 `false`)按旧值直接对应;保留 `hideInToolbox` 的 `true`(5 条)。
- **不写** §8.2 字段(`itemEffect`/`itemEffectTip`/`useItemEffect`/`useItemEvent`/`canUseItemEffect`)与 `equip` 子字段值、§8.1 字段(`hideInReplay`);沿用现有样例的**省略策略**(`effect`/`equip` 省略,由默认合并兜底),在 SUMMARY 记录该策略与 `open_items`。
- `name` = 旧 `name` 直接对应。
- **`text`(关键):** 旧 `items.js` 中若含 `text` 则直接对应;**旧值缺失的 13 条(`wand` / `pack` / `I331`..`I341`,均 `cls: items`)显式写入 `""`(空字符串)**—— 与 `defaults/item.jsonc` 的 `"text": ""` 一致,且保证门禁 `typeof it.text === 'string'` 与「恰 5 键」断言成立。**`text` 不适用省略策略。**
- `hideInToolbox` = 旧值(缺省 `false`);保留为 `true` 的 5 条。
- **不写** §8.2 字段(`itemEffect`/`itemEffectTip`/`useItemEffect`/`useItemEvent`/`canUseItemEffect`)与 `equip` 子字段值、§8.1 字段(`hideInReplay`);**仅 `effect`/`equip` 沿用现有样例的省略策略**(由默认合并兜底),在 SUMMARY 记录该策略(并明确 `text` 例外)与 `open_items`。
(4) **收口:** 三份 jsonc 的 `// --- SYSTEM PREFIX END --- //` 之后必须是严格 JSON(无注释、无尾逗号);文件头 `// Type:` 前缀行不得改动;不改任何其它文件;`num`/`id` 全局唯一且五处基准可复用。
</action>
<reversibility rating="reversible">替换 `src/content` 三份样例数据文件;`git checkout` / 重新生成可整体回退;未触及任何源码与既有注释。</reversibility>
<verify>
<automated>node -e "const fs=require('fs');const root='src/content/';const read=p=>{const t=fs.readFileSync(root+p,'utf8');const parts=t.split('// --- SYSTEM PREFIX END --- //');if(parts.length!==2)throw new Error(p+' :: missing/duplicated SYSTEM PREFIX END marker');return JSON.parse(parts[1])};const bad=[];let tile,enemy,item;try{tile=read('tile.jsonc')}catch(e){bad.push('tile.jsonc: '+e.message)}try{enemy=read('enemy.jsonc')}catch(e){bad.push('enemy.jsonc: '+e.message)}try{item=read('item.jsonc')}catch(e){bad.push('item.jsonc: '+e.message)}const numToId=new Map(),idSet=new Set();if(tile){if(!Array.isArray(tile))bad.push('tile.jsonc not an array');else{if(tile.length!==235)bad.push('tile count '+tile.length+' != 235');for(const t of tile){const keys=Object.keys(t).sort().join(',');if(keys!=='eventPass,events,id,num,pass,type')bad.push('tile '+t.id+' unexpected keys: '+keys);if(![2,3,4,5,6,7].includes(t.type))bad.push('tile '+t.id+' bad type '+t.type);if(numToId.has(t.num))bad.push('dup num '+t.num);if(idSet.has(t.id))bad.push('dup id '+t.id);numToId.set(t.num,t.id);idSet.add(t.id);if(!t.pass||typeof t.pass.outPass!=='number'||typeof t.pass.inPass!=='number')bad.push('tile '+t.id+' bad pass')}}}const enemyTiles=new Set(),itemTiles=new Set();if(tile)for(const t of tile){if(t.type===5)enemyTiles.add(t.num);if(t.type===4)itemTiles.add(t.num)}if(enemy){if(!Array.isArray(enemy)||enemy.length!==81)bad.push('enemy count '+(enemy?enemy.length:'?')+' != 81');else for(const e of enemy){if(!enemyTiles.has(e.num))bad.push('enemy num '+e.num+' not an Enemy tile');if(!e.attribute||typeof e.attribute.name!=='string'||typeof e.attribute.hp!=='number')bad.push('enemy '+e.num+' bad attribute');if(typeof e.special!=='object'||e.special===null||Array.isArray(e.special))bad.push('enemy '+e.num+' special not an object')}}if(item){if(!Array.isArray(item)||item.length!==62)bad.push('item count '+(item?item.length:'?')+' != 62');else for(const it of item){if(!itemTiles.has(it.num))bad.push('item num '+it.num+' not an Item tile');if(typeof it.name!=='string'||typeof it.text!=='string'||typeof it.hideInToolbox!=='boolean')bad.push('item '+it.num+' bad name/text/hideInToolbox');if(![1,2,3,4].includes(it.category))bad.push('item '+it.num+' bad category '+it.category)}}if(bad.length>0){console.error('ENTITY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK entities: tile 235 unique id/num; enemy 81 on Enemy tiles; item 62 on Item tiles; strict JSON after marker')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "ENTITY GATE FAILED"(jsonc 缺分隔标记 / 解析失败 / tile≠235 或字段集不符 / `id`/`num` 重复 / `type` 非法 / enemy≠81 或 `num` 非 `Enemy` 图块 / enemy `attribute`/`special` 形状错误 / item≠62 或 `num` 非 `Item` 图块 / item `name`/`text`/`category` 错误)</fails_when>
<automated>node -e "const fs=require('fs');const root='src/content/';const read=p=>{const t=fs.readFileSync(root+p,'utf8');const parts=t.split('// --- SYSTEM PREFIX END --- //');if(parts.length!==2)throw new Error(p+' :: missing/duplicated SYSTEM PREFIX END marker');return JSON.parse(parts[1])};const bad=[];let tile,enemy,item;try{tile=read('tile.jsonc')}catch(e){bad.push('tile.jsonc: '+e.message)}try{enemy=read('enemy.jsonc')}catch(e){bad.push('enemy.jsonc: '+e.message)}try{item=read('item.jsonc')}catch(e){bad.push('item.jsonc: '+e.message)}const numToId=new Map(),idSet=new Set();if(tile){if(!Array.isArray(tile))bad.push('tile.jsonc not an array');else{if(tile.length!==235)bad.push('tile count '+tile.length+' != 235');for(const t of tile){const keys=Object.keys(t).sort().join(',');if(keys!=='eventPass,events,id,num,pass,type')bad.push('tile '+t.id+' unexpected keys: '+keys);if(![2,3,4,5,6,7].includes(t.type))bad.push('tile '+t.id+' bad type '+t.type);if(numToId.has(t.num))bad.push('dup num '+t.num);if(idSet.has(t.id))bad.push('dup id '+t.id);numToId.set(t.num,t.id);idSet.add(t.id);if(!t.pass||typeof t.pass.outPass!=='number'||typeof t.pass.inPass!=='number')bad.push('tile '+t.id+' bad pass')}}}const enemyTiles=new Set(),itemTiles=new Set();if(tile)for(const t of tile){if(t.type===5)enemyTiles.add(t.num);if(t.type===4)itemTiles.add(t.num)}if(enemy){if(!Array.isArray(enemy)||enemy.length!==81)bad.push('enemy count '+(enemy?enemy.length:'?')+' != 81');else for(const e of enemy){const ekeys=Object.keys(e).sort().join(',');if(ekeys!=='attribute,num,special')bad.push('enemy '+e.num+' forbidden/extra keys: '+ekeys);if(!enemyTiles.has(e.num))bad.push('enemy num '+e.num+' not an Enemy tile');if(!e.attribute||typeof e.attribute.name!=='string'||typeof e.attribute.hp!=='number')bad.push('enemy '+e.num+' bad attribute');if(typeof e.special!=='object'||e.special===null||Array.isArray(e.special))bad.push('enemy '+e.num+' special not an object')}}if(item){if(!Array.isArray(item))bad.push('item.jsonc not an array');else{if(item.length!==62)bad.push('item count '+item.length+' != 62');for(const it of item){const ikeys=Object.keys(it).sort().join(',');if(ikeys!=='category,hideInToolbox,name,num,text')bad.push('item '+it.num+' forbidden/extra keys: '+ikeys);if(!itemTiles.has(it.num))bad.push('item num '+it.num+' not an Item tile');if(typeof it.text!=='string')bad.push('item '+it.num+' text not a string');if(typeof it.name!=='string')bad.push('item '+it.num+' name not a string');if(typeof it.hideInToolbox!=='boolean')bad.push('item '+it.num+' hideInToolbox not a boolean');if(![1,2,4].includes(it.category))bad.push('item '+it.num+' bad category '+it.category)}}}if(bad.length>0){console.error('ENTITY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK entities: tile 235 (6-key, unique id/num, type ok) / enemy 81 (3-key, Enemy tiles, attr+special) / item 62 (5-key, Item tiles, text string)')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "ENTITY GATE FAILED"(jsonc 缺分隔标记 / 解析失败 / tile≠235 或键集非 6 键 / `id`/`num` 重复 / `type` 非法 / enemy≠81 或键集非 3 键或含 §8.1/§8.2 键 / enemy `num` 非 `Enemy` 图块 / enemy `attribute`/`special` 形状错误 / item≠62 或键集非 5 键或含 §8.1/§8.2 键 / item `num` 非 `Item` 图块 / item `name`/`text`/`hideInToolbox`/`category` 错误)</fails_when>
<automated>node -e "const fs=require('fs');const t=fs.readFileSync('src/content/tile.jsonc','utf8');const lines=t.split(/\r?\n/);const i=lines.findIndex(l=>l.includes('SYSTEM PREFIX END'));if(i<1||!lines.slice(0,i).some(l=>l.startsWith('// Type:'))){console.error('PREFIX GATE FAILED :: tile.jsonc missing // Type: header before marker');process.exit(1)}console.log('OK tile.jsonc retains header // Type: line before SYSTEM PREFIX END')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "PREFIX GATE FAILED"(`tile.jsonc` 头部 `// Type:` 注释行被删除或分隔标记缺失)</fails_when>
</verify>
<acceptance_criteria>
- `tile.jsonc` 恰 235 条;每条键集恰为 `num`/`id`/`events`/`type`/`eventPass`/`pass`;`num` 与 `id` 各自唯一;`type` ∈ {2,3,4,5,6,7};含 `cannotOut`/`cannotIn` 的图块其 `pass.outPass`/`inPass` 按位掩码取反
- `enemy.jsonc` 恰 81 条;每条 `num` 均为 tile 中 `Enemy`(type=5)图块的 `num`;`attribute` 七字段齐备;`special` 为对象(键=旧数字数组元素,值 `null`)
- `item.jsonc` 恰 62 条;每条 `num` 均为 tile 中 `Item`(type=4)图块的 `num`;`category` 按 §4.1;`name`/`text`/`hideInToolbox` 就位
- 三份 jsonc 分隔标记后为严格 JSON;`// Type:` 头部注释行保留;未写任何 §8.1/§8.2 字段;未改任何其它文件
- `tile.jsonc` 恰 235 条;每条键集恰为 `num`/`id`/`events`/`type`/`eventPass`/`pass`(6 键);`num` 与 `id` 各自唯一;`type` ∈ {2,3,4,5,6,7};含 `cannotOut`/`cannotIn` 的图块其 `pass.outPass`/`inPass` 按位掩码取反
- `enemy.jsonc` 恰 81 条;每条键集恰为 `num`/`attribute`/`special`(3 键,无 §8.1/§8.2 键);每条 `num` 均为 tile 中 `Enemy`(type=5)图块的 `num`;`attribute` 七字段齐备;`special` 为对象(键=旧数字数组元素,值 `null`)
- `item.jsonc` 恰 62 条;每条键集恰为 `num`/`category`/`name`/`text`/`hideInToolbox`(5 键,无 §8.1/§8.2 键);每条 `num` 均为 tile 中 `Item`(type=4)图块的 `num`;`category` 按 §4.1;`name`/`text`(含旧缺 `text` 的 13 条写 `""`)/`hideInToolbox` 就位
- 三份 jsonc 分隔标记后为严格 JSON;`// Type:` 头部注释行保留;未写任何 §8.1/§8.2 字段(精确键集断言);未改任何其它文件
</acceptance_criteria>
<done>三份实体 jsonc 落盘且门禁全绿;全局 `oldNum→id→newNum` 表建立并唯一一致</done>
</task>
<task type="auto">
<name>Task 2: 一次性零依赖 PNG 脚本 + 按 D-72 生成贴图素材(默认 OI-3=B 保持 D-81)+ 写 `texture/.list.jsonc`</name>
<name>Task 2: 一次性零依赖 PNG 脚本 + 按 D-72/D-84 生成贴图素材(列数固定、行数 ≤4 且按每图实际裁剪;默认 OI-3=B 保持 D-81)+ 写 `texture/.list.jsonc`</name>
<precondition>Task 1 已完成(`tile.jsonc` 235 条与全局 `id`↔`num` 基准就位,可被本任务复用);Task 0 已就 OI-3 明确裁定(默认 B)。</precondition>
<files>script/migrateProjectTextures.ts, src/content/texture/.list.jsonc, src/content/texture/*.png</files>
<read_first>
- .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md(§0.5 D-72 五规则 + tilesets/autotiles 例外、§0.5.1 `animates` 并入 `terrains`、§1.4 图块→贴图归属、§2.1 `materials` 尺寸、§2.2 autotiles、§2.3 tilesets、§2.4 拆分方案、§2.5 命名、§5.2 npc 合并说明、§7 / §7.1 schema 与类型)
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-72 / D-73 / D-80 / D-81 / D-82 / D-83)
- packages-user/client-base/src/material/types.ts:12-17(`AutotileType`)、:50-55(`IIndexedIdentifier`)、:261-307(`addGrid`/`addRowAnimate`/`addAutotile`/`addTileset`)—— list schema 能力依据;packages-user/client-base/src/material/manager.ts:144-190(实现:`addRowAnimate` 单图单尺寸、`source.width % width === 0`);packages/render/src/assets/splitter.ts:8-18(`TextureRowSplitter`)—— OI-3 单元格统一依据
- .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md(§0.5 D-72 五规则 + tilesets/autotiles 例外、§0.5.1 `animates` 并入 `terrains`、§1.4 图块→贴图归属、§2.1 `materials` 尺寸、§2.2 autotiles、§2.3 tilesets、§2.4 拆分方案、§2.5 命名、§5.2 npc 合并说明、§7 / §7.1 schema 与类型)—— **注意:§2.4「单帧 4×4 / 多帧 帧数列×4 行」的严格 4 行读法由 D-84 取代(裁到实际行数);盘点文档本身不改写。**
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-72 / D-73 / D-80 / D-81 / D-82 / D-83 / **D-84**)
- packages-user/client-base/src/material/types.ts:12-17(`AutotileType`)、:50-55(`IIndexedIdentifier`)、:261-307(`addGrid`/`addRowAnimate`/`addAutotile`/`addTileset`)—— list schema 能力依据;packages-user/client-base/src/material/manager.ts:144-190(实现:`addRowAnimate` 单图单尺寸、`source.width % width === 0`);packages/render/src/assets/splitter.ts:8-18(`TextureRowSplitter`:`lines = ceil(height / frameH)`)—— OI-3 单元格统一与 D-84 逐图裁剪依据
- src/content/client.jsonc:9(`textureListDir: texture/.list.jsonc`、`textureDir: texture`)、src/content/texture/.list.jsonc(现状 `{}`)—— 落点与路径形态参考
- src/content/tile.jsonc(Task 1 产出:新 `num` 与 `id`)
- public/project/materials/*.png、public/project/autotiles/*.png、public/project/tilesets/magictower.png —— 源素材
- public/project/materials/*.png、public/project/autotiles/*.png、public/project/tilesets/magictower.png —— 源素材(实测块数:`terrains` 35、`animates` 29、`items` 62、`enemys` 73、`enemy48` 8、`npcs` 14、`npc48` 10)
- dev.md(命名规则:**代码文件名小驼峰**、常量全大写、模块无副作用);AGENTS.md(不新增依赖、命名先报备)
</read_first>
<action>
@ -301,27 +308,31 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
- 编码:写 8 位 RGBA、非隔行 PNG(`IHDR` + 每行 filter 0 + `zlib.deflateSync`(level 9)的 `IDAT` + `IEND`,各块 CRC32 自实现)。
- 该脚本一次性运行生成素材,不纳入构建;脚本不含导出变量、顶层不使用 `try-catch`(dev.md)。
- 读取源图:`public/project/materials/{terrains,animates,items,enemys,enemy48,npcs,npc48}.png`(均为 8 位非隔行)。
- 生成 A 组 `terrains` 单帧:源 `terrains.png`(32×1120,1 列 × 35 行,每格 32×32)→ 依序填入 4×4 网格,每图 16 格,共 3 图 `terrains0..2.png`(128×128;末图剩余格透明填充)。
- 生成 B 组(`animates` 并入 `terrains`,D-72③):源 `animates.png`(128×928,4 帧列 × 29 行,每格帧 32×32)→ 每图 4 行(每行 4 帧列),共 8 图 `terrainsAnim0..7.png`(128×128)。
- 生成 C 组 `items` 单帧:源 `items.png`(32×1984,1 列 × 62 行,每格 32×32)→ 4×4 网格,共 4 图 `items0..3.png`(128×128)。
- 生成 D 组 `enemy`(合并 `enemys`+`enemy48`,D-72①):源 `enemys.png`(64×2336,2 帧列 × 73 行,每格帧 32×32)与 `enemy48.png`(128×384,2 帧列 × 8 行,每格帧 64×48);多帧 → 每图 4 行、每行 2 帧列;**按单元格尺寸分组**:32px 组 73 个 → 19 图(每图 64×128)、48px 组 8 个 → 2 图(每图 128×192),共 21 图 `enemy0..20.png`(与 D-81 一致)。
- 生成 E 组 `npc`(合并 `npcs`+`npc48`,D-72①):源 `npcs.png`(64×448,2 帧列 × 14 行,每格帧 32×32)与 `npc48.png`(128×480,2 帧列 × 10 行,每格帧 64×48);多帧 → 每图 4 行、每行 2 帧列。**按 Task 0 的 OI-3 裁定生成:** **默认 B(保持 D-81)** = 单元格统一到 64×48(14 个 32×32 帧以透明留白嵌入 64×48 单元格,不重采样;与 10 个 64×48 帧同布局)→ 24 块 ÷ 4 = 6 图 `npc0..5.png`(均 128×192,`SPLIT-COUNT` 42);**A(若用户裁定)** = 按尺寸分组:32px 组 14 → 4 图(64×128)、48px 组 10 → 3 图(128×192),共 7 图 `npc0..6.png`(总数 43,偏离 D-81,须记 SUMMARY);**C(若用户裁定)** = 只出 32px:14 块 → 4 图 `npc0..3.png`(64×128),48px 延后(npc 素材不完整,记 SUMMARY)。
- **布局总则(D-84):** 每张目标图的**列数固定**(单帧组 = **4 列**;多帧组 = **帧数列**),**行数 ≤4 且按每图实际所需块数裁剪**(不足整 4 行时裁到 1–3 行);每张图 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`)。**按来源真实块数逐图计算行数,不得统一假定 4 行。** 单帧组「每图 16 格 = 4×4 满格」,末图不足 16 格时其行数 = `ceil(剩余格数 / 4)`;多帧组「每图 4 块 = 4 行(每行 1 块,占帧数列列)」,末图不足 4 块时其行数 = 剩余块数。
- 生成 A 组 `terrains` 单帧:源 `terrains.png`(32×1120,1 列 × 35 行,每格 32×32)→ 依序填入 4 列网格,每图 16 格,共 3 图 `terrains0..2.png`;`terrains0`/`terrains1` 各 4 行(128×128)、**`terrains2` 仅 3 格 → 1 行 → 128×32**。
- 生成 B 组(`animates` 并入 `terrains`,D-72③):源 `animates.png`(128×928,4 帧列 × 29 行,每格帧 32×32)→ 每图 4 帧列 × 4 行(4 块/图),共 8 图 `terrainsAnim0..7.png`;`terrainsAnim0..6` 各 4 行(128×128)、**`terrainsAnim7` 仅 1 块 → 1 行 → 128×32**。
- 生成 C 组 `items` 单帧:源 `items.png`(32×1984,1 列 × 62 行,每格 32×32)→ 4 列网格,每图 16 格,共 4 图 `items0..3.png`;`items0..2` 各 4 行、`items3` 有 14 格 → 占满 4 行,**四图均 128×128**。
- 生成 D 组 `enemy`(合并 `enemys`+`enemy48`,D-72①):源 `enemys.png`(64×2336,2 帧列 × 73 行,每格帧 32×32)与 `enemy48.png`(128×384,2 帧列 × 8 行,每格帧 64×48);多帧 → 每图 4 行(4 块/图)、每行 2 帧列;**按单元格尺寸分组**:32px 组 73 块 → 19 图(`enemy0..17` 各 4 行 64×128、**`enemy18` 仅 1 块 → 1 行 → 64×32**)、48px 组 8 块 → 2 图(`enemy19`/`enemy20` 各 4 行 128×192),共 21 图 `enemy0..20.png`(与 D-81 一致)。
- 生成 E 组 `npc`(合并 `npcs`+`npc48`,D-72①):源 `npcs.png`(64×448,2 帧列 × 14 行 = 14 个 NPC 块,各 2 帧 32×32)与 `npc48.png`(128×480,2 帧列 × 10 行 = 10 个 NPC 块,各 2 帧 64×48);多帧 → 每图 4 行(4 块/图)、每行 2 帧列。**按 Task 0 的 OI-3 裁定生成(各图行数按 D-84 逐图裁剪):**
- **默认 B(保持 D-81)** = 单元格统一到 64×48(14 个 NPC 块(各 2 帧 32×32)以透明留白嵌入 64×48 单元格,不重采样;与 10 个 NPC 块(各 2 帧 64×48)同布局)→ 24 块 ÷ 4 = 6 图 `npc0..5.png`(24÷4=6 满图,每图 4 行 → 均 128×192,`SPLIT-COUNT` 42)。
- **A(若用户裁定)** = 按尺寸分组:32px 组 14 块 → 4 图(`npc0..2` 各 4 行 64×128、**`npc3` 2 块 → 2 行 → 64×64**)、48px 组 10 块 → 3 图(`npc4`/`npc5` 各 4 行 128×192、**`npc6` 2 块 → 2 行 → 128×96**),共 7 图 `npc0..6.png`(总数 43,偏离 D-81,须记 SUMMARY)。
- **C(若用户裁定)** = 只出 32px:14 块 → 4 图 `npc0..3.png`(`npc0..2` 各 4 行 64×128、**`npc3` 2 块 → 2 行 → 64×64**),`npc48` 的 10 个 64×48 块延后(总数 40,npc 素材不完整,记 SUMMARY)。
- 复制(逐字节,不拆分):`public/project/autotiles/{autotile,autotile1,autotile2,autotile3}.png` → `src/content/texture/` 同名;`public/project/tilesets/magictower.png` → `src/content/texture/magictower.png`。
- 生成后断言:每张产出图 `width`/`height` 与预期一致;`materials` 拆分图总格数 ≥ 源图块数。
- 生成后自检:每张产出图 `width == 列数 × 帧宽` 且 `height == 行数 × 帧高`(`1 ≤ 行数 ≤ 4`);`materials` 拆分图总格数 ≥ 源图块数。
(2) **写 `src/content/texture/.list.jsonc`(D-80 已批准 schema):** 保留头部注释与分隔行;`// --- SYSTEM PREFIX END --- //` 后为严格 JSON 对象 `{ "textures": [ ... ] }`,条目:
- A/C 组 `{ "type": "grid", "source": "<文件名>", "map": [<新 num…>], "width": 32, "height": 32 }`。
- B/D/E 组 `{ "type": "rowAnimate", "source": "<文件名>", "map": [<新 num…>], "width": <帧宽>, "height": <帧高> }`(默认 B 下 npc 条目 `width`/`height` = 64/48;A 下 32px 条目 = 32/32、48px 条目 = 64/48)。
- B/D/E 组 `{ "type": "rowAnimate", "source": "<文件名>", "map": [<新 num…>], "width": <帧宽>, "height": <帧高> }`(默认 B 下 npc 条目 `width`/`height` = 64/48;A 下 32px 条目 = 32/32、48px 条目 = 64/48;C 下 npc 条目 = 32/32)。
- autotile `{ "type": "autotile", "source": "autotile*.png", "identifier": <20|151|152|153 对应新 num>, "autotileType": <AutotileType 数值> }`(参数名用已批准的 `autotileType`;默认 `autotile.png`/`autotile2.png`/`autotile3.png` 取 `Big3x4`=1,`autotile1.png` 暂取 `Big3x4`=1,OI-6)。
- tileset `{ "type": "tileset", "source": "magictower.png", "identifier": { "index": 0, "alias": "magictower" }, "cellWidth": 32, "cellHeight": 32 }`(默认候选,OI-6)。
- `source` 默认用相对 `texture/` 的文件名(OI-5);`map` 中每个 `num` 必须来自 Task 1 的 `tile.jsonc`;**集合覆盖约束:** `map`(所有 grid/rowAnimate 条目)∪ autotile 的 `identifier` 必须覆盖**每一处有贴图的图块 `num`**——即 `tile.jsonc` 中 `type ∈ {Terrain(2),Animate(3),Item(4),Enemy(5),Npc(6),Autotile(7)}` 的全部 `num`(Tile not present 时为空集)。
- `source` 默认用相对 `texture/` 的文件名(OI-5);`map` 中每个 `num` 必须来自 Task 1 的 `tile.jsonc`;**集合覆盖约束:** `map`(所有 grid/rowAnimate 条目)∪ autotile 的 `identifier` 必须覆盖**每一处有贴图的图块 `num`**——即 `tile.jsonc` 中 `type ∈ {Terrain(2),Animate(3),Item(4),Enemy(5),Npc(6),Autotile(7)}` 的全部 `num`(Tile not present 时为空集)。**裁剪只改变图的行数/尺寸,不改变 `map` 的条目集合与拆分图数。**
(3) **收口:** 不写 §8.2/§8.1 内容;不改 `client.jsonc`/`defaults/**`;不新增依赖、不改 `package.json`;脚本运行后 `src/content/texture/` 内文件与 `.list.jsonc` 互一致。
</action>
<reversibility rating="reversible">新增脚本 + 生成 `src/content/texture/**`;可删除生成物或 `git checkout` 回退;未触及任何既有源码与注释。</reversibility>
<verify>
<automated>node -e "const fs=require('fs');const dir='src/content/texture/';function ihdr(f){const b=fs.readFileSync(dir+f);if(b.readUInt32BE(0)!==0x89504e47)throw new Error(f+' not PNG');const w=b.readUInt32BE(16),h=b.readUInt32BE(20),bd=b[24],il=b[28];if(il!==0)throw new Error(f+' interlaced');if(bd!==8)throw new Error(f+' bitDepth '+bd);return{w,h}}const bad=[];const fixed={};for(let i=0;i<3;i++)fixed['terrains'+i+'.png']={w:128,h:128};for(let i=0;i<8;i++)fixed['terrainsAnim'+i+'.png']={w:128,h:128};for(let i=0;i<4;i++)fixed['items'+i+'.png']={w:128,h:128};for(const f of Object.keys(fixed)){if(!fs.existsSync(dir+f)){bad.push('missing '+f);continue}let m;try{m=ihdr(f)}catch(e){bad.push(e.message);continue}if(m.w!==fixed[f].w||m.h!==fixed[f].h)bad.push(f+' size '+m.w+'x'+m.h+' != '+fixed[f].w+'x'+fixed[f].h)}for(let i=0;i<=20;i++){const f='enemy'+i+'.png';if(!fs.existsSync(dir+f)){bad.push('missing '+f);continue}let m;try{m=ihdr(f)}catch(e){bad.push(e.message);continue}if(!((m.w===64&&m.h===128)||(m.w===128&&m.h===192)))bad.push(f+' size '+m.w+'x'+m.h+' not in 64x128/128x192')}let K=0;while(fs.existsSync(dir+'npc'+K+'.png'))K++;if(K!==6&&K!==7)bad.push('npc count '+K+' not in {6,7}');const npcS=[];for(let i=0;i<K;i++){let m;try{m=ihdr('npc'+i+'.png')}catch(e){bad.push(e.message);continue}npcS.push(m.w+'x'+m.h);if(!((m.w===64&&m.h===128)||(m.w===128&&m.h===192)))bad.push('npc'+i+'.png size '+m.w+'x'+m.h+' not in 64x128/128x192')}if(K===6&&npcS.some(s=>s!=='128x192'))bad.push('npc K=6 (OI-3=B unify) requires all 128x192; got '+npcS.join(','));const tiles=JSON.parse(fs.readFileSync('src/content/tile.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1]);const numSet=new Set(tiles.map(x=>x.num));let list;try{const raw=fs.readFileSync(dir+'.list.jsonc','utf8');const parts=raw.split('// --- SYSTEM PREFIX END --- //');if(parts.length!==2)throw new Error('bad marker');list=JSON.parse(parts[1])}catch(e){bad.push('.list.jsonc: '+e.message)}if(list){if(!Array.isArray(list.textures))bad.push('textures not array');else{const covered=new Set();for(const e of list.textures){if(!e||typeof e.source!=='string'){bad.push('entry bad source');continue}if(!fs.existsSync(dir+e.source))bad.push('source missing on disk: '+e.source);if(e.type==='grid'||e.type==='rowAnimate'){if(!Array.isArray(e.map))bad.push(e.source+' map not array');else for(const n of e.map){if(!numSet.has(n))bad.push(e.source+' map num not in tile.jsonc: '+n);covered.add(n)}}else if(e.type==='autotile'){if(typeof e.identifier!=='number'||!numSet.has(e.identifier))bad.push(e.source+' identifier not in tile.jsonc');if(typeof e.autotileType!=='number')bad.push(e.source+' autotileType not number');covered.add(e.identifier)}else if(e.type==='tileset'){if(!e.identifier||typeof e.identifier.alias!=='string')bad.push(e.source+' identifier bad')}else bad.push('unknown entry type '+e.type)}const required=new Set();for(const t of tiles)if([2,3,4,5,6,7].includes(t.type))required.add(t.num);for(const n of required)if(!covered.has(n))bad.push('uncovered textured tile num '+n)}}if(bad.length>0){console.error('TEXTURE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK textures: all groups present+8bit+non-interlaced+D-72 sizes (enemy/npc included); list covers every textured tile num')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "TEXTURE GATE FAILED"(拆分图缺失/非 PNG/隔行/位深≠8/尺寸不符预期(含任一 `enemy*`/`npc*` 不在 {64×128, 128×192},或默认 B 下 `npc` 非全 128×192)/ `npc` 文件数 ∉ {6,7} / `.list.jsonc` 标记缺失或解析失败 / `textures` 非数组 / 条目 `type` 未知 / `source` 文件在磁盘不存在 / `map` 或 autotile `identifier` 的 `num` 不在 `tile.jsonc` / `autotileType` 非数值 / tileset `identifier.alias` 缺失 / **存在未被任何 `map` 或 autotile `identifier` 覆盖的有贴图图块 `num`**)</fails_when>
<automated>node -e "const fs=require('fs');const dir='src/content/texture/';function ihdr(f){const b=fs.readFileSync(dir+f);if(b.readUInt32BE(0)!==0x89504e47)throw new Error(f+' not PNG');const w=b.readUInt32BE(16),h=b.readUInt32BE(20),bd=b[24],il=b[28];if(il!==0)throw new Error(f+' interlace');if(bd!==8)throw new Error(f+' bitDepth '+bd);return{w,h}}function srcWH(f){const b=fs.readFileSync('public/project/materials/'+f);return{w:b.readUInt32BE(16),h:b.readUInt32BE(20)}}const bad=[];function sf(prefix,cells,cw,ch){const cols=4,per=16,sheets=Math.ceil(cells/per),out={};for(let i=0;i<sheets;i++){const c=Math.min(per,cells-per*i),rows=Math.ceil(c/cols);out[prefix+i+'.png']={w:cols*cw,h:rows*ch,rows,cols}}return out}function mf(prefix,blocks,frameCols,fw,fh,start){const sheets=Math.ceil(blocks/4),out={};for(let i=0;i<sheets;i++){const b=Math.min(4,blocks-4*i),rows=b;out[prefix+(start+i)+'.png']={w:frameCols*fw,h:rows*fh,rows,cols:frameCols}}return{out,next:start+sheets}}const S={terrains:srcWH('terrains.png'),animates:srcWH('animates.png'),items:srcWH('items.png'),enemys:srcWH('enemys.png'),enemy48:srcWH('enemy48.png'),npcs:srcWH('npcs.png'),npc48:srcWH('npc48.png')};const tCells=(S.terrains.w/32)*(S.terrains.h/32),iCells=(S.items.w/32)*(S.items.h/32),aBlocks=S.animates.h/32,eBlocks=S.enemys.h/32,e48Blocks=S.enemy48.h/48,nBlocks=S.npcs.h/32,n48Blocks=S.npc48.h/48;const e1=mf('enemy',eBlocks,2,32,32,0),e2=mf('enemy',e48Blocks,2,64,48,e1.next);const exp=Object.assign({},sf('terrains',tCells,32,32),sf('items',iCells,32,32),mf('terrainsAnim',aBlocks,4,32,32,0).out,e1.out,e2.out);let K=0;while(fs.existsSync(dir+'npc'+K+'.png'))K++;if([4,6,7].indexOf(K)<0)bad.push('npc count '+K+' not in {4,6,7}');if(K===6)Object.assign(exp,mf('npc',nBlocks+n48Blocks,2,64,48,0).out);else if(K===4)Object.assign(exp,mf('npc',nBlocks,2,32,32,0).out);else if(K===7){const a32=mf('npc',nBlocks,2,32,32,0);Object.assign(exp,a32.out,mf('npc',n48Blocks,2,64,48,a32.next).out)}for(const f of Object.keys(exp)){const e=exp[f];if(e.rows<1||e.rows>4)bad.push(f+' computed rows out of range: '+e.rows);if(!fs.existsSync(dir+f)){bad.push('missing '+f);continue}let m;try{m=ihdr(f)}catch(err){bad.push(err.message);continue}if(m.w!==e.w||m.h!==e.h)bad.push(f+' = '+m.w+'x'+m.h+' != expected '+e.w+'x'+e.h+' (cols='+e.cols+', rows='+e.rows+')')}const gen=fs.readdirSync(dir).filter(f=>/^(terrainsAnim|terrains|items|enemy|npc)\d+\.png$/.test(f));for(const f of gen)if(!exp[f])bad.push('unexpected extra sheet '+f);const tiles=JSON.parse(fs.readFileSync('src/content/tile.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1]);const tnums=new Set(tiles.map(x=>x.num));const textured=new Set(tiles.filter(x=>[2,3,4,5,6,7].indexOf(x.type)>=0).map(x=>x.num));const lp=fs.readFileSync('src/content/texture/.list.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //');if(lp.length!==2)bad.push('.list.jsonc: marker');else{let cfg;try{cfg=JSON.parse(lp[1])}catch(e){bad.push('.list.jsonc parse '+e.message)}if(cfg){if(!Array.isArray(cfg.textures))bad.push('textures not array');else{const covered=new Set();for(const e of cfg.textures){if(['grid','rowAnimate','autotile','tileset'].indexOf(e.type)<0){bad.push('unknown entry type '+e.type);continue}if(!fs.existsSync(dir+e.source))bad.push('source missing: '+e.source);if(e.type==='grid'||e.type==='rowAnimate'){if(!Array.isArray(e.map))bad.push(e.source+' map not array');else for(const n of e.map){if(!tnums.has(n))bad.push(e.source+' map num '+n+' not in tile.jsonc');covered.add(n)}}else if(e.type==='autotile'){if(typeof e.autotileType!=='number')bad.push(e.source+' autotileType not number');if(typeof e.identifier!=='number'||!tnums.has(e.identifier))bad.push(e.source+' identifier '+e.identifier+' not in tile.jsonc');else covered.add(e.identifier)}else if(e.type==='tileset'){if(!e.identifier||typeof e.identifier.alias!=='string')bad.push(e.source+' tileset identifier.alias missing')}}for(const n of textured)if(!covered.has(n))bad.push('textured tile num '+n+' not covered')}}}if(bad.length>0){console.error('TEXTURE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK textures: '+Object.keys(exp).length+' split sheets (per-sheet cols x frameW / rows x frameH, rows<=4) + copies; list covers all textured nums')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "TEXTURE GATE FAILED"(拆分图缺失/非 PNG/隔行/位深≠8/尺寸不等于按 D-84 逐图推导的「列数×帧宽 × 行数×帧高」(含任一 `enemy*`/`npc*`;末图可按实际行数裁剪,如 128×32 / 64×32)/ 出现多余拆分图 / `npc` 文件数 `K` ∉ {4,6,7} / 尺寸与 OI-3 取值不符(B=24 块满图均 128×192;A=`npc0..2` 64×128+`npc3` 64×64+`npc4`/`npc5` 128×192+`npc6` 128×96;C=`npc0..2` 64×128+`npc3` 64×64)/ `.list.jsonc` 标记缺失或解析失败 / `textures` 非数组 / 条目 `type` 未知 / `source` 文件在磁盘不存在 / `map` 或 autotile `identifier` 的 `num` 不在 `tile.jsonc` / `autotileType` 非数值 / tileset `identifier.alias` 缺失 / **存在未被任何 `map` 或 autotile `identifier` 覆盖的有贴图图块 `num`**)</fails_when>
<automated>node -e "const fs=require('fs');const dir='src/content/texture/';const need=['autotile.png','autotile1.png','autotile2.png','autotile3.png','magictower.png'];const bad=[];for(const f of need){if(!fs.existsSync(dir+f))bad.push('missing copied '+f)}const a=fs.readFileSync('public/project/autotiles/autotile.png');const b=fs.existsSync(dir+'autotile.png')?fs.readFileSync(dir+'autotile.png'):null;if(b&&!a.equals(b))bad.push('autotile.png not byte-identical');const a2=fs.readFileSync('public/project/autotiles/autotile2.png');const b2=fs.existsSync(dir+'autotile2.png')?fs.readFileSync(dir+'autotile2.png'):null;if(b2&&!a2.equals(b2))bad.push('autotile2.png not byte-identical');const ta=fs.readFileSync('public/project/tilesets/magictower.png');const tb=fs.existsSync(dir+'magictower.png')?fs.readFileSync(dir+'magictower.png'):null;if(tb&&!ta.equals(tb))bad.push('magictower.png not byte-identical');if(bad.length>0){console.error('COPY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK autotiles (4) + tileset (1) copied byte-identical (not re-encoded)')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "COPY GATE FAILED"(autotile/tileset 复制缺失,或未逐字节复制而重编码)</fails_when>
<automated>node -e "const fs=require('fs');const p='script/migrateProjectTextures.ts';const bad=[];if(!fs.existsSync(p))bad.push('script missing');else{const t=fs.readFileSync(p,'utf8');if(!/node:zlib|require\(['\\u0022]zlib['\\u0022]\)/.test(t))bad.push('script does not use node zlib');if(/from ['\\u0022](pngjs|sharp|canvas|jimp)/.test(t))bad.push('script imports a forbidden png lib')}const pkg=JSON.parse(fs.readFileSync('package.json','utf8'));const all=Object.assign({},pkg.dependencies||{},pkg.devDependencies||{});for(const n of ['pngjs','sharp','canvas','jimp'])if(all[n])bad.push('dependency added: '+n);if(bad.length>0){console.error('DEP GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK zero-dependency: script uses node zlib; no png lib added to package.json')"</automated>
@ -329,11 +340,11 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
</verify>
<acceptance_criteria>
- `script/migrateProjectTextures.ts` 存在(小驼峰,符合 dev.md),仅用 Node `zlib` + `fs`(零外部依赖),未改 `package.json`
- `src/content/texture/` 含 42 张拆分图(默认 OI-3=B:`terrains0..2` / `terrainsAnim0..7` / `items0..3` / `enemy0..20` / `npc0..5`)与 5 张逐字节复制素材;**每张 PNG**(含全部 `enemy*`/`npc*`)均为非隔行 8 位、尺寸 ∈ D-72 方案(`terrains*`/`terrainsAnim*`/`items*`=128×128;`enemy*`/`npc*` ∈ {64×128,128×192};默认 B 下 `npc` 全为 128×192)
- `src/content/texture/` 含拆分图(默认 OI-3=B:42 张 = `terrains0..2` / `terrainsAnim0..7` / `items0..3` / `enemy0..20` / `npc0..5`;OI-3=A 时 43 张 `npc0..6`;OI-3=C 时 40 张 `npc0..3`)与 5 张逐字节复制素材;**每张 PNG**(含全部 `enemy*`/`npc*`)均为非隔行 8 位、且尺寸 = **按 D-84 逐图推导**的「`列数 × 帧宽` × `行数 × 帧高`」(`1 ≤ 行数 ≤ 4`,列数固定;末图可裁剪,如 `terrains2`/`terrainsAnim7`=128×32、`enemy18`=64×32);`npc` 图数 `K ∈ {4,6,7}` 且尺寸与 OI-3 取值自洽(B=24 块满图均 128×192;A=`npc3` 64×64、`npc6` 128×96;C=`npc3` 64×64)
- `texture/.list.jsonc` 为 D-80 已批准 schema(`textures` 数组,条目 `type` ∈ {`grid`,`rowAnimate`,`autotile`,`tileset`});每个 `source` 文件存在;`map`/`identifier` 的 `num` 全部来自 `tile.jsonc`;**`map ∪ autotile identifier` 覆盖每一处有贴图的图块 `num`**
- 分隔标记后为严格 JSON;头部注释保留;未写 §8.1/§8.2 内容
</acceptance_criteria>
<done>42 张拆分图(默认 B)+ 5 张复制素材生成;`texture/.list.jsonc` 落盘且 `source`/`map`/`identifier` 与 `tile.jsonc` 一致且覆盖全部有贴图图块;零依赖门禁全绿</done>
<done>拆分图(默认 B=42;A=43 `npc0..6`;C=40 `npc0..3`)+ 5 张复制素材生成;**每张图尺寸按 D-84 逐图实际行数(≤4)经门禁断言**(不硬编码 4 行尺寸);`texture/.list.jsonc` 落盘且 `source`/`map`/`identifier` 与 `tile.jsonc` 一致且覆盖全部有贴图图块;`npc` 图数按 OI-3 取值经门禁断言;零依赖门禁全绿</done>
</task>
<task type="auto">
@ -342,7 +353,7 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
<files>src/content/maps/empty.jsonc, src/content/maps/MT0.jsonc, src/content/maps/sample0.jsonc, src/content/maps/sample1.jsonc, src/content/maps/sample2.jsonc, src/content/maps/.list.jsonc, .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md, .planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md</files>
<read_first>
- .planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-AUDIT.md(§6.1 楼层登记、§6.2 map 字段分类、§8.1 U-16 无目标字段清单、§8.2 U-15/U-16b 待定)
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-76 / D-78 / D-82 / D-83)
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-76 / D-78 / D-82 / D-83 / D-84)
- packages-user/data-common/src/store/types.ts:260-271(`IMapRawData`);packages-user/data-base/src/map/mapState.ts:40-95(`fromRaw`:各层等长、`length % width === 0`)
- src/content/defaults/map.jsonc(`@required` = `floorId`;`map` / `layerAlias` / `events` 骨架);src/content/maps/MT0.jsonc(现状样例);src/content/maps/.list.jsonc(现状 `["MT0"]`)
- src/content/tile.jsonc(Task 1 产出:新 `num` 与 `id`,用于旧→新重映射)
@ -350,35 +361,35 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
- dev.md(Markdown 文档文件名=连字符命名法、注释规范);AGENTS.md(不臆造、命名先报备)
</read_first>
<action>
(1) **写 5 份楼层 `src/content/maps/{empty,MT0,sample0,sample1,sample2}.jsonc`(D-82):** 保留头部 `// Type:` 注释与分隔行;`// --- SYSTEM PREFIX END --- //` 后为严格 JSON `IMapRawData`:
(1) **写 5 份楼层 `src/content/maps/{empty,MT0,sample0,sample1,sample2}.jsonc`(D-82):** 保留头部 `// Type:` 注释与分隔行;`// --- SYSTEM PREFIX END --- //` 后为严格 JSON `IMapRawData`(**顶层键集精确 = `floorId`/`width`/`map`/`layerAlias`/`events`,恰 5 键**):
- `floorId` = 楼层 id;`width` = 旧 `width`(13 或 26)。
- `map`:把旧二维 `map`(`[row][col]`)按**行主序展平**为**一张图层数组**(`length = width × height`),图层键默认 `0`(OI-4);数组内每个值经「旧 `num`→`id`→新 `num`」重映射(`0` 保持 `0`)。
- `layerAlias` = `{}`(默认);`events` = `{}`(§8.2 U-15 不移植)。
- **不写** §8.2 的 `bgmap`/`fgmap`/`bg2map`/`fg2map`(U-16b)与 §8.1 的 `title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`。
- `layerAlias` = `{}`(默认);`events` = `{}`(默认骨架;§8.2 U-15 的旧事件数据不移植)。
- **不写** §8.2 的 `bgmap`/`fgmap`/`bg2map`/`fg2map`(U-16b)与 §8.1 的 `title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`。**顶层只写上述 5 键,多余键由门禁精确键集断言拦截。**
- **硬约束:** 每个图层数组长度必须 = `width × height`(`mapState.fromRaw` 要求 `length % width === 0` 且各层等长)。
(2) **写 `src/content/maps/.list.jsonc`:** 保留头部注释与分隔行;`// --- SYSTEM PREFIX END --- //` 后为严格 JSON 字符串数组,含 5 个 id:`["empty","MT0","sample0","sample1","sample2"]`(不带 `.jsonc` 后缀)。
(3) **写 `09-DATA-MIGRATION-UNMAPPED.md`(路径/命名待批准,OI-1;仅 §8.1):** 采用连字符命名(Markdown 文档文件名规范)、裸 LF。内容仅收录「无目标字段 → 只记录、不移植」项,按维度分组并附「所查接口与缺失字段」依据(引用 `09-DATA-MIGRATION-AUDIT.md` §8.1):tile(`canBreak`/`script`/`doorInfo`/`animate`/`name`/`faceIds`/`autotileConnection`)、enemy(`faceIds`)、item(`hideInReplay`)、map(`title`/`name`/`height`/`firstArrive`/`eachArrive`/`parallelDo`/`changeFloor`/`afterBattle`/`afterGetItem`/`afterOpenDoor`/`beforeBattle`/`cannotMove`/`cannotMoveIn`/`autoEvent`/`images`/`bgm`/`weather`/`ratio`/`defaultGround`/`canFlyTo`/`canFlyFrom`/`canUseQuickShop`/`cannotViewMap`/`upFloor`)。**不得**收录 §8.2 待定项——它们保留在盘点台账。
(4) **写 `09-19-SUMMARY.md`:** 记录本计划范围与逐类映射口径、`num` 策略 A 的分段/取值、素材产出(拆分图数量与 **OI-3 最终处置**:默认 B=42 张 `npc0..5`,或 A=43 张 `npc0..6`,或 C=4 张 `npc0..3`,及 5 张复制素材)、地图图层键与旧→新重映射口径、消费接线未实现(D-70/D-83)、门禁结果、`open_items`(OI-1..OI-7 的最终处置)。
(4) **写 `09-19-SUMMARY.md`:** 记录本计划范围与逐类映射口径、`num` 策略 A 的分段/取值、`text` 缺失 13 条的 `""` 处置、素材产出(拆分图数量、**每张图的实际行数与尺寸(含裁剪末图,如 `terrains2`/`terrainsAnim7`=128×32、`enemy18`=64×32)**、**OI-3 最终处置**(默认 B=42 张 `npc0..5` 均 4 行 128×192;或 A=43 张 `npc0..6`(含 `npc3` 64×64、`npc6` 128×96);或 C=40 张 `npc0..3`(含 `npc3` 64×64);及 5 张复制素材;C 下需注明 48px 变体延后)、**D-84 布局口径(列数固定、行数 ≤4 且按每图实际裁剪)与「§2 严格 4 行读法已被 D-84 取代」说明**、地图图层键与旧→新重映射口径、消费接线未实现(D-70/D-83)、门禁结果(含 enemy/item/map 精确键集断言与 D-84 逐图尺寸断言)、`open_items`(OI-1..OI-7 的最终处置)。
(5) **收口:** 不改任何源码 / `public/project/**` / `ROADMAP.md` / `STATE.md` / 既有台账与计划;提交前 `git status` 逐条核对,仅暂存本计划 `files_modified` 条目(避开用户并发改动 D-56)。
</action>
<reversibility rating="reversible">新增/替换 `src/content/maps/**` 与两份文档;`git checkout` / 删除可回退;未触及任何既有源码与注释。</reversibility>
<verify>
<automated>node -e "const fs=require('fs');const dir='src/content/maps/';const bad=[];const tiles=JSON.parse(fs.readFileSync('src/content/tile.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1]);const numSet=new Set(tiles.map(x=>x.num));const ids=['empty','MT0','sample0','sample1','sample2'];for(const id of ids){const p=dir+id+'.jsonc';if(!fs.existsSync(p)){bad.push('missing '+id+'.jsonc');continue}const parts=fs.readFileSync(p,'utf8').split('// --- SYSTEM PREFIX END --- //');if(parts.length!==2){bad.push(id+': marker');continue}let m;try{m=JSON.parse(parts[1])}catch(e){bad.push(id+': parse '+e.message);continue}if(m.floorId!==id)bad.push(id+': floorId '+m.floorId);if(typeof m.width!=='number')bad.push(id+': width');if(typeof m.layerAlias!=='object')bad.push(id+': layerAlias');if(typeof m.events!=='object')bad.push(id+': events');const keys=Object.keys(m.map||{});if(keys.length<1)bad.push(id+': no map layers');for(const k of keys){const a=m.map[k];if(!Array.isArray(a)){bad.push(id+': layer '+k+' not array');continue}if(a.length%m.width!==0)bad.push(id+': layer '+k+' length '+a.length+' % width '+m.width+' != 0');for(const v of a){if(v!==0&&!numSet.has(v)){bad.push(id+': value '+v+' not a tile num');break}}}}let list;try{list=JSON.parse(fs.readFileSync(dir+'.list.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1])}catch(e){bad.push('.list.jsonc: '+e.message)}if(list&&(!Array.isArray(list)||list.length!==5||!ids.every(x=>list.includes(x))))bad.push('.list.jsonc not the 5 floor ids');const up='.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md';if(!fs.existsSync(up))bad.push('unmapped doc missing');else{const u=fs.readFileSync(up,'utf8');for(const tok of ['doorInfo','faceIds','hideInReplay','firstArrive','changeFloor','defaultGround']){if(u.indexOf(tok)<0)bad.push('unmapped missing section-8.1 token: '+tok)}for(const tok of ['canPass','zoneSquare','itemEffect','bgmap']){if(u.indexOf(tok)>=0)bad.push('unmapped contains a section-8.2 token: '+tok)}}const sm='.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md';if(!fs.existsSync(sm))bad.push('SUMMARY missing');if(bad.length>0){console.error('MAP GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK maps: 5 floors, each layer length % width == 0, values in {0} u tile nums; list=5 ids; unmapped doc only section-8.1; summary present')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "MAP GATE FAILED"(楼层文件缺失/标记或解析失败 / `floorId` 不符 / `map` 无图层或图层非数组 / 图层长度不能被 `width` 整除 / 数值非 `0` 且非有效 `tile` `num` / `.list.jsonc` 非 5 个 id / unmapped 文档缺失或含 §8.2 项 / SUMMARY 缺失)</fails_when>
<automated>node -e "const fs=require('fs');const dir='src/content/maps/';const bad=[];const tiles=JSON.parse(fs.readFileSync('src/content/tile.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1]);const numSet=new Set(tiles.map(x=>x.num));const ids=['empty','MT0','sample0','sample1','sample2'];for(const id of ids){const p=dir+id+'.jsonc';if(!fs.existsSync(p)){bad.push('missing '+id+'.jsonc');continue}const parts=fs.readFileSync(p,'utf8').split('// --- SYSTEM PREFIX END --- //');if(parts.length!==2){bad.push(id+': marker');continue}let m;try{m=JSON.parse(parts[1])}catch(e){bad.push(id+': parse '+e.message);continue}const mkeys=Object.keys(m).sort().join(',');if(mkeys!=='events,floorId,layerAlias,map,width')bad.push(id+': forbidden/extra top-level keys: '+mkeys);if(m.floorId!==id)bad.push(id+': floorId '+m.floorId);if(typeof m.width!=='number')bad.push(id+': width');if(typeof m.layerAlias!=='object')bad.push(id+': layerAlias');if(typeof m.events!=='object')bad.push(id+': events');const keys=Object.keys(m.map||{});if(keys.length<1)bad.push(id+': no map layers');for(const k of keys){const a=m.map[k];if(!Array.isArray(a)){bad.push(id+': layer '+k+' not array');continue}if(a.length%m.width!==0)bad.push(id+': layer '+k+' length '+a.length+' % width '+m.width+' != 0');for(const v of a){if(v!==0&&!numSet.has(v)){bad.push(id+': value '+v+' not a tile num');break}}}}let list;try{list=JSON.parse(fs.readFileSync(dir+'.list.jsonc','utf8').split('// --- SYSTEM PREFIX END --- //')[1])}catch(e){bad.push('.list.jsonc: '+e.message)}if(list&&(!Array.isArray(list)||list.length!==5||!ids.every(x=>list.indexOf(x)>=0)))bad.push('.list.jsonc not the 5 floor ids');const up='.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md';if(!fs.existsSync(up))bad.push('unmapped doc missing');else{const u=fs.readFileSync(up,'utf8');for(const tok of ['doorInfo','faceIds','hideInReplay','firstArrive','changeFloor','defaultGround']){if(u.indexOf(tok)<0)bad.push('unmapped missing token: '+tok)}if(/U-15|bgmap|fgmap/.test(u))bad.push('unmapped doc must NOT contain §8.2 tokens (U-15/bgmap/fgmap)')}const sum='.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md';if(!fs.existsSync(sum))bad.push('SUMMARY missing');if(bad.length>0){console.error('MAP GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK maps: 5 floors (5-key top level, layer length % width==0, nums in tile) + list + unmapped(§8.1 only) + SUMMARY')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "MAP GATE FAILED"(楼层文件缺失/标记或解析失败 / 顶层键集非 5 键或含 §8.1/§8.2 键 / `floorId` 不符 / `map` 无图层或图层非数组 / 图层长度不能被 `width` 整除 / 数值非 `0` 且非有效 `tile` `num` / `.list.jsonc` 非 5 个 id / unmapped 文档缺失或含 §8.2 项 / SUMMARY 缺失)</fails_when>
<!-- plan-criteria-allow: R4 - SCOPE GATE 内的 git status 由 node execSync 调用并以 try/catch 捕获失败(失败即非零退出),同行的 `|` 属 JS 正则/`||` 运算符,非 shell 管道阶段 -->
<automated>node -e "const {execSync}=require('child_process');let rawStatus;try{rawStatus=execSync('git status --porcelain',{encoding:'utf8'})}catch(e){console.error('SCOPE GATE FAILED :: git status failed :: '+e.message);process.exit(1)}const status=rawStatus.split(/\r?\n/).filter(Boolean);const bad=[];for(const l of status){const p=l.slice(3).trim().replace(/^\u0022|\u0022$/g,'');if(p==='AGENTS.md'||p==='.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md')continue;if(p==='ROADMAP.md'||p.endsWith('/ROADMAP.md')||p.endsWith('STATE.md')||p.endsWith('/09-DATA-MIGRATION-AUDIT.md'))bad.push('forbidden planning edit: '+p);else if(p.startsWith('packages/')||p.startsWith('packages-user/')||p.startsWith('public/project/'))bad.push(p);else if(p.startsWith('src/')&&!p.startsWith('src/content/'))bad.push(p);else if(/\.(ts|tsx)$/.test(p)&&p!=='script/migrateProjectTextures.ts'&&!p.startsWith('src/content/'))bad.push(p)}if(bad.length>0){console.error('SCOPE GATE FAILED :: out-of-scope changes :: '+bad.join(' | '));process.exit(1)}console.log('OK scope: only src/content/** + migration script + planning docs changed (user concurrent changes ignored)')"</automated>
<fails_when>non-zero exit, 或 stderr 含 "SCOPE GATE FAILED"(本计划执行期改动了 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`/盘点台账,或新增了 `files_modified` 外的 `.ts`/`.tsx`)</fails_when>
</verify>
<acceptance_criteria>
- 5 份楼层 jsonc 齐备(`empty`/`MT0`/`sample0`/`sample1`/`sample2`),`floorId`/`width`/`map`/`layerAlias`/`events` 就位;每个图层数组长度 = `width`×`height`;数值均为 `0` 或有效新 `num`
- 5 份楼层 jsonc 齐备(`empty`/`MT0`/`sample0`/`sample1`/`sample2`),顶层键集恰为 `floorId`/`width`/`map`/`layerAlias`/`events`(5 键,无 §8.1/§8.2 键);每个图层数组长度 = `width`×`height`;数值均为 `0` 或有效新 `num`
- `maps/.list.jsonc` 恰 5 个 id;旧 `num` 已重映射为新 `num`
- `09-DATA-MIGRATION-UNMAPPED.md` 存在且仅含 §8.1 项(含 tile/enemy/item/map 四组,含 `src`/接口依据),不含 §8.2 项
- `09-19-SUMMARY.md` 已写(含范围、`num` 分配、素材产出与 **OI-3 处置**、地图口径、门禁、`open_items`)
- 未改任何源码 / `public/project/**` / 既有台账与计划;未写 §8.2/§8.1 进数据文件
- `09-19-SUMMARY.md` 已写(含范围、`num` 分配、`text` 缺失 13 条的 `""` 处置、素材产出(含**每张图实际行数与裁剪末图尺寸**)与 **OI-3 处置**、**D-84 布局口径**、地图口径、门禁、`open_items`)
- 未改任何源码 / `public/project/**` / 既有台账与计划;未写 §8.2/§8.1 进数据文件(精确键集断言)
</acceptance_criteria>
<done>5 楼层 + `maps/.list.jsonc` + unmapped 文档 + SUMMARY 落盘;地图/作用域门禁全绿</done>
</task>
@ -392,52 +403,54 @@ Phase 9 · 任务 9 · 第 19 个计划 —— **旧引擎数据基础移植**
|----------|-------------|
| 写入边界 | 本增量是写操作;只允许写 `files_modified` 白名单(`src/content/**` 数据与 PNG + 迁移脚本 + 两份规划文档) |
| 范围边界 | 不得改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`/既有台账与计划 |
| 判定边界 | §8.2「待定」与 §8.1「无目标字段」均不得落入数据文件;§8.1 只入 unmapped 文档,§8.2 只留台账 |
| 判定边界 | §8.2「待定」与 §8.1「无目标字段」均不得落入数据文件;§8.1 只入 unmapped 文档,§8.2 只留台账;各实体/地图以精确键集断言兜底 |
| `num` 一致性边界 | `id`↔`num` 跨 `tile`/`enemy`/`item`/`texture.list`/`maps` 五处必须一致(D-79) |
| 贴图布局边界 | 拆分图列数固定(单帧 4 / 多帧 = 帧数列),行数 ≤4 且按每图实际块数裁剪;门禁须按**实际行数**推导尺寸(D-84),不得硬编码 4 行尺寸 |
| 贴图覆盖边界 | `texture/.list.jsonc` 的 `map ∪ autotile identifier` 必须覆盖每一处有贴图的图块 `num` |
| 决策边界 | OI-3(48px 合并)必须由用户显式裁定(默认 B 保持 D-81),不得由「可以执行」隐含放行 |
| 依赖边界 | 不得新增依赖;PNG 生成须零依赖 Node `zlib` |
| 命名边界 | 未批准命名(脚本路径、unmapped 文档路径、`source` 形态、图层键、48px 方案)先入 `open_items` 待批准(D-73 / AGENTS.md) |
| 提交边界 | 不得把用户并发改动(D-56,`AGENTS.md` / `09-CONTEXT.md`)纳入暂存 / 提交 |
**威胁级别说明(security_enforcement = ASVS L1,block on high):** 本计划为**本地数据/素材移植**,不执行第三方代码、不新增依赖、不引入网络面,**不存在 `high` 级威胁**;下列寄存器均为 `medium` 及以下,并逐条给出缓解。
**威胁级别说明(security_enforcement = ASVS L1,block on high):** 本计划为**本地数据/素材移植**,不执行第三方代码、不新增依赖、不引入网络面,**不存在未缓解的 `high` 级威胁**(寄存器 `T-09-19-SC` 虽标记 `high`,已由「不新增依赖 + `DEP GATE`」缓解);其余寄存器均为 `medium` 及以下,并逐条给出缓解。
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-19-01 | Tampering | 越界写入生产源码 / 数据(`packages*/`、`src/`(除 `src/content/**`)、`public/project/**`) | medium | mitigate | `files_modified` 白名单 + prohibitions + Task 3 `SCOPE GATE`(`git status` 逐条校验);Task 2 `DEP GATE` 校验 `package.json` 未新增依赖 |
| T-09-19-02 | Tampering | 移植了 §8.2「待定」或 §8.1「无目标字段」,或 unmapped 文档混入 §8.2 | medium | mitigate | Task 1 门禁校验 tile 键集恰为 6 键(挡 §8.1/§8.2 字段);Task 3 门禁校验 unmapped 文档含 §8.1 token 且不含 §8.2 token |
| T-09-19-02 | Tampering | 移植了 §8.2「待定」或 §8.1「无目标字段」,或 unmapped 文档混入 §8.2 | medium | mitigate | Task 1 门禁按**精确键集**校验 tile(6 键)/ enemy(3 键)/ item(5 键)——任何 §8.1/§8.2 键即失败;Task 3 门禁按**精确键集**校验 map 顶层(5 键)并校验 unmapped 文档含 §8.1 token 且不含 §8.2 token |
| T-09-19-03 | Tampering | `id`↔`num` 跨文件不一致(贴图 `map`/地图数值/实体 `num` 失配) | medium | mitigate | Task 1 建立唯一基准表;Task 2 门禁校验 `list` 的 `map`/`identifier` `num` 均在 `tile.jsonc`;Task 3 门禁校验地图数值均为 `0` 或有效 `num` |
| T-09-19-04 | Tampering | 生成 PNG 尺寸 / 数量偏离 D-72 方案或损坏(编造像素) | medium | mitigate | Task 2 `TEXTURE GATE` 校验**全部**产出图(含 `enemy*`/`npc*`)为非隔行 8 位且尺寸符合预期;`COPY GATE` 校验 autotile/tileset 逐字节复制 |
| T-09-19-04 | Tampering | 生成 PNG 尺寸 / 数量偏离 D-72/D-84 方案或损坏(编造像素) | medium | mitigate | Task 2 `TEXTURE GATE` 从**来源 PNG 块数与列数**逐图推导期望 `width = 列数 × 帧宽`、`height = 行数 × 帧高`(`1 ≤ 行数 ≤ 4`),对**全部**产出图(含 `enemy*`/`npc*`)校验非隔行 8 位且尺寸相符;`npc` 图数 `K` 与 OI-3 取值联动断言;`COPY GATE` 校验 autotile/tileset 逐字节复制 |
| T-09-19-05 | Tampering | 贴图列表漏项:某有贴图图块未被任何 `map`/autotile `identifier` 覆盖 | medium | mitigate | Task 2 `TEXTURE GATE` 集合覆盖断言:`map ∪ autotile identifier ⊇` 所有 `type ∈ {2,3,4,5,6,7}` 的图块 `num` |
| T-09-19-06 | Tampering | 地图结构非法(图层长度非 `width×height` 倍数、含越界 `num`) | medium | mitigate | Task 3 `MAP GATE` 校验每层 `length % width === 0` 且数值 ∈ {0} ∪ `tile` nums |
| T-09-19-06 | Tampering | 地图结构非法(图层长度非 `width×height` 倍数、含越界 `num`、顶层混入 §8.1/§8.2 键) | medium | mitigate | Task 3 `MAP GATE` 校验顶层键集恰 5 键、每层 `length % width === 0` 且数值 ∈ {0} ∪ `tile` nums |
| T-09-19-07 | Elevation of Privilege | 未经批准即执行 / 使用未报备命名 / 新增依赖 | medium | mitigate | Task 0 `blocking-human`(须含 OI-3 取值);命名候选入 `open_items`;Task 2 `DEP GATE` 禁依赖新增 |
| T-09-19-08 | Elevation of Privilege | 48px 合并处置被「可以执行」隐含放行,导致静默偏离 D-81 | medium | mitigate | Task 0 明确要求 OI-3 取值(A/B/C),默认 B 保持 D-81 的 6 图;`TEXTURE GATE` 校验默认 B 下 `npc` 全 128×192、K=6 |
| T-09-19-08 | Elevation of Privilege | 48px 合并处置被「可以执行」隐含放行,导致静默偏离 D-81 | medium | mitigate | Task 0 明确要求 OI-3 取值(A/B/C),默认 B 保持 D-81 的 6 图;`TEXTURE GATE` 按取值断言 `K ∈ {4,6,7}` 及对应**实际行数尺寸**(B=24 块满图均 128×192;A=`npc3` 64×64、`npc6` 128×96;C=`npc3` 64×64) |
| T-09-19-09 | Tampering | 破坏加载:`// --- SYSTEM PREFIX END --- //` 之后写入注释 / 尾逗号(`JSON.parse` 失败) | medium | mitigate | 各门禁均以 `split(marker)` 后 `JSON.parse` 校验;prohibitions 明列严格 JSON |
| T-09-19-10 | Information Disclosure | 改动既有 `// Type:` 头部注释 / 既有注释(AGENTS.md 禁改既有注释) | low | mitigate | Task 1 保留头部门禁;prohibitions 明列不改 `// Type:` 前缀与既有注释 |
| T-09-19-11 | Repudiation | 未跑门禁即收口 / 未记录 TWO PASS 结果与 `open_items` | low | mitigate | Task 1/2/3 各有自动化门禁 + `09-19-SUMMARY.md` 记录范围、OI-3 处置与 `open_items` |
| T-09-19-11 | Repudiation | 未跑门禁即收口 / 未记录 TWO PASS 结果与 `open_items` | low | mitigate | Task 1/2/3 各有自动化门禁 + `09-19-SUMMARY.md` 记录范围、`text` 处置、OI-3 处置、D-84 布局口径与 `open_items` |
| T-09-19-12 | Tampering | 门禁硬编码 4 行尺寸(64×128 / 128×192)导致裁剪末图被误判为缺失/失败 | medium | mitigate | Task 2 `TEXTURE GATE` 从来源块数**逐图推导**行数与尺寸(不硬编码 4 行);prohibitions 明列「贴图尺寸不硬编码(D-84)」 |
| T-09-19-SC | Tampering | npm/pip/cargo installs | high | mitigate | 本增量**不新增依赖**;Task 2 `DEP GATE` 阻断任何 PNG 依赖写入 `package.json`;如需新增依赖须回 Task 0 报备获批 |
</threat_model>
<verification>
- **Task 0**:预执行状态(台账 + 类型/地图管理器/切分器/加载器/源数据 + `node zlib` 就位;unmapped/summary 不存在);人工关卡(用户回复且明确 OI-3 取值)。
- **Task 1**:三份实体 jsonc 分隔标记后严格 JSON;tile 235 且 `id`/`num` 唯一、`type` ∈ {2,3,4,5,6,7}、键集恰 6 键;enemy 81 且 `num` ∈ `Enemy` 图块、`attribute`/`special` 形状正确;item 62 且 `num` ∈ `Item` 图块;头部 `// Type:` 保留。
- **Task 2**:42 张拆分图(默认 B)+ 复制素材存在、PNG `IHDR` 合法(8 位非隔行)、尺寸符合 D-72(**逐张断言含全部 `enemy*`/`npc*`**);`texture/.list.jsonc` 解析成功、`source` 存在、`map`/`identifier` `num` 均在 `tile.jsonc` 且 **覆盖全部有贴图图块 `num`**;autotile/tileset 逐字节复制;脚本零依赖、`package.json` 未变。
- **Task 3**:5 楼层齐备、每层长度 = `width×height`、数值 ∈ {0} ∪ `tile` nums;`maps/.list.jsonc` = 5 id;unmapped 文档仅含 §8.1;SUMMARY 存在;`SCOPE GATE` 无越界改动。
- **一致性**:五处 `id`↔`num` 全局一致(D-79);maps 5 楼层;unmapped 文档只含 §8.1;OI-3 处置与 `npc` 图数 / 总数自洽(B=6/42、A=7/43、C=4)。
- **Task 1**:三份实体 jsonc 分隔标记后严格 JSON;tile 235 且 `id`/`num` 唯一、`type` ∈ {2,3,4,5,6,7}、键集恰 6 键;enemy 81 且键集恰 3 键(`num`/`attribute`/`special`)、`num` ∈ `Enemy` 图块、`attribute`/`special` 形状正确;item 62 且键集恰 5 键(`num`/`category`/`name`/`text`/`hideInToolbox`)、`text` 为字符串(旧缺 `text` 的 13 条写 `""`)、`num` ∈ `Item` 图块;头部 `// Type:` 保留。
- **Task 2**:拆分图(默认 B=42;A=43;C=40)+ 复制素材存在、PNG `IHDR` 合法(8 位非隔行)、**尺寸按 D-84 从来源块数逐图推导**(`width = 列数 × 帧宽`、`height = 行数 × 帧高`,`1 ≤ 行数 ≤ 4`;含全部 `enemy*`/`npc*`;末图可裁剪为 128×32 / 64×32 等),`npc` 图数 `K` 与 OI-3 取值联动断言;`texture/.list.jsonc` 解析成功、`source` 存在、`map`/`identifier` `num` 均在 `tile.jsonc` 且 **覆盖全部有贴图图块 `num`**;autotile/tileset 逐字节复制;脚本零依赖、`package.json` 未变。
- **Task 3**:5 楼层齐备、顶层键集恰 5 键(无 §8.1/§8.2 键)、每层长度 = `width×height`、数值 ∈ {0} ∪ `tile` nums;`maps/.list.jsonc` = 5 id;unmapped 文档仅含 §8.1;SUMMARY 存在;`SCOPE GATE` 无越界改动。
- **一致性**:五处 `id`↔`num` 全局一致(D-79);各实体/地图键集精确(§8.1/§8.2 键均不落数据文件);maps 5 楼层;unmapped 文档只含 §8.1;OI-3 处置与 `npc` 图数 / 总数自洽(B=6/42、A=7/43、C=4/40);贴图尺寸与 D-84 实际行数自洽(不硬编码 4 行)。
- **无全仓门禁**:全程不使用 `pnpm check:type` / `pnpm build` / 全仓 TS 诊断数。
- **人工复核**:结论写入 `09-19-SUMMARY.md`(含范围与映射口径、`num` 分配、素材产出与 OI-3 处置、地图口径、门禁结果、`open_items`)。
- **人工复核**:结论写入 `09-19-SUMMARY.md`(含范围与映射口径、`num` 分配、`text` 处置、素材产出(含每图实际行数与裁剪末图尺寸)与 OI-3 处置、D-84 布局口径、地图口径、门禁结果、`open_items`)。
</verification>
<success_criteria>
- **实体(D-76):** `tile.jsonc` 235 / `enemy.jsonc` 81 / `item.jsonc` 62 条,基础字段按「映射候选(可确定)」全量、按 `num` 策略 A 分配;§8.2/§8.1 字段均未落入数据文件。
- **素材(D-72/D-81):** 默认 42 张拆分图(OI-3=B,`npc0..5`)+ 4 autotile + 1 tileset 落 `src/content/texture/`;`animates` 并入 `terrains`;零依赖生成;每张 PNG 尺寸经断言;`texture/.list.jsonc` 用 D-80 已批准 schema 且与 `tile.jsonc` `num` 一致、覆盖全部有贴图图块。
- **地图(D-82):** 5 楼层全部写入;每层展平数组长度 = `width×height`;旧 `num` 重映射为新 `num`。
- **实体(D-76):** `tile.jsonc` 235 / `enemy.jsonc` 81 / `item.jsonc` 62 条,基础字段按「映射候选(可确定)」全量、按 `num` 策略 A 分配;旧缺 `text` 的 13 条写 `""`;§8.2/§8.1 字段均未落入数据文件(各实体精确键集断言)。
- **素材(D-72/D-81/D-84):** 默认 42 张拆分图(OI-3=B,`npc0..5`)+ 4 autotile + 1 tileset 落 `src/content/texture/`(备选 A=43 `npc0..6`、C=40 `npc0..3`);`animates` 并入 `terrains`;零依赖生成;**每张 PNG 尺寸按 D-84 从来源块数逐图推导(列数固定、行数 ≤4 且裁剪),门禁按实际行数断言(不硬编码 4 行尺寸)**;`npc` 图数按 OI-3 取值断言;`texture/.list.jsonc` 用 D-80 已批准 schema 且与 `tile.jsonc` `num` 一致、覆盖全部有贴图图块。
- **地图(D-82):** 5 楼层全部写入;顶层键集恰 5 键;每层展平数组长度 = `width×height`;旧 `num` 重映射为新 `num`。
- **unmapped(D-78):** 文档仅含 §8.1;§8.2 未落入文档且未移植。
- **边界(D-70/D-83):** 未实现消费接线;未改 `packages*/`、`src/`(除 `src/content/**`)、`public/project/**`、`ROADMAP.md`/`STATE.md`/既有台账与计划;未新增依赖。
- **决策(D-81 / OI-3):** 48px 合并处置由用户显式裁定;默认保持 D-81 的 `npc0..5`=6 图 / `SPLIT-COUNT` 42。
- **门禁:** 全部静态门禁(结构 / 标记 / 尺寸 / 覆盖 / 一致性 / 作用域)全绿;产出经 Task 0 获批。
- **决策(D-81 / D-84 / OI-3):** 48px 合并处置由用户显式裁定;默认保持 D-81 的 `npc0..5`=6 图 / `SPLIT-COUNT` 42;备选 A=7/43、C=4/40;贴图布局按 D-84 逐图裁剪、门禁按实际行数断言(§2 严格 4 行读法已由 D-84 取代)。
- **门禁:** 全部静态门禁(结构 / 标记 / 精确键集 / D-84 逐图尺寸 / 覆盖 / 一致性 / 作用域)全绿;产出经 Task 0 获批。
</success_criteria>
<output>
@ -448,12 +461,12 @@ Create `.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md` when done
| 符号 / 路径 | 类型 | 说明 |
|---|---|---|
| `src/content/tile.jsonc`(替换样例) | 数据 | 235 条图块(`num` 策略 A、`id`、`type`、`pass`) |
| `src/content/enemy.jsonc`(替换样例) | 数据 | 81 条怪物(`num` 对应图块、`attribute`、`special`) |
| `src/content/item.jsonc`(替换样例) | 数据 | 62 条道具(`num` 对应图块、`category`、`name`、`text`、`hideInToolbox`) |
| `src/content/maps/*.jsonc`(新增 4 + 替换 MT0) | 数据 | 5 楼层 `IMapRawData` |
| `src/content/tile.jsonc`(替换样例) | 数据 | 235 条图块(`num` 策略 A、`id`、`type`、`pass`;键集恰 6 键) |
| `src/content/enemy.jsonc`(替换样例) | 数据 | 81 条怪物(`num` 对应图块、`attribute`、`special`;键集恰 3 键) |
| `src/content/item.jsonc`(替换样例) | 数据 | 62 条道具(`num` 对应图块、`category`、`name`、`text`(缺则 `""`)、`hideInToolbox`;键集恰 5 键) |
| `src/content/maps/*.jsonc`(新增 4 + 替换 MT0) | 数据 | 5 楼层 `IMapRawData`(顶层键集恰 5 键) |
| `src/content/maps/.list.jsonc`(替换) | 数据 | 5 楼层 id 列表 |
| `src/content/texture/*.png`(新增) | 素材 | 42 张拆分图(OI-3=A 时 43)+ 4 autotile + 1 tileset |
| `src/content/texture/*.png`(新增) | 素材 | 42 张拆分图(OI-3=A 时 43;C 时 40)+ 4 autotile + 1 tileset;**列数固定、行数 ≤4 且按每图实际裁剪(末图如 128×32 / 64×32)** |
| `src/content/texture/.list.jsonc`(替换) | 数据 | D-80 已批准 `ITextureListConfig` |
| `script/migrateProjectTextures.ts`(新增,待批准) | 脚本 | 一次性零依赖 PNG 生成(小驼峰) |
| `.planning/phases/09-minimum-browser-runnable/09-DATA-MIGRATION-UNMAPPED.md`(新增,待批准) | 文档 | 仅 §8.1 无目标字段说明 |
@ -467,8 +480,8 @@ Create `.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md` when done
|------|----|------|
| GOAL | Phase 9(ROADMAP)「在已完成的数据端之上完成渲染端框架,产出可在浏览器中运行并验证的最小版本」 | COVERED(增量·任务 9 子切片:基础数据移植,为可运行版本提供真实数据) |
| REQ | Phase 9 ROADMAP「Requirements: TBD」,无需求 ID | N/A(`requirements: []` 如实标注) |
| RESEARCH | 无 RESEARCH.md(本阶段为增量派发,无独立研究产物) | N/A(PNG 可行性与 48px 合并可行性已在本规划直接核实并记入 assumptions) |
| CONTEXT | D-76(本次规划单元 = 基础移植;§8.1 不移植) | COVERED(Task 1/3 + prohibitions + 门禁键集) |
| RESEARCH | 无 RESEARCH.md(本阶段为增量派发,无独立研究产物) | N/A(PNG 可行性、48px 合并可行性与 D-84 逐图裁剪可行性已在本规划直接核实并记入 assumptions) |
| CONTEXT | D-76(本次规划单元 = 基础移植;§8.1 不移植) | COVERED(Task 1/3 + prohibitions + 精确键集门禁) |
| CONTEXT | D-77(产出形态:tile/enemy/item/maps/texture + 六类基础字段全量) | COVERED(Task 1/2/3) |
| CONTEXT | D-78(unmapped 文档仅 §8.1;§8.2 不移植不入文档) | COVERED(Task 3 + `MAP GATE` token 校验) |
| CONTEXT | D-79(`num` 策略 A,`id`↔`num` 一致) | COVERED(Task 1 (0) + 跨文件一致性门禁) |
@ -476,8 +489,9 @@ Create `.planning/phases/09-minimum-browser-runnable/09-19-SUMMARY.md` when done
| CONTEXT | D-81(素材落点与命名 `npc0..5`/`enemy0..20`/`SPLIT-COUNT` 42) | COVERED(Task 2 默认 OI-3=B 保持 6 图;偏离需用户显式裁定) |
| CONTEXT | D-82(maps 全部 5 楼层) | COVERED(Task 3) |
| CONTEXT | D-83(消费接线不在本增量;`animates` 并入 `terrains`;events/trigger 不移植) | COVERED(Task 2 B 组 + Task 3 + prohibitions) |
| CONTEXT | **D-84(贴图布局放宽:列数固定、行数 ≤4 且按需裁剪;门禁按实际行数断言、不硬编码 4 行尺寸)** | COVERED(Task 0 ⑥ + Task 2 布局总则与逐图行数推导 + `TEXTURE GATE` 逐图尺寸断言 + prohibitions「贴图尺寸不硬编码」+ 威胁寄存器 T-09-19-12) |
| CONTEXT | D-70 / D-73 / D-75(落点与接线边界 / 命名先报备 / 素材由脚本生成) | COVERED(Task 0 + `open_items` + Task 2 脚本) |
| CONTEXT | D-01 / D-03 / D-04 / D-06(增量派发 / 执行前获批 / 命名获批 / 不扩范围) | COVERED(Task 0 + prohibitions) |
| 缺口 | 无(六类基础字段已逐项落到 Task 1/2/3 与门禁;OI-3 显式裁定项已并入门禁) | CLOSED |
| 缺口 | 无(六类基础字段已逐项落到 Task 1/2/3 与门禁;OI-3 显式裁定项已并入门禁;D-84 逐图行数/尺寸推导已并入 Task 2 与门禁;旧缺 `text` 的 13 条已明确 `""` 处置) | CLOSED |
> **范围外(非缺口):** animation 素材(D-71)、`images`/`sounds`/`bgms`/`fonts`、§8.2 待定项、消费接线(D-70/D-83)—— 依决策不属本增量。