diff --git a/assets/resources/ASSETS.md b/assets/resources/ASSETS.md index d16a3b5..179df0f 100644 --- a/assets/resources/ASSETS.md +++ b/assets/resources/ASSETS.md @@ -3,7 +3,11 @@ > 本清单对应 [task-item.md](../../../.codebuddy/plan/kage_legend_mvp/task-item.md) 中的 **10.1 集成像素美术与音频资源**。 > 真正的 PNG / WAV / MP3 二进制文件需由美术、音效组按下表规格制作并放入对应目录。 -> **当前状态**:所有条目均已用 1×1 纯色 PNG / 0.1s 静音 WAV / 空 MP3 帧**占位**,可通过 `node scripts/gen_placeholder_assets.js` 随时重新生成。占位资产仅保证工程可打开可联调,**不具有任何视觉/听觉效果**,必须由美术/音效替换为正式素材才能发布。 +> **当前状态**: +> - **PNG 资源**:已用 Python 程序化绘制(`scripts/gen_pixel_art_assets.py`),按 `ASSETS.md` 规格生成像素美术占位,**可直接进游戏看到角色/敌人/场景/剧情插画**,但仅为过渡素材,正式版仍需美术替换。 +> - **WAV / MP3 资源**:仍用 `scripts/gen_placeholder_assets.js` 生成的静音占位,便于工程联调。 +> +> 两个脚本都是幂等的,可随时重跑。程序化 PNG 脚本会**覆盖**占位脚本的 1×1 纯色 PNG。 --- @@ -93,51 +97,51 @@ MVP 仅启用青叶配色;红叶 / 雪原调色板预留接口但不纳入 MVP ## 8. 资源交付跟踪表(Delivery Tracker) -> 状态图例:⬜ 待制作 🟨 占位中(1×1 纯色 / 静音) 🟩 已交付正式版 -> 占位由 `scripts/gen_placeholder_assets.js` 生成,每次跑会刷新所有 🟨 条目。 +> 状态图例:⬜ 待制作 🟨 静音/空包占位(WAV/MP3) 🟦 程序化像素美术占位(PNG) 🟩 已交付正式版 +> 🟨 由 `scripts/gen_placeholder_assets.js` 生成;🟦 由 `scripts/gen_pixel_art_assets.py` 生成。两个脚本都幂等可重跑。 ### 8.1 主角 · 3 形态 | 资源 | 状态 | 负责人 | 计划交付 | 备注 | |---|---|---|---|---| -| `textures/characters/kage_red.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | -| `textures/characters/kage_green.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | -| `textures/characters/kage_yellow.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | +| `textures/characters/kage_red.png` | 🟦 | TBD-美术 | TBD | 16×32 · 11 帧(程序化) | +| `textures/characters/kage_green.png` | 🟦 | TBD-美术 | TBD | 16×32 · 11 帧(程序化) | +| `textures/characters/kage_yellow.png` | 🟦 | TBD-美术 | TBD | 16×32 · 11 帧(程序化) | ### 8.2 敌人 + BOSS | 资源 | 状态 | 负责人 | 计划交付 | 备注 | |---|---|---|---|---| -| `textures/enemies/qing_ren.png` | 🟨 | TBD-美术 | TBD | 16×16 · 7 帧 | -| `textures/enemies/chi_ren.png` | 🟨 | TBD-美术 | TBD | 16×16 · 7 帧 | -| `textures/enemies/hei_ren.png` | 🟨 | TBD-美术 | TBD | 20×24 · 6 帧 | -| `textures/enemies/yao_fang.png` | 🟨 | TBD-美术 | TBD | 18×20 · 4 帧 | -| `textures/bosses/shuang_huan_fang.png` | 🟨 | TBD-美术 | TBD | 32×32 本体 + 96×32 双身 | -| `textures/bosses/butterfly.png` | 🟨 | TBD-美术 | TBD | 16×16 · 4 帧 | +| `textures/enemies/qing_ren.png` | 🟦 | TBD-美术 | TBD | 16×16 · 7 帧(程序化) | +| `textures/enemies/chi_ren.png` | 🟦 | TBD-美术 | TBD | 16×16 · 7 帧(程序化) | +| `textures/enemies/hei_ren.png` | 🟦 | TBD-美术 | TBD | 20×24 · 6 帧(程序化) | +| `textures/enemies/yao_fang.png` | 🟦 | TBD-美术 | TBD | 18×20 · 4 帧(程序化) | +| `textures/bosses/shuang_huan_fang.png` | 🟦 | TBD-美术 | TBD | 32×32 × 9 帧(程序化) | +| `textures/bosses/butterfly.png` | 🟦 | TBD-美术 | TBD | 16×16 · 4 帧(程序化) | ### 8.3 场景视差(3 主题 × 4 层 = 12 张) | 主题 | far | mid | near | fx | 负责人 | 计划交付 | |---|---|---|---|---|---|---| -| 森林 `textures/scenes/forest/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | -| 城墙 `textures/scenes/castle_wall/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | -| 魔城 `textures/scenes/demon_castle/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | +| 森林 `textures/scenes/forest/*` | 🟦 | 🟦 | 🟦 | 🟦 | TBD-美术 | TBD | +| 城墙 `textures/scenes/castle_wall/*` | 🟦 | 🟦 | 🟦 | 🟦 | TBD-美术 | TBD | +| 魔城 `textures/scenes/demon_castle/*` | 🟦 | 🟦 | 🟦 | 🟦 | TBD-美术 | TBD | ### 8.4 剧情背景插画(req 19.2) | 资源 | 状态 | 负责人 | 计划交付 | 备注 | |---|---|---|---|---| -| `textures/story/ch1_page1_ninja.png` | 🟨 | TBD-美术 | TBD | 480×270 | -| `textures/story/ch1_page2_princess.png` | 🟨 | TBD-美术 | TBD | 480×270 | -| `textures/story/ch1_page3_depart.png` | 🟨 | TBD-美术 | TBD | 480×270 | +| `textures/story/ch1_page1_ninja.png` | 🟦 | TBD-美术 | TBD | 480×270(程序化) | +| `textures/story/ch1_page2_princess.png` | 🟦 | TBD-美术 | TBD | 480×270(程序化) | +| `textures/story/ch1_page3_depart.png` | 🟦 | TBD-美术 | TBD | 480×270(程序化) | ### 8.5 粒子特效贴图 | 资源 | 状态 | 负责人 | 计划交付 | 备注 | |---|---|---|---|---| -| `textures/fx/leaf_particle.png` | 🟨 | TBD-美术 | TBD | 透明底落叶 | -| `textures/fx/jump_dust.png` | 🟨 | TBD-美术 | TBD | 透明底尘土 | -| `textures/fx/parry_spark.png` | 🟨 | TBD-美术 | TBD | 透明底火花 | +| `textures/fx/leaf_particle.png` | 🟦 | TBD-美术 | TBD | 透明底落叶(程序化) | +| `textures/fx/jump_dust.png` | 🟦 | TBD-美术 | TBD | 透明底尘土(程序化) | +| `textures/fx/parry_spark.png` | 🟦 | TBD-美术 | TBD | 透明底火花(程序化) | ### 8.6 音效 WAV(每段 ≤ 0.5 s,req 16.1) diff --git a/assets/resources/configs/levels.json b/assets/resources/configs/levels.json index a45c448..e03958d 100644 --- a/assets/resources/configs/levels.json +++ b/assets/resources/configs/levels.json @@ -15,6 +15,10 @@ { "type": "yao_fang", "atPx": 2100 }, { "type": "chi_ren", "atPx": 2600, "count": 2 }, { "type": "yao_fang", "atPx": 3200 } + ], + "reinforcements": [ + { "type": "qing_ren", "intervalSec": 8, "count": 1, "maxTotal": 4, "edge": "right", "delaySec": 10 }, + { "type": "chi_ren", "intervalSec": 12, "count": 1, "maxTotal": 2, "edge": "both", "delaySec": 20 } ] }, { @@ -32,6 +36,10 @@ { "type": "chi_ren", "atPx": 1600, "count": 3 }, { "type": "qing_ren", "atPx": 2400, "count": 2 }, { "type": "yao_fang", "atPx": 3600 } + ], + "reinforcements": [ + { "type": "qing_ren", "intervalSec": 7, "count": 2, "maxTotal": 6, "edge": "both", "delaySec": 8 }, + { "type": "chi_ren", "intervalSec": 10, "count": 1, "maxTotal": 3, "edge": "right", "delaySec": 15 } ] }, { @@ -50,6 +58,10 @@ { "type": "chi_ren", "atPx": 2200, "count": 1 }, { "type": "qing_ren", "atPx": 3000, "count": 3 }, { "type": "qing_ren", "atPx": 4000, "count": 2 } + ], + "reinforcements": [ + { "type": "qing_ren", "intervalSec": 6, "count": 2, "maxTotal": 8, "edge": "both", "delaySec": 5 }, + { "type": "chi_ren", "intervalSec": 10, "count": 1, "maxTotal": 3, "edge": "right", "delaySec": 20 } ] }, { @@ -67,6 +79,10 @@ { "type": "qing_ren", "atPx": 1400, "count": 2 }, { "type": "hei_ren", "atPx": 2000 }, { "type": "chi_ren", "atPx": 2600, "count": 2 } + ], + "reinforcements": [ + { "type": "hei_ren", "intervalSec": 15, "count": 1, "maxTotal": 2, "edge": "both", "delaySec": 25 }, + { "type": "qing_ren", "intervalSec": 8, "count": 1, "maxTotal": 4, "edge": "both", "delaySec": 10 } ] }, { @@ -79,6 +95,9 @@ "objective": { "kind": "defeat_boss", "bossId": "shuang_huan_fang" }, "levelLengthPx": 1920, "bgm": "bgm_boss", - "enemySpawns": [] + "enemySpawns": [], + "reinforcements": [ + { "type": "qing_ren", "intervalSec": 10, "count": 1, "maxTotal": 3, "edge": "both", "delaySec": 15 } + ] } ] diff --git a/assets/resources/configs/stories.json b/assets/resources/configs/stories.json index ce447ac..246802a 100644 --- a/assets/resources/configs/stories.json +++ b/assets/resources/configs/stories.json @@ -6,17 +6,17 @@ "pages": [ { "index": 1, - "illustration": "story/ch1_page1_ninja", + "illustration": "textures/story/ch1_page1_ninja", "text": "在月影摇曳的古国,有一位代代相传的忍者——影。他身着赤红忍装,精通手里剑与忍者刀,守护着这片宁静的土地。" }, { "index": 2, - "illustration": "story/ch1_page2_princess", + "illustration": "textures/story/ch1_page2_princess", "text": "然而在一个暴雨之夜,青忍的黑影撕开了宫殿的夜幕,公主被青忍的爪牙掳走,消失在魔城方向的天际。" }, { "index": 3, - "illustration": "story/ch1_page3_depart", + "illustration": "textures/story/ch1_page3_depart", "text": "为了将公主从邪恶的双幻坊手中救出,影踏上了穿越森林、洞穴、城壁、直入魔城天守阁的征程。" } ] diff --git a/assets/resources/textures/bosses/butterfly.png b/assets/resources/textures/bosses/butterfly.png index 0157b90..33623d4 100644 Binary files a/assets/resources/textures/bosses/butterfly.png and b/assets/resources/textures/bosses/butterfly.png differ diff --git a/assets/resources/textures/bosses/butterfly.png.meta b/assets/resources/textures/bosses/butterfly.png.meta index 4860734..769c11d 100644 --- a/assets/resources/textures/bosses/butterfly.png.meta +++ b/assets/resources/textures/bosses/butterfly.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e@f9941", + "displayName": "butterfly", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 3, + "width": 64, + "height": 10, + "rawWidth": 64, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -32, + -5, + 0, + 32, + -5, + 0, + -32, + 5, + 0, + 32, + 5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 13, + 64, + 13, + 0, + 3, + 64, + 3 + ], + "nuv": [ + 0, + 0.1875, + 1, + 0.1875, + 0, + 0.8125, + 1, + 0.8125 + ], + "minPos": [ + -32, + -5, + 0 + ], + "maxPos": [ + 32, + 5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e@6c48a" diff --git a/assets/resources/textures/bosses/shuang_huan_fang.png b/assets/resources/textures/bosses/shuang_huan_fang.png index b95c9fa..93bdc40 100644 Binary files a/assets/resources/textures/bosses/shuang_huan_fang.png and b/assets/resources/textures/bosses/shuang_huan_fang.png differ diff --git a/assets/resources/textures/bosses/shuang_huan_fang.png.meta b/assets/resources/textures/bosses/shuang_huan_fang.png.meta index e81b99c..0f78fe4 100644 --- a/assets/resources/textures/bosses/shuang_huan_fang.png.meta +++ b/assets/resources/textures/bosses/shuang_huan_fang.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "30ebfe50-e7e5-4c59-9d41-a23906d2406c@f9941", + "displayName": "shuang_huan_fang", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0.5, + "trimX": 0, + "trimY": 1, + "width": 288, + "height": 29, + "rawWidth": 288, + "rawHeight": 32, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -144, + -14.5, + 0, + 144, + -14.5, + 0, + -144, + 14.5, + 0, + 144, + 14.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 31, + 288, + 31, + 0, + 2, + 288, + 2 + ], + "nuv": [ + 0, + 0.0625, + 1, + 0.0625, + 0, + 0.96875, + 1, + 0.96875 + ], + "minPos": [ + -144, + -14.5, + 0 + ], + "maxPos": [ + 144, + 14.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "30ebfe50-e7e5-4c59-9d41-a23906d2406c@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "30ebfe50-e7e5-4c59-9d41-a23906d2406c@6c48a" diff --git a/assets/resources/textures/characters/kage_green.png b/assets/resources/textures/characters/kage_green.png index 6b5bfd2..9ae6ed3 100644 Binary files a/assets/resources/textures/characters/kage_green.png and b/assets/resources/textures/characters/kage_green.png differ diff --git a/assets/resources/textures/characters/kage_green.png.meta b/assets/resources/textures/characters/kage_green.png.meta index 2b484b4..8f8211a 100644 --- a/assets/resources/textures/characters/kage_green.png.meta +++ b/assets/resources/textures/characters/kage_green.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "de17d584-ed54-49ec-a1a5-de351ecb6e4d@f9941", + "displayName": "kage_green", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1.5, + "offsetY": 1, + "trimX": 3, + "trimY": 0, + "width": 173, + "height": 30, + "rawWidth": 176, + "rawHeight": 32, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -86.5, + -15, + 0, + 86.5, + -15, + 0, + -86.5, + 15, + 0, + 86.5, + 15, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 3, + 32, + 176, + 32, + 3, + 2, + 176, + 2 + ], + "nuv": [ + 0.017045454545454544, + 0.0625, + 1, + 0.0625, + 0.017045454545454544, + 1, + 1, + 1 + ], + "minPos": [ + -86.5, + -15, + 0 + ], + "maxPos": [ + 86.5, + 15, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "de17d584-ed54-49ec-a1a5-de351ecb6e4d@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "de17d584-ed54-49ec-a1a5-de351ecb6e4d@6c48a" diff --git a/assets/resources/textures/characters/kage_red.png b/assets/resources/textures/characters/kage_red.png index 9c4b7fd..17c6fdf 100644 Binary files a/assets/resources/textures/characters/kage_red.png and b/assets/resources/textures/characters/kage_red.png differ diff --git a/assets/resources/textures/characters/kage_red.png.meta b/assets/resources/textures/characters/kage_red.png.meta index d2f917d..fd98391 100644 --- a/assets/resources/textures/characters/kage_red.png.meta +++ b/assets/resources/textures/characters/kage_red.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "1ea27d49-1512-48e8-b3d4-3636a93c07a3@f9941", + "displayName": "kage_red", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1.5, + "offsetY": 1, + "trimX": 3, + "trimY": 0, + "width": 173, + "height": 30, + "rawWidth": 176, + "rawHeight": 32, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -86.5, + -15, + 0, + 86.5, + -15, + 0, + -86.5, + 15, + 0, + 86.5, + 15, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 3, + 32, + 176, + 32, + 3, + 2, + 176, + 2 + ], + "nuv": [ + 0.017045454545454544, + 0.0625, + 1, + 0.0625, + 0.017045454545454544, + 1, + 1, + 1 + ], + "minPos": [ + -86.5, + -15, + 0 + ], + "maxPos": [ + 86.5, + 15, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "1ea27d49-1512-48e8-b3d4-3636a93c07a3@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "1ea27d49-1512-48e8-b3d4-3636a93c07a3@6c48a" diff --git a/assets/resources/textures/characters/kage_yellow.png b/assets/resources/textures/characters/kage_yellow.png index fc027be..57c0075 100644 Binary files a/assets/resources/textures/characters/kage_yellow.png and b/assets/resources/textures/characters/kage_yellow.png differ diff --git a/assets/resources/textures/characters/kage_yellow.png.meta b/assets/resources/textures/characters/kage_yellow.png.meta index 3a1448c..047362c 100644 --- a/assets/resources/textures/characters/kage_yellow.png.meta +++ b/assets/resources/textures/characters/kage_yellow.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "cc33a404-518b-4d7a-9699-765d88256b1f@f9941", + "displayName": "kage_yellow", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1.5, + "offsetY": 1, + "trimX": 3, + "trimY": 0, + "width": 173, + "height": 30, + "rawWidth": 176, + "rawHeight": 32, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -86.5, + -15, + 0, + 86.5, + -15, + 0, + -86.5, + 15, + 0, + 86.5, + 15, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 3, + 32, + 176, + 32, + 3, + 2, + 176, + 2 + ], + "nuv": [ + 0.017045454545454544, + 0.0625, + 1, + 0.0625, + 0.017045454545454544, + 1, + 1, + 1 + ], + "minPos": [ + -86.5, + -15, + 0 + ], + "maxPos": [ + 86.5, + 15, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "cc33a404-518b-4d7a-9699-765d88256b1f@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "cc33a404-518b-4d7a-9699-765d88256b1f@6c48a" diff --git a/assets/resources/textures/enemies/chi_ren.png b/assets/resources/textures/enemies/chi_ren.png index 15917d1..347bbed 100644 Binary files a/assets/resources/textures/enemies/chi_ren.png and b/assets/resources/textures/enemies/chi_ren.png differ diff --git a/assets/resources/textures/enemies/chi_ren.png.meta b/assets/resources/textures/enemies/chi_ren.png.meta index af6dd64..e58cbfd 100644 --- a/assets/resources/textures/enemies/chi_ren.png.meta +++ b/assets/resources/textures/enemies/chi_ren.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "36016f50-9022-4662-9b78-b9b29ba64510@f9941", + "displayName": "chi_ren", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1, + "offsetY": -1, + "trimX": 2, + "trimY": 2, + "width": 110, + "height": 14, + "rawWidth": 112, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -55, + -7, + 0, + 55, + -7, + 0, + -55, + 7, + 0, + 55, + 7, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 2, + 14, + 112, + 14, + 2, + 0, + 112, + 0 + ], + "nuv": [ + 0.017857142857142856, + 0, + 1, + 0, + 0.017857142857142856, + 0.875, + 1, + 0.875 + ], + "minPos": [ + -55, + -7, + 0 + ], + "maxPos": [ + 55, + 7, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "36016f50-9022-4662-9b78-b9b29ba64510@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "36016f50-9022-4662-9b78-b9b29ba64510@6c48a" diff --git a/assets/resources/textures/enemies/hei_ren.png b/assets/resources/textures/enemies/hei_ren.png index 5cfd3dd..7e0ab49 100644 Binary files a/assets/resources/textures/enemies/hei_ren.png and b/assets/resources/textures/enemies/hei_ren.png differ diff --git a/assets/resources/textures/enemies/hei_ren.png.meta b/assets/resources/textures/enemies/hei_ren.png.meta index 28a48f8..f4253c0 100644 --- a/assets/resources/textures/enemies/hei_ren.png.meta +++ b/assets/resources/textures/enemies/hei_ren.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8@f9941", + "displayName": "hei_ren", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1.5, + "offsetY": 0, + "trimX": 4, + "trimY": 1, + "width": 115, + "height": 22, + "rawWidth": 120, + "rawHeight": 24, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -57.5, + -11, + 0, + 57.5, + -11, + 0, + -57.5, + 11, + 0, + 57.5, + 11, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 4, + 23, + 119, + 23, + 4, + 1, + 119, + 1 + ], + "nuv": [ + 0.03333333333333333, + 0.041666666666666664, + 0.9916666666666667, + 0.041666666666666664, + 0.03333333333333333, + 0.9583333333333334, + 0.9916666666666667, + 0.9583333333333334 + ], + "minPos": [ + -57.5, + -11, + 0 + ], + "maxPos": [ + 57.5, + 11, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8@6c48a" diff --git a/assets/resources/textures/enemies/qing_ren.png b/assets/resources/textures/enemies/qing_ren.png index ed50e60..5d25515 100644 Binary files a/assets/resources/textures/enemies/qing_ren.png and b/assets/resources/textures/enemies/qing_ren.png differ diff --git a/assets/resources/textures/enemies/qing_ren.png.meta b/assets/resources/textures/enemies/qing_ren.png.meta index 29d67e7..391cdc9 100644 --- a/assets/resources/textures/enemies/qing_ren.png.meta +++ b/assets/resources/textures/enemies/qing_ren.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "c5261dd4-ea58-49eb-88ff-7c864104b499@f9941", + "displayName": "qing_ren", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1, + "offsetY": -1, + "trimX": 2, + "trimY": 2, + "width": 110, + "height": 14, + "rawWidth": 112, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -55, + -7, + 0, + 55, + -7, + 0, + -55, + 7, + 0, + 55, + 7, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 2, + 14, + 112, + 14, + 2, + 0, + 112, + 0 + ], + "nuv": [ + 0.017857142857142856, + 0, + 1, + 0, + 0.017857142857142856, + 0.875, + 1, + 0.875 + ], + "minPos": [ + -55, + -7, + 0 + ], + "maxPos": [ + 55, + 7, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "c5261dd4-ea58-49eb-88ff-7c864104b499@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "c5261dd4-ea58-49eb-88ff-7c864104b499@6c48a" diff --git a/assets/resources/textures/enemies/yao_fang.png b/assets/resources/textures/enemies/yao_fang.png index d52841c..ae3e281 100644 Binary files a/assets/resources/textures/enemies/yao_fang.png and b/assets/resources/textures/enemies/yao_fang.png differ diff --git a/assets/resources/textures/enemies/yao_fang.png.meta b/assets/resources/textures/enemies/yao_fang.png.meta index 9d55458..5c08a6f 100644 --- a/assets/resources/textures/enemies/yao_fang.png.meta +++ b/assets/resources/textures/enemies/yao_fang.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "1c138e9b-5973-4b83-b632-dd13e00f5429@f9941", + "displayName": "yao_fang", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0.5, + "trimX": 0, + "trimY": 0, + "width": 72, + "height": 19, + "rawWidth": 72, + "rawHeight": 20, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -36, + -9.5, + 0, + 36, + -9.5, + 0, + -36, + 9.5, + 0, + 36, + 9.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 20, + 72, + 20, + 0, + 1, + 72, + 1 + ], + "nuv": [ + 0, + 0.05, + 1, + 0.05, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -36, + -9.5, + 0 + ], + "maxPos": [ + 36, + 9.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "1c138e9b-5973-4b83-b632-dd13e00f5429@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "1c138e9b-5973-4b83-b632-dd13e00f5429@6c48a" diff --git a/assets/resources/textures/fx/jump_dust.png b/assets/resources/textures/fx/jump_dust.png index e44804c..fa35ec1 100644 Binary files a/assets/resources/textures/fx/jump_dust.png and b/assets/resources/textures/fx/jump_dust.png differ diff --git a/assets/resources/textures/fx/jump_dust.png.meta b/assets/resources/textures/fx/jump_dust.png.meta index 33ff811..0ffd65b 100644 --- a/assets/resources/textures/fx/jump_dust.png.meta +++ b/assets/resources/textures/fx/jump_dust.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd@f9941", + "displayName": "jump_dust", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0.5, + "offsetY": -3, + "trimX": 1, + "trimY": 6, + "width": 15, + "height": 10, + "rawWidth": 16, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -7.5, + -5, + 0, + 7.5, + -5, + 0, + -7.5, + 5, + 0, + 7.5, + 5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 1, + 10, + 16, + 10, + 1, + 0, + 16, + 0 + ], + "nuv": [ + 0.0625, + 0, + 1, + 0, + 0.0625, + 0.625, + 1, + 0.625 + ], + "minPos": [ + -7.5, + -5, + 0 + ], + "maxPos": [ + 7.5, + 5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd@6c48a" diff --git a/assets/resources/textures/fx/leaf_particle.png b/assets/resources/textures/fx/leaf_particle.png index af5c506..59bcd83 100644 Binary files a/assets/resources/textures/fx/leaf_particle.png and b/assets/resources/textures/fx/leaf_particle.png differ diff --git a/assets/resources/textures/fx/leaf_particle.png.meta b/assets/resources/textures/fx/leaf_particle.png.meta index 9b76528..3fa9070 100644 --- a/assets/resources/textures/fx/leaf_particle.png.meta +++ b/assets/resources/textures/fx/leaf_particle.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac@f9941", + "displayName": "leaf_particle", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 1, + "offsetY": 0.5, + "trimX": 4, + "trimY": 3, + "width": 10, + "height": 9, + "rawWidth": 16, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -5, + -4.5, + 0, + 5, + -4.5, + 0, + -5, + 4.5, + 0, + 5, + 4.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 4, + 13, + 14, + 13, + 4, + 4, + 14, + 4 + ], + "nuv": [ + 0.25, + 0.25, + 0.875, + 0.25, + 0.25, + 0.8125, + 0.875, + 0.8125 + ], + "minPos": [ + -5, + -4.5, + 0 + ], + "maxPos": [ + 5, + 4.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac@6c48a" diff --git a/assets/resources/textures/fx/parry_spark.png b/assets/resources/textures/fx/parry_spark.png index d64a198..776f120 100644 Binary files a/assets/resources/textures/fx/parry_spark.png and b/assets/resources/textures/fx/parry_spark.png differ diff --git a/assets/resources/textures/fx/parry_spark.png.meta b/assets/resources/textures/fx/parry_spark.png.meta index 57a96b8..6ed4490 100644 --- a/assets/resources/textures/fx/parry_spark.png.meta +++ b/assets/resources/textures/fx/parry_spark.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "adfcaead-eec0-4a25-b69c-571a34154af1@f9941", + "displayName": "parry_spark", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0.5, + "offsetY": -0.5, + "trimX": 1, + "trimY": 1, + "width": 15, + "height": 15, + "rawWidth": 16, + "rawHeight": 16, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -7.5, + -7.5, + 0, + 7.5, + -7.5, + 0, + -7.5, + 7.5, + 0, + 7.5, + 7.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 1, + 15, + 16, + 15, + 1, + 0, + 16, + 0 + ], + "nuv": [ + 0.0625, + 0, + 1, + 0, + 0.0625, + 0.9375, + 1, + 0.9375 + ], + "minPos": [ + -7.5, + -7.5, + 0 + ], + "maxPos": [ + 7.5, + 7.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "adfcaead-eec0-4a25-b69c-571a34154af1@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "adfcaead-eec0-4a25-b69c-571a34154af1@6c48a" diff --git a/assets/resources/textures/scenes/castle_wall/far.png b/assets/resources/textures/scenes/castle_wall/far.png index dbcce41..7b9f804 100644 Binary files a/assets/resources/textures/scenes/castle_wall/far.png and b/assets/resources/textures/scenes/castle_wall/far.png differ diff --git a/assets/resources/textures/scenes/castle_wall/far.png.meta b/assets/resources/textures/scenes/castle_wall/far.png.meta index 4266f53..49e0d09 100644 --- a/assets/resources/textures/scenes/castle_wall/far.png.meta +++ b/assets/resources/textures/scenes/castle_wall/far.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "58461a49-befa-4fa6-9a0c-3a4a2780a05e@f9941", + "displayName": "far", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "58461a49-befa-4fa6-9a0c-3a4a2780a05e@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "58461a49-befa-4fa6-9a0c-3a4a2780a05e@6c48a" diff --git a/assets/resources/textures/scenes/castle_wall/fx.png b/assets/resources/textures/scenes/castle_wall/fx.png index 3641cc6..453e932 100644 Binary files a/assets/resources/textures/scenes/castle_wall/fx.png and b/assets/resources/textures/scenes/castle_wall/fx.png differ diff --git a/assets/resources/textures/scenes/castle_wall/fx.png.meta b/assets/resources/textures/scenes/castle_wall/fx.png.meta index 71ab6c2..346ad4f 100644 --- a/assets/resources/textures/scenes/castle_wall/fx.png.meta +++ b/assets/resources/textures/scenes/castle_wall/fx.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "422d5dc6-7148-44f5-889c-f75a00567421@f9941", + "displayName": "fx", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -95.5, + "trimX": 0, + "trimY": 191, + "width": 480, + "height": 79, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -39.5, + 0, + 240, + -39.5, + 0, + -240, + 39.5, + 0, + 240, + 39.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 79, + 480, + 79, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 0.29259259259259257, + 1, + 0.29259259259259257 + ], + "minPos": [ + -240, + -39.5, + 0 + ], + "maxPos": [ + 240, + 39.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "422d5dc6-7148-44f5-889c-f75a00567421@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "422d5dc6-7148-44f5-889c-f75a00567421@6c48a" diff --git a/assets/resources/textures/scenes/castle_wall/mid.png b/assets/resources/textures/scenes/castle_wall/mid.png index da6c50d..6a769a8 100644 Binary files a/assets/resources/textures/scenes/castle_wall/mid.png and b/assets/resources/textures/scenes/castle_wall/mid.png differ diff --git a/assets/resources/textures/scenes/castle_wall/mid.png.meta b/assets/resources/textures/scenes/castle_wall/mid.png.meta index 260b1aa..5056be1 100644 --- a/assets/resources/textures/scenes/castle_wall/mid.png.meta +++ b/assets/resources/textures/scenes/castle_wall/mid.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "6db47cc3-72a3-4a9b-b100-48148f1cc934@f9941", + "displayName": "mid", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -35, + "trimX": 0, + "trimY": 120, + "width": 480, + "height": 100, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -50, + 0, + 240, + -50, + 0, + -240, + 50, + 0, + 240, + 50, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 150, + 480, + 150, + 0, + 50, + 480, + 50 + ], + "nuv": [ + 0, + 0.18518518518518517, + 1, + 0.18518518518518517, + 0, + 0.5555555555555556, + 1, + 0.5555555555555556 + ], + "minPos": [ + -240, + -50, + 0 + ], + "maxPos": [ + 240, + 50, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "6db47cc3-72a3-4a9b-b100-48148f1cc934@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "6db47cc3-72a3-4a9b-b100-48148f1cc934@6c48a" diff --git a/assets/resources/textures/scenes/castle_wall/near.png b/assets/resources/textures/scenes/castle_wall/near.png index e426f98..1225d0b 100644 Binary files a/assets/resources/textures/scenes/castle_wall/near.png and b/assets/resources/textures/scenes/castle_wall/near.png differ diff --git a/assets/resources/textures/scenes/castle_wall/near.png.meta b/assets/resources/textures/scenes/castle_wall/near.png.meta index 3fbb52d..d999828 100644 --- a/assets/resources/textures/scenes/castle_wall/near.png.meta +++ b/assets/resources/textures/scenes/castle_wall/near.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "be8f61cb-4af2-466e-bc79-18e27d2a70f6@f9941", + "displayName": "near", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -72.5, + "trimX": 0, + "trimY": 145, + "width": 480, + "height": 125, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -62.5, + 0, + 240, + -62.5, + 0, + -240, + 62.5, + 0, + 240, + 62.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 125, + 480, + 125, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 0.46296296296296297, + 1, + 0.46296296296296297 + ], + "minPos": [ + -240, + -62.5, + 0 + ], + "maxPos": [ + 240, + 62.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "be8f61cb-4af2-466e-bc79-18e27d2a70f6@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "be8f61cb-4af2-466e-bc79-18e27d2a70f6@6c48a" diff --git a/assets/resources/textures/scenes/demon_castle/far.png b/assets/resources/textures/scenes/demon_castle/far.png index 596e8a6..8a1d99f 100644 Binary files a/assets/resources/textures/scenes/demon_castle/far.png and b/assets/resources/textures/scenes/demon_castle/far.png differ diff --git a/assets/resources/textures/scenes/demon_castle/far.png.meta b/assets/resources/textures/scenes/demon_castle/far.png.meta index 28ab9bf..ee96272 100644 --- a/assets/resources/textures/scenes/demon_castle/far.png.meta +++ b/assets/resources/textures/scenes/demon_castle/far.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "23a1bcbc-d250-4d63-9a28-738127c71789@f9941", + "displayName": "far", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "23a1bcbc-d250-4d63-9a28-738127c71789@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "23a1bcbc-d250-4d63-9a28-738127c71789@6c48a" diff --git a/assets/resources/textures/scenes/demon_castle/fx.png b/assets/resources/textures/scenes/demon_castle/fx.png index 7415a1d..4bf87e0 100644 Binary files a/assets/resources/textures/scenes/demon_castle/fx.png and b/assets/resources/textures/scenes/demon_castle/fx.png differ diff --git a/assets/resources/textures/scenes/demon_castle/fx.png.meta b/assets/resources/textures/scenes/demon_castle/fx.png.meta index 7d4f981..bd2463b 100644 --- a/assets/resources/textures/scenes/demon_castle/fx.png.meta +++ b/assets/resources/textures/scenes/demon_castle/fx.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "7ba84d2b-d60a-42c7-b7d9-4692b2283526@f9941", + "displayName": "fx", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -0.5, + "trimX": 0, + "trimY": 1, + "width": 480, + "height": 269, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -134.5, + 0, + 240, + -134.5, + 0, + -240, + 134.5, + 0, + 240, + 134.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 269, + 480, + 269, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 0.9962962962962963, + 1, + 0.9962962962962963 + ], + "minPos": [ + -240, + -134.5, + 0 + ], + "maxPos": [ + 240, + 134.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "7ba84d2b-d60a-42c7-b7d9-4692b2283526@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "7ba84d2b-d60a-42c7-b7d9-4692b2283526@6c48a" diff --git a/assets/resources/textures/scenes/demon_castle/mid.png b/assets/resources/textures/scenes/demon_castle/mid.png index 624e20d..dd2bb7b 100644 Binary files a/assets/resources/textures/scenes/demon_castle/mid.png and b/assets/resources/textures/scenes/demon_castle/mid.png differ diff --git a/assets/resources/textures/scenes/demon_castle/mid.png.meta b/assets/resources/textures/scenes/demon_castle/mid.png.meta index c45bab7..8499db0 100644 --- a/assets/resources/textures/scenes/demon_castle/mid.png.meta +++ b/assets/resources/textures/scenes/demon_castle/mid.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "db901ada-5c31-4820-b0bd-1990341ac44f@f9941", + "displayName": "mid", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0.5, + "offsetY": 2, + "trimX": 101, + "trimY": 56, + "width": 279, + "height": 154, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -139.5, + -77, + 0, + 139.5, + -77, + 0, + -139.5, + 77, + 0, + 139.5, + 77, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 101, + 214, + 380, + 214, + 101, + 60, + 380, + 60 + ], + "nuv": [ + 0.21041666666666667, + 0.2222222222222222, + 0.7916666666666666, + 0.2222222222222222, + 0.21041666666666667, + 0.7925925925925926, + 0.7916666666666666, + 0.7925925925925926 + ], + "minPos": [ + -139.5, + -77, + 0 + ], + "maxPos": [ + 139.5, + 77, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "db901ada-5c31-4820-b0bd-1990341ac44f@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "db901ada-5c31-4820-b0bd-1990341ac44f@6c48a" diff --git a/assets/resources/textures/scenes/demon_castle/near.png b/assets/resources/textures/scenes/demon_castle/near.png index 3f30bf6..6478152 100644 Binary files a/assets/resources/textures/scenes/demon_castle/near.png and b/assets/resources/textures/scenes/demon_castle/near.png differ diff --git a/assets/resources/textures/scenes/demon_castle/near.png.meta b/assets/resources/textures/scenes/demon_castle/near.png.meta index 524a93b..b9bbc6e 100644 --- a/assets/resources/textures/scenes/demon_castle/near.png.meta +++ b/assets/resources/textures/scenes/demon_castle/near.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "60456a45-0933-4a9f-b8bb-61cbf1761456@f9941", + "displayName": "near", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -83, + "trimX": 0, + "trimY": 166, + "width": 480, + "height": 104, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -52, + 0, + 240, + -52, + 0, + -240, + 52, + 0, + 240, + 52, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 104, + 480, + 104, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 0.3851851851851852, + 1, + 0.3851851851851852 + ], + "minPos": [ + -240, + -52, + 0 + ], + "maxPos": [ + 240, + 52, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "60456a45-0933-4a9f-b8bb-61cbf1761456@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "60456a45-0933-4a9f-b8bb-61cbf1761456@6c48a" diff --git a/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png b/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png new file mode 100644 index 0000000..7ce2409 Binary files /dev/null and b/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png differ diff --git a/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png.meta b/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png.meta new file mode 100644 index 0000000..66d432b --- /dev/null +++ b/assets/resources/textures/scenes/forest/ChatGPT Image 2026年5月27日 07_52_37.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "68a7b8ce-cdd1-440b-94cb-00ebc277252b", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "68a7b8ce-cdd1-440b-94cb-00ebc277252b@6c48a", + "displayName": "ChatGPT Image 2026年5月27日 07_52_37", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "68a7b8ce-cdd1-440b-94cb-00ebc277252b", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": false, + "redirect": "68a7b8ce-cdd1-440b-94cb-00ebc277252b@6c48a" + } +} diff --git a/assets/resources/textures/scenes/forest/far.png b/assets/resources/textures/scenes/forest/far.png index 4b928cf..4aaab35 100644 Binary files a/assets/resources/textures/scenes/forest/far.png and b/assets/resources/textures/scenes/forest/far.png differ diff --git a/assets/resources/textures/scenes/forest/far.png.meta b/assets/resources/textures/scenes/forest/far.png.meta index ed09423..6794993 100644 --- a/assets/resources/textures/scenes/forest/far.png.meta +++ b/assets/resources/textures/scenes/forest/far.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6@f9941", + "displayName": "far", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6@6c48a" diff --git a/assets/resources/textures/scenes/forest/fx.png b/assets/resources/textures/scenes/forest/fx.png index 7007475..12f03c4 100644 Binary files a/assets/resources/textures/scenes/forest/fx.png and b/assets/resources/textures/scenes/forest/fx.png differ diff --git a/assets/resources/textures/scenes/forest/fx.png.meta b/assets/resources/textures/scenes/forest/fx.png.meta index 02e234d..2587c94 100644 --- a/assets/resources/textures/scenes/forest/fx.png.meta +++ b/assets/resources/textures/scenes/forest/fx.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2@f9941", + "displayName": "fx", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2@6c48a" diff --git a/assets/resources/textures/scenes/forest/mid.png b/assets/resources/textures/scenes/forest/mid.png index 7676df9..26f07fd 100644 Binary files a/assets/resources/textures/scenes/forest/mid.png and b/assets/resources/textures/scenes/forest/mid.png differ diff --git a/assets/resources/textures/scenes/forest/mid.png.meta b/assets/resources/textures/scenes/forest/mid.png.meta index acf2e8f..b9e9a91 100644 --- a/assets/resources/textures/scenes/forest/mid.png.meta +++ b/assets/resources/textures/scenes/forest/mid.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "a77268f3-adc4-4859-a395-bb20202cccad@f9941", + "displayName": "mid", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 25, + "trimX": 0, + "trimY": 30, + "width": 480, + "height": 160, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -80, + 0, + 240, + -80, + 0, + -240, + 80, + 0, + 240, + 80, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 240, + 480, + 240, + 0, + 80, + 480, + 80 + ], + "nuv": [ + 0, + 0.2962962962962963, + 1, + 0.2962962962962963, + 0, + 0.8888888888888888, + 1, + 0.8888888888888888 + ], + "minPos": [ + -240, + -80, + 0 + ], + "maxPos": [ + 240, + 80, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "a77268f3-adc4-4859-a395-bb20202cccad@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "a77268f3-adc4-4859-a395-bb20202cccad@6c48a" diff --git a/assets/resources/textures/scenes/forest/near.png b/assets/resources/textures/scenes/forest/near.png index e4aac69..a807213 100644 Binary files a/assets/resources/textures/scenes/forest/near.png and b/assets/resources/textures/scenes/forest/near.png differ diff --git a/assets/resources/textures/scenes/forest/near.png.meta b/assets/resources/textures/scenes/forest/near.png.meta index a420db1..35c9b4d 100644 --- a/assets/resources/textures/scenes/forest/near.png.meta +++ b/assets/resources/textures/scenes/forest/near.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3@f9941", + "displayName": "near", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": -15.5, + "trimX": 0, + "trimY": 31, + "width": 480, + "height": 239, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -119.5, + 0, + 240, + -119.5, + 0, + -240, + 119.5, + 0, + 240, + 119.5, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 239, + 480, + 239, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 0.8851851851851852, + 1, + 0.8851851851851852 + ], + "minPos": [ + -240, + -119.5, + 0 + ], + "maxPos": [ + 240, + 119.5, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3@6c48a" diff --git a/assets/resources/textures/story/ch1_page1_ninja.png b/assets/resources/textures/story/ch1_page1_ninja.png index 6c36b36..6055965 100644 Binary files a/assets/resources/textures/story/ch1_page1_ninja.png and b/assets/resources/textures/story/ch1_page1_ninja.png differ diff --git a/assets/resources/textures/story/ch1_page1_ninja.png.meta b/assets/resources/textures/story/ch1_page1_ninja.png.meta index 653a96c..208f44c 100644 --- a/assets/resources/textures/story/ch1_page1_ninja.png.meta +++ b/assets/resources/textures/story/ch1_page1_ninja.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "647f87dd-0c4b-48c9-a6ab-b991424c683a@f9941", + "displayName": "ch1_page1_ninja", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "647f87dd-0c4b-48c9-a6ab-b991424c683a@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "647f87dd-0c4b-48c9-a6ab-b991424c683a@6c48a" diff --git a/assets/resources/textures/story/ch1_page2_princess.png b/assets/resources/textures/story/ch1_page2_princess.png index 3ae4433..338ee34 100644 Binary files a/assets/resources/textures/story/ch1_page2_princess.png and b/assets/resources/textures/story/ch1_page2_princess.png differ diff --git a/assets/resources/textures/story/ch1_page2_princess.png.meta b/assets/resources/textures/story/ch1_page2_princess.png.meta index d62e444..9efb97c 100644 --- a/assets/resources/textures/story/ch1_page2_princess.png.meta +++ b/assets/resources/textures/story/ch1_page2_princess.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "9074a6bf-d546-4e11-bd01-54d848018750@f9941", + "displayName": "ch1_page2_princess", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "9074a6bf-d546-4e11-bd01-54d848018750@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "9074a6bf-d546-4e11-bd01-54d848018750@6c48a" diff --git a/assets/resources/textures/story/ch1_page3_depart.png b/assets/resources/textures/story/ch1_page3_depart.png index ed535fb..2b2ac1f 100644 Binary files a/assets/resources/textures/story/ch1_page3_depart.png and b/assets/resources/textures/story/ch1_page3_depart.png differ diff --git a/assets/resources/textures/story/ch1_page3_depart.png.meta b/assets/resources/textures/story/ch1_page3_depart.png.meta index dc6cf2f..6e56869 100644 --- a/assets/resources/textures/story/ch1_page3_depart.png.meta +++ b/assets/resources/textures/story/ch1_page3_depart.png.meta @@ -31,10 +31,102 @@ ".json" ], "subMetas": {} + }, + "f9941": { + "importer": "sprite-frame", + "uuid": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4@f9941", + "displayName": "ch1_page3_depart", + "id": "f9941", + "name": "spriteFrame", + "userData": { + "trimThreshold": 1, + "rotated": false, + "offsetX": 0, + "offsetY": 0, + "trimX": 0, + "trimY": 0, + "width": 480, + "height": 270, + "rawWidth": 480, + "rawHeight": 270, + "borderTop": 0, + "borderBottom": 0, + "borderLeft": 0, + "borderRight": 0, + "packable": true, + "pixelsToUnit": 100, + "pivotX": 0.5, + "pivotY": 0.5, + "meshType": 0, + "vertices": { + "rawPosition": [ + -240, + -135, + 0, + 240, + -135, + 0, + -240, + 135, + 0, + 240, + 135, + 0 + ], + "indexes": [ + 0, + 1, + 2, + 2, + 1, + 3 + ], + "uv": [ + 0, + 270, + 480, + 270, + 0, + 0, + 480, + 0 + ], + "nuv": [ + 0, + 0, + 1, + 0, + 0, + 1, + 1, + 1 + ], + "minPos": [ + -240, + -135, + 0 + ], + "maxPos": [ + 240, + 135, + 0 + ] + }, + "isUuid": true, + "imageUuidOrDatabaseUri": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4@6c48a", + "atlasUuid": "", + "trimType": "auto" + }, + "ver": "1.0.12", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} } }, "userData": { - "type": "texture", + "type": "sprite-frame", "fixAlphaTransparencyArtifacts": false, "hasAlpha": true, "redirect": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4@6c48a" diff --git a/assets/scripts/data/ConfigMgr.ts b/assets/scripts/data/ConfigMgr.ts index aca565e..eb4a9ee 100644 --- a/assets/scripts/data/ConfigMgr.ts +++ b/assets/scripts/data/ConfigMgr.ts @@ -197,6 +197,19 @@ export class ConfigMgr { throw new Error(`ConfigMgr: level "${lv.id}" spawn references unknown enemy "${sp.type}"`); } } + if (lv.reinforcements) { + for (const rule of lv.reinforcements) { + if (!enemyIds.has(rule.type)) { + throw new Error(`ConfigMgr: level "${lv.id}" reinforcement references unknown enemy "${rule.type}"`); + } + if (rule.intervalSec <= 0) { + throw new Error(`ConfigMgr: level "${lv.id}" reinforcement intervalSec must be positive`); + } + if (rule.count <= 0) { + throw new Error(`ConfigMgr: level "${lv.id}" reinforcement count must be positive`); + } + } + } } } diff --git a/assets/scripts/data/Interfaces.ts b/assets/scripts/data/Interfaces.ts index 508d327..c7fb594 100644 --- a/assets/scripts/data/Interfaces.ts +++ b/assets/scripts/data/Interfaces.ts @@ -134,6 +134,22 @@ export interface ILevelObjective { bossId?: string; } +/** Reinforcement rule — enemies that jump in from screen edges during gameplay. */ +export interface IReinforcementRule { + /** Which enemy type to spawn as reinforcement. */ + type: EnemyType; + /** Minimum interval (seconds) between two reinforcement waves of this rule. */ + intervalSec: number; + /** How many enemies to spawn per reinforcement wave. */ + count: number; + /** Maximum total reinforcements of this rule per level (0 = unlimited). */ + maxTotal?: number; + /** Which edge(s) the enemies appear from. */ + edge: 'left' | 'right' | 'both'; + /** Minimum elapsed seconds before this rule becomes active. */ + delaySec?: number; +} + export interface ILevelConfig { id: string; // e.g. '1-1' chapter: 1 | 2 | 3; @@ -150,6 +166,8 @@ export interface ILevelConfig { bgm: string; /** Enemy spawn list evaluated by the LevelMgr. */ enemySpawns: Array<{ type: EnemyType; atPx: number; count?: number }>; + /** Dynamic reinforcement rules — enemies jump in from screen edges. */ + reinforcements?: IReinforcementRule[]; } // --------------------------------------------------------------------------- diff --git a/assets/scripts/logic/BossController.ts b/assets/scripts/logic/BossController.ts index 10a9090..a4d3ebf 100644 --- a/assets/scripts/logic/BossController.ts +++ b/assets/scripts/logic/BossController.ts @@ -65,8 +65,12 @@ export class BossController { if (!this.butterflyRevealed) return []; if (this.killed) return []; const out: BossOutcomeEvent[] = []; + this.hp = Math.max(0, this.hp - 1); - out.push(...this.checkPhaseTransition(), ...this.checkPrincessCutscene()); + // Check phase transition AFTER decrementing HP so the ratio + // reflects the new HP (e.g. 3→2 = 2/3). + const hpRatio = this.hp / this.cfg.hp; + out.push(...this.checkPhaseTransitionAtRatio(hpRatio), ...this.checkPrincessCutscene()); if (this.hp === 0) { this.killed = true; out.push({ kind: 'boss_killed' }); @@ -76,8 +80,7 @@ export class BossController { // -------------------------------------------------------------------- - private checkPhaseTransition(): BossOutcomeEvent[] { - const hpRatio = this.hp / this.cfg.hp; + private checkPhaseTransitionAtRatio(hpRatio: number): BossOutcomeEvent[] { for (let i = this.phaseIndex + 1; i < this.cfg.phases.length; i++) { if (hpRatio <= this.cfg.phases[i].hpThreshold) { this.phaseIndex = i; diff --git a/assets/scripts/logic/EnemyAI.ts b/assets/scripts/logic/EnemyAI.ts index 828b835..7bc2147 100644 --- a/assets/scripts/logic/EnemyAI.ts +++ b/assets/scripts/logic/EnemyAI.ts @@ -1,4 +1,4 @@ -import { AttackType, EnemyType, IEnemyConfig } from '../data/Interfaces'; +import { AttackType, EnemyType, IEnemyConfig, IReinforcementRule } from '../data/Interfaces'; /** * Enemy AI base class + four concrete subclasses (req 6.1-6.7). @@ -47,6 +47,7 @@ export abstract class EnemyAIBase { public readonly type: EnemyType; public pos: { x: number; y: number }; public alive = true; + public hp: number; protected cooldownSec = 0; protected readonly cfg: IEnemyConfig; @@ -54,6 +55,7 @@ export abstract class EnemyAIBase { this.cfg = cfg; this.type = cfg.id; this.pos = { x: spawnX, y: spawnY }; + this.hp = cfg.hp; } public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[]; @@ -197,15 +199,18 @@ export class EnemyManager { } /** - * Update all live enemies that intersect `cull`. Returns the concatenated - * list of actions emitted so the caller (LevelMgr) can instantiate - * projectiles, drops, etc. + * Update all live enemies that intersect `cull` (with optional margin). + * Returns the concatenated list of actions emitted so the caller + * (LevelMgr) can instantiate projectiles, drops, etc. + * + * @param cullingMargin Extra pixels beyond `cull` to still activate enemies. + * Used for reinforcements that spawn just outside the screen. */ - public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] { + public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect, cullingMargin = 0): IEnemyAction[] { const actions: IEnemyAction[] = []; for (const e of this.enemies) { if (!e.alive) continue; - if (!this.inside(e, cull)) continue; + if (!this.inside(e, cull, cullingMargin)) continue; const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player }; actions.push(...e.update(ctx)); } @@ -222,12 +227,145 @@ export class EnemyManager { this.enemies.length = 0; } - private inside(e: EnemyAIBase, cull: ICullingRect): boolean { + private inside(e: EnemyAIBase, cull: ICullingRect, margin = 0): boolean { return ( - e.pos.x + e.aabb.w / 2 >= cull.leftX && - e.pos.x - e.aabb.w / 2 <= cull.rightX && - e.pos.y + e.aabb.h / 2 >= cull.bottomY && - e.pos.y - e.aabb.h / 2 <= cull.topY + e.pos.x + e.aabb.w / 2 >= cull.leftX - margin && + e.pos.x - e.aabb.w / 2 <= cull.rightX + margin && + e.pos.y + e.aabb.h / 2 >= cull.bottomY - margin && + e.pos.y - e.aabb.h / 2 <= cull.topY + margin ); } } + +// ---------- Reinforcement Scheduler (dynamic edge spawns) ----------------- + +/** An enemy spawned by the reinforcement scheduler, with a jump-in arc. */ +export interface IReinforcementSpawn { + enemy: EnemyAIBase; + /** The edge the enemy is jumping in from. */ + edge: 'left' | 'right'; +} + +/** + * Manages periodic reinforcement waves — enemies that jump in from screen + * edges even when the player is standing still. Each rule specifies an + * enemy type, interval, count, and which edge(s) they appear from. + * + * Usage: call `tick()` every frame. It returns newly-spawned enemies that + * the caller must register with `EnemyManager.spawn()` and create visual + * nodes for. + */ +export class ReinforcementScheduler { + private readonly timers = new Map(); + private readonly totals = new Map(); + + constructor(private readonly rules: IReinforcementRule[]) {} + + /** + * Tick the scheduler. Returns enemies that should be spawned this frame. + * The caller is responsible for: + * 1. Passing the new enemies to `EnemyManager.spawn()` + * 2. Creating visual nodes for them + * 3. Calling `EnemyManager.update()` as usual + * + * @param dtSec Frame delta in seconds. + * @param elapsedSec Total elapsed seconds since level start. + * @param cull Current camera culling rect (used to determine edge positions). + * @param groundY Ground Y in physics coords (feet surface). + * @param enemyCfgFactory A function that returns the IEnemyConfig for a given EnemyType. + */ + public tick( + dtSec: number, + elapsedSec: number, + cull: ICullingRect, + groundY: number, + enemyCfgFactory: (type: EnemyType) => { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } }, + ): IReinforcementSpawn[] { + const spawns: IReinforcementSpawn[] = []; + + for (let i = 0; i < this.rules.length; i++) { + const rule = this.rules[i]; + + // Check delay + if (rule.delaySec && elapsedSec < rule.delaySec) continue; + + // Check max total + const total = this.totals.get(i) ?? 0; + if (rule.maxTotal && rule.maxTotal > 0 && total >= rule.maxTotal) continue; + + // Accumulate timer + const timer = this.timers.get(i) ?? 0; + const newTimer = timer + dtSec; + this.timers.set(i, newTimer); + + if (newTimer < rule.intervalSec) continue; + + // Reset timer (keep remainder for smoother intervals) + this.timers.set(i, newTimer - rule.intervalSec); + + // Determine edge(s) + const edges: Array<'left' | 'right'> = rule.edge === 'both' + ? (Math.random() < 0.5 ? ['left'] : ['right']) + : [rule.edge]; + + for (const edge of edges) { + for (let j = 0; j < rule.count; j++) { + const cfg = enemyCfgFactory(rule.type); + // Spawn position: just outside the culling rect on the chosen edge. + // Moving enemies (moveSpeed > 0) start farther out and walk in; + // stationary enemies (YaoFang) start just inside the visible edge. + const margin = cfg.moveSpeed > 0 ? 60 : -30; // negative = inside screen + const spawnX = edge === 'left' + ? cull.leftX - margin - j * 50 + : cull.rightX + margin + j * 50; + const spawnY = groundY + cfg.size.h / 2; + + const enemy = createReinforcementEnemy(rule.type, cfg, spawnX, spawnY, edge); + spawns.push({ enemy, edge }); + this.totals.set(i, (this.totals.get(i) ?? 0) + 1); + } + } + } + + return spawns; + } + + /** Reset all timers and counters (e.g. on level restart). */ + public reset(): void { + this.timers.clear(); + this.totals.clear(); + } +} + +/** + * Create an enemy AI instance for a reinforcement spawn. + * Reuses the same AI classes as static spawns. The enemy spawns at ground + * level on the chosen edge; moving enemies (ChiRen) will walk into the + * screen automatically, while stationary enemies (YaoFang) are placed + * just inside the visible edge so they're immediately active. + */ +function createReinforcementEnemy( + type: EnemyType, + cfg: { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } }, + spawnX: number, + spawnY: number, + _edge: 'left' | 'right', +): EnemyAIBase { + const fullCfg = { + id: type, + displayName: type, + size: cfg.size, + moveSpeed: cfg.moveSpeed, + attackIntervalSec: cfg.attackIntervalSec, + attacks: [] as never[], + hp: type === EnemyType.HeiRen ? 2 : 1, + }; + + switch (type) { + case EnemyType.ChiRen: return new ChiRenAI(fullCfg, spawnX, spawnY); + case EnemyType.HeiRen: return new HeiRenAI(fullCfg, spawnX, spawnY); + case EnemyType.YaoFang: return new YaoFangAI(fullCfg, spawnX, spawnY); + case EnemyType.QingRen: + default: return new QingRenAI(fullCfg, spawnX, spawnY); + } +} diff --git a/assets/scripts/logic/JumpController.ts b/assets/scripts/logic/JumpController.ts index f786a0a..9a23810 100644 --- a/assets/scripts/logic/JumpController.ts +++ b/assets/scripts/logic/JumpController.ts @@ -80,13 +80,16 @@ export class JumpController { /** Called on `jumpPressed` UI event. */ public pressJump(nowMs: number): IJumpDispatchResult { if (!this.motion.isGrounded) { + console.log('[JumpController] pressJump REJECTED — airborne, phase=', this.phase); return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' }; } if (this.phase !== 'idle') { + console.log('[JumpController] pressJump REJECTED — phase=', this.phase, ', not idle'); return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' }; } this.phase = 'charging'; this.pressTs = nowMs; + console.log('[JumpController] pressJump ACCEPTED — entering charging phase'); return { phase: this.phase, height: 0, horizontalImpulse: 0 }; } diff --git a/assets/scripts/logic/PlayerMotionModel.ts b/assets/scripts/logic/PlayerMotionModel.ts index a630e49..2bccadf 100644 --- a/assets/scripts/logic/PlayerMotionModel.ts +++ b/assets/scripts/logic/PlayerMotionModel.ts @@ -44,6 +44,8 @@ export interface IPlayerMotionOptions { platforms: IPlatform[]; /** Starting color state. */ initialColorState?: PlayerColorState; + /** Level horizontal extent (px). Used to clamp player X so they cannot leave the level. */ + levelLengthPx: number; } export const DEFAULT_GRAVITY = 2500; // px/s² — tuned so a 250px jump peaks in ~0.45s @@ -63,12 +65,14 @@ export class PlayerMotionModel { private _aabb: IAxisAlignedBox; private _platforms: IPlatform[]; private readonly gravity: number; + private readonly _levelLengthPx: number; constructor(options: IPlayerMotionOptions) { this._aabb = { ...options.aabb }; this._platforms = options.platforms.slice(); this._colorState = options.initialColorState ?? PlayerColorState.Red; this.gravity = options.gravity ?? DEFAULT_GRAVITY; + this._levelLengthPx = options.levelLengthPx; } // -- accessors ---------------------------------------------------------- @@ -130,6 +134,9 @@ export class PlayerMotionModel { * ground; mid-air `_vx` is preserved (起跳定型). */ public update(dt: number): void { + // Remember feet position before integration (for sweep test). + const prevFeetY = this._aabb.y - this._aabb.h / 2; + if (this._grounded) { this._vx = this._horizontalInput * MOVE_SPEED[this._colorState]; this._vy = 0; @@ -143,16 +150,34 @@ export class PlayerMotionModel { x: this._aabb.x + this._vx * dt, y: this._aabb.y + this._vy * dt, }; - // Resolve against platforms (basic AABB vs. top-surface only). + + const curFeetY = this._aabb.y - this._aabb.h / 2; + + // Resolve against platforms using sweep test. + // If the feet crossed a platform surface this frame (prev above → cur below), + // snap the player onto that platform — prevents tunneling at high fall speeds. this._grounded = false; for (const p of this._platforms) { - if (this.isRestingOn(p)) { + const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX; + if (!withinHorizontal) continue; + if (this._vy > 0) continue; // moving upward — cannot land + + // Sweep test: feet were above topY last frame, now at or below topY. + const crossedSurface = prevFeetY >= p.topY - 0.5 && curFeetY <= p.topY + 0.5; + // Also catch the small-window case (slow fall, feet near surface). + const nearSurface = curFeetY <= p.topY + 0.5 && curFeetY >= p.topY - 6; + + if (crossedSurface || nearSurface) { this._grounded = true; this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 }; if (this._vy < 0) this._vy = 0; break; } } + + // Clamp AABB X within level boundaries so the player cannot leave the level. + const halfW = this._aabb.w / 2; + this._aabb.x = Math.max(halfW, Math.min(this._aabb.x, this._levelLengthPx - halfW)); } // -- helpers ------------------------------------------------------------ diff --git a/assets/scripts/logic/PlayerStateMachine.ts b/assets/scripts/logic/PlayerStateMachine.ts index b3564e2..975244d 100644 --- a/assets/scripts/logic/PlayerStateMachine.ts +++ b/assets/scripts/logic/PlayerStateMachine.ts @@ -36,7 +36,7 @@ export interface IPlayerState { export class PlayerStateMachine { private state: IPlayerState; - constructor(initialLives = 3) { + constructor(initialLives = 1) { this.state = { color: PlayerColorState.Red, lives: initialLives, @@ -135,8 +135,11 @@ export class PlayerStateMachine { this.startIFrames(); if (this.state.lives === 0) { this.state.isDead = true; + return { kind: 'died', cause }; } - return { kind: 'died', cause }; + // Lost a life but still alive — treat as a downgrade so callers + // can distinguish real death from a mere life-loss. + return { kind: 'downgraded', from: this.state.color, to: PlayerColorState.Red }; } private startIFrames(): void { diff --git a/assets/scripts/scene_entries/LevelEntry.ts b/assets/scripts/scene_entries/LevelEntry.ts index 316cb91..18aa975 100644 --- a/assets/scripts/scene_entries/LevelEntry.ts +++ b/assets/scripts/scene_entries/LevelEntry.ts @@ -1,21 +1,102 @@ -import { _decorator, Component, director, Color, Label, Node } from 'cc'; +import { _decorator, Component, director, Color, Graphics, Label, Node, Sprite, UITransform, Vec3 } from 'cc'; import { ConfigMgr } from '../data/ConfigMgr'; import { CCJsonLoader } from './CCJsonLoader'; import { LevelMgr } from '../logic/LevelMgr'; -import { ensureCanvasSize, createLabel } from './MainMenuEntry'; -import { DESIGN_HEIGHT } from '../common/Constants'; +import { + ensureCanvasSize, + createLabel, + createSprite, + createSpriteSheetFrame, +} from './MainMenuEntry'; +import { + DESIGN_WIDTH, + DESIGN_HEIGHT, + PlayerColorState, +} from '../common/Constants'; +import { globalEventBus, globalTimeMgr } from '../common/index'; +import { PlayerMotionModel, HorizontalInput, IPlatform } from '../logic/PlayerMotionModel'; +import { JumpController } from '../logic/JumpController'; +import { PlayerStateMachine } from '../logic/PlayerStateMachine'; +import { AttackController, IJumpStateProvider, IAttackDispatchEvent } from '../logic/AttackController'; +import { EnemyAIBase, EnemyManager, QingRenAI, ChiRenAI, HeiRenAI, YaoFangAI, IEnemyAction, ICullingRect, ReinforcementScheduler, IReinforcementSpawn } from '../logic/EnemyAI'; +import { CameraScroller, cameraFromLevel, PARALLAX_LAYERS, ParallaxLayer } from '../logic/CameraScroller'; +import { DamageSystem } from '../logic/DamageSystem'; +import { InputEvents } from '../ui/InputEvents'; +import { JoystickAngleClass, DEFAULT_LAYOUT } from '../ui/InputModel'; +import { FloatingControlLayer } from '../ui/FloatingControlLayer'; +import { EnemyType, WeaponType, IReinforcementRule } from '../data/Interfaces'; const { ccclass, property } = _decorator; +// --------------------------------------------------------------------------- +// Coordinate conventions +// --------------------------------------------------------------------------- +// PlayerMotionModel uses +y-up (physics). Cocos Creator uses +y-down +// (render). The design resolution origin is at the centre of the 960×540 +// canvas. We convert: cocosY = -physicsY (relative to canvas centre). +// --------------------------------------------------------------------------- + +/** Ground platform Y in physics coords (+y up). */ +const GROUND_Y = 110; +/** Hero spawn X in physics coords. */ +const HERO_SPAWN_X = 120; + +/** Visual scale applied to the hero sprite (16×32 → 48×96). */ +const HERO_SCALE = 3; +/** Visual scale applied to enemy sprites (16×16 → 48×48). */ +const ENEMY_SCALE = 3; + +/** + * Map an EnemyType to its sprite-sheet resource path and frame dimensions. + */ +function enemySpriteInfo(type: EnemyType): { res: string; fw: number; fh: number } { + switch (type) { + case EnemyType.QingRen: return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 }; + case EnemyType.ChiRen: return { res: 'textures/enemies/chi_ren', fw: 16, fh: 16 }; + case EnemyType.HeiRen: return { res: 'textures/enemies/hei_ren', fw: 20, fh: 24 }; + case EnemyType.YaoFang: return { res: 'textures/enemies/yao_fang', fw: 18, fh: 20 }; + default: return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 }; + } +} + +/** Map an EnemyType enum to the concrete AI class. */ +function createEnemyAI(type: EnemyType, cfg: { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } }, spawnX: number, spawnY: number): EnemyAIBase { + const fullCfg = { id: type, displayName: type, size: cfg.size, moveSpeed: cfg.moveSpeed, attackIntervalSec: cfg.attackIntervalSec, attacks: [] as never[], hp: 1 }; + switch (type) { + case EnemyType.ChiRen: return new ChiRenAI(fullCfg, spawnX, spawnY); + case EnemyType.HeiRen: return new HeiRenAI(fullCfg, spawnX, spawnY); + case EnemyType.YaoFang: return new YaoFangAI(fullCfg, spawnX, spawnY); + case EnemyType.QingRen: + default: return new QingRenAI(fullCfg, spawnX, spawnY); + } +} + +// --------------------------------------------------------------------------- +// JumpStateProvider — bridges JumpController ↔ PlayerMotionModel +// --------------------------------------------------------------------------- +class JumpStateProvider implements IJumpStateProvider { + private _lastJumpPressTs: number | undefined; + + public setLastJumpPressTs(ts: number | undefined): void { + this._lastJumpPressTs = ts; + } + + public lastJumpPressTs(): number | undefined { + return this._lastJumpPressTs; + } + + constructor(private readonly motion: PlayerMotionModel) {} + + public isGrounded(): boolean { + return this.motion.isGrounded; + } +} + /** * Generic Level scene entry (task 7.1-7.2 hookup). * - * Attach to the root node of `Level_1_1` … `Level_1_5`. Configure the - * `levelId` property in the Inspector ("1-1", "1-2", ...). - * - * For MVP the scene has no real gameplay rendering yet; `autoBuildUI` - * simply draws a top-centered label with the current level id so you can - * verify scene transitions by eye. + * Now fully wired: input events → logic models → scene node positions, + * so both the player and enemies move in real-time. */ @ccclass('LevelEntry') export class LevelEntry extends Component { @@ -28,27 +109,821 @@ export class LevelEntry extends Component { @property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' }) public autoBuildUI: boolean = true; - private mgr: LevelMgr | undefined; + // -- logic models ------------------------------------------------------- + private mgr!: LevelMgr; + private cfg!: ConfigMgr; + private motion!: PlayerMotionModel; + private jumpCtrl!: JumpController; + private psm!: PlayerStateMachine; + private attackCtrl!: AttackController; + private jumpStateProvider!: JumpStateProvider; + private enemyMgr!: EnemyManager; + private camera!: CameraScroller; + private dmgSystem!: DamageSystem; + private reinforcementScheduler!: ReinforcementScheduler; + private levelElapsedSec = 0; + + // -- view nodes --------------------------------------------------------- private hudNode: Node | null = null; + private heroNode: Node | null = null; + /** Enemy AI → visual node mapping. */ + private enemyNodes = new Map(); + /** Parallax background nodes keyed by layer name. */ + private bgNodes = new Map(); + /** Projectile visual nodes (shuriken, fireball, etc.). */ + private projectileNodes: Node[] = []; + + // -- input state -------------------------------------------------------- + private currentJoystickKlass: JoystickAngleClass = 'none'; + private currentHorizontalInput: HorizontalInput = 0; + + // -- event handler refs (for cleanup) ----------------------------------- + private boundHandlers: Array<[string, (payload: any) => void]> = []; + private _deathHandled = false; + /** Reference to the FloatingControlLayer container node — must remain the + * top-most sibling so dynamically-added sprites (projectiles, slashes, + * re-created hero) never occlude touch input. */ + private ctrlLayerNode: Node | null = null; protected async onLoad(): Promise { + // 1. Load configs + this.cfg = new ConfigMgr(new CCJsonLoader()); + await this.cfg.load(); + const levelCfg = this.cfg.level(this.levelId); + + // 0. Reset global singletons that survive across scene loads. + // Placed AFTER await so update()-driven accumulation during the + // async gap does not negate the reset. + globalTimeMgr.reset(); + + this.mgr = new LevelMgr(levelCfg); + + // 2. Build platforms (simple ground + level-specific platforms) + const platforms = this.buildPlatforms(); + + // 3. Create logic models + // AABB.y is the **centre** of the box; feet must align with the + // ground surface (topY = GROUND_Y), so centre = GROUND_Y + h/2. + const HERO_H = 32; + this.motion = new PlayerMotionModel({ + aabb: { x: HERO_SPAWN_X, y: GROUND_Y + HERO_H / 2, w: 16, h: HERO_H }, + platforms, + initialColorState: PlayerColorState.Red, + levelLengthPx: levelCfg.levelLengthPx, + }); + this.psm = new PlayerStateMachine(1); + this.jumpStateProvider = new JumpStateProvider(this.motion); + this.jumpCtrl = new JumpController(this.motion); + this.attackCtrl = new AttackController(this.jumpStateProvider); + this.dmgSystem = new DamageSystem(this.psm); + this.camera = cameraFromLevel(levelCfg); + this.enemyMgr = new EnemyManager(); + + // 4. Spawn enemies from level config + for (const spawn of levelCfg.enemySpawns) { + const enemyCfg = this.cfg.enemy(spawn.type); + // Enemy pos.y = centre; feet must sit on ground (topY = GROUND_Y). + const spawnY = GROUND_Y + enemyCfg.size.h / 2; + const count = spawn.count ?? 1; + for (let i = 0; i < count; i++) { + const ai = createEnemyAI(spawn.type, enemyCfg, spawn.atPx + i * 60, spawnY); + this.enemyMgr.spawn(ai); + } + } + + // 4b. Initialize reinforcement scheduler from level config + this.reinforcementScheduler = new ReinforcementScheduler(levelCfg.reinforcements ?? []); + + // 5. Build UI & sprites if (this.autoBuildUI) this.buildDefaultUI(); - const cfg = new ConfigMgr(new CCJsonLoader()); - await cfg.load(); - this.mgr = new LevelMgr(cfg.level(this.levelId)); + + // 6. Add FloatingControlLayer + this.addControlLayer(); + + // 7. Subscribe to input events + this.subscribeInputEvents(); } protected update(dt: number): void { - if (!this.mgr) return; - const status = this.mgr.tick(dt); + // 1. Update time + globalTimeMgr.update(dt); + const scaledDt = globalTimeMgr.scaledDelta(dt); + if (scaledDt <= 0) return; // paused + + // Guard: onLoad is async — models may not be ready yet. + if (!this.attackCtrl || !this.psm) return; + + // 11. Early-out: if the player died in a previous frame, skip all + // gameplay logic (motion, attacks, enemies) and go straight to + // the death → scene-transition path. + if (this.psm.isDead) { + if (!this._deathHandled) { + const deathStatus = this.mgr.tick(scaledDt); + this.refreshHud(); + if (deathStatus === 'player_dead') { + this._deathHandled = true; + this.attackCtrl.reset(); + this.unsubscribeInputEvents(); + director.loadScene('Settlement'); + } + // If deathStatus is still 'running', onPlayerDied() was just + // called in the previous frame's step-11 but LevelMgr hasn't + // propagated the state yet. _deathHandled stays false so we + // retry next frame. + } + return; + } + + const nowMs = globalTimeMgr.realTime * 1000; + + // 2. Tick player motion FIRST so that isGrounded is up-to-date + // when jumpCtrl.tick reads it (fixes "cannot jump after landing" + // bug — jumpCtrl was reading stale isGrounded from previous frame). + this.motion.setHorizontalInput(this.currentHorizontalInput); + this.motion.update(scaledDt); + this.jumpCtrl.tick(nowMs); + + // 3. Tick player state machine (i-frames) + this.psm.tick(scaledDt); + + // 4. Tick attack controller + const attacks = this.attackCtrl.tick(nowMs, this.psm.color); + + // 5. Tick enemies + const playerSense = { + x: this.motion.aabb.x, + y: this.motion.aabb.y, + isGrounded: this.motion.isGrounded, + }; + const cull = this.camera.cullRect(); + const CULLING_MARGIN = 100; // allow enemies just outside screen to still update AI (reinforcements) + const enemyActions = this.enemyMgr.update(scaledDt, nowMs, playerSense, cull as ICullingRect, CULLING_MARGIN); + + // 6. Process enemy actions (spawn projectiles, etc.) + this.processEnemyActions(enemyActions); + + // 6b. Tick reinforcement scheduler — spawn enemies from screen edges + this.levelElapsedSec += scaledDt; + const newReinforcements = this.reinforcementScheduler.tick( + scaledDt, + this.levelElapsedSec, + cull as ICullingRect, + GROUND_Y, + (type: EnemyType) => this.cfg.enemy(type), + ); + for (const r of newReinforcements) { + this.enemyMgr.spawn(r.enemy); + // Create visual node for the reinforcement enemy + const info = enemySpriteInfo(r.enemy.type); + const ePos = this.physicsToCocos(r.enemy.pos.x, r.enemy.pos.y); + const node = createSpriteSheetFrame( + this.node, + info.res, + 0, + info.fw, info.fh, + ePos.x, ePos.y, + info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE, + { name: `Reinforce_${r.enemy.type}`, flipX: r.edge === 'right' }, + ); + this.enemyNodes.set(r.enemy, node); + this.promoteCtrlLayerToTop(); + } + + // 7. Process player attacks (spawn projectiles) + this.processPlayerAttacks(attacks); + + // 8. Camera follow + this.camera.followPlayer(this.motion.aabb.x, this.motion.aabb.y); + + // 9. Tick level manager + const status = this.mgr.tick(scaledDt); + + // 10. Sync all visual nodes + this.syncHeroNode(); + this.syncEnemyNodes(); + this.syncParallax(); + this.syncProjectiles(scaledDt); this.refreshHud(); + + // Keep the FloatingControlLayer as the top-most sibling so touch + // events on attack/jump buttons are never shadowed by dynamically + // spawned sprites (projectiles / slash FX / re-created hero). + this.promoteCtrlLayerToTop(); + + // 11. Check game-over conditions + if (this.psm.isDead) { + this.mgr.onPlayerDied(); + } if (status === 'victory') { + this.unsubscribeInputEvents(); director.loadScene(this.nextSceneName || this.deriveNextScene()); } else if (status === 'timeout' || status === 'player_dead') { + this.unsubscribeInputEvents(); director.loadScene('Settlement'); } } + protected onDestroy(): void { + this.attackCtrl?.reset(); + this.unsubscribeInputEvents(); + } + + // ====================================================================== + // Platform construction + // ====================================================================== + + private buildPlatforms(): IPlatform[] { + const platforms: IPlatform[] = []; + // Ground platform — spans the entire level length + const levelCfg = this.cfg.level(this.levelId); + platforms.push({ + topY: GROUND_Y, + leftX: 0, + rightX: levelCfg.levelLengthPx, + }); + // TODO: Add elevated platforms per level layout when available. + return platforms; + } + + // ====================================================================== + // UI construction + // ====================================================================== + + private buildDefaultUI(): void { + ensureCanvasSize(this.node); + + // Parallax background (4 layers). + const theme = this.pickThemeFolder(); + for (const layer of PARALLAX_LAYERS) { + const node = createSprite( + this.node, + `textures/scenes/${theme}/${layer}`, + 0, 0, + DESIGN_WIDTH, DESIGN_HEIGHT, + { name: `BG_${layer}` }, + ); + this.bgNodes.set(layer, node); + } + + // Hero sprite (first idle frame of kage_red, 16×32 upscaled ×3). + const heroPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y); + this.heroNode = createSpriteSheetFrame( + this.node, + 'textures/characters/kage_red', + 0, + 16, 32, + heroPos.x, heroPos.y, + 16 * HERO_SCALE, 32 * HERO_SCALE, + { name: 'Hero' }, + ); + + // Enemy sprites — one per spawned enemy. + for (const enemy of this.enemyMgr.all) { + const info = enemySpriteInfo(enemy.type); + const ePos = this.physicsToCocos(enemy.pos.x, enemy.pos.y); + const node = createSpriteSheetFrame( + this.node, + info.res, + 0, + info.fw, info.fh, + ePos.x, ePos.y, + info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE, + { name: `Enemy_${enemy.type}`, flipX: true }, + ); + this.enemyNodes.set(enemy, node); + } + + // HUD text. + createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE); + this.hudNode = createLabel( + this.node, + 'Time: --', + 0, + DESIGN_HEIGHT / 2 - 90, + 22, + new Color(255, 220, 120, 255), + ); + } + + /** Add the FloatingControlLayer component on a full-screen UI child node. */ + private addControlLayer(): void { + console.log('[LevelEntry] addControlLayer start — this.node=', this.node?.name, 'isValid=', this.node?.isValid); + const ctrlNode = new Node('FloatingControlLayer'); + ctrlNode.layer = this.node.layer; + this.node.addChild(ctrlNode); + this.ctrlLayerNode = ctrlNode; + const ut = ctrlNode.addComponent(UITransform); + ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT); + ctrlNode.setPosition(new Vec3(0, 0, 0)); + + // Create visual sub-nodes for joystick and buttons. + const fcl = ctrlNode.addComponent(FloatingControlLayer); + console.log('[LevelEntry] addControlLayer — FloatingControlLayer component added. fcl=', !!fcl, 'ctrlNode.isValid=', ctrlNode.isValid); + const layout = DEFAULT_LAYOUT; + + // Joystick: background disc + handle. + fcl.joystickRoot = this.createJoystickVisual(ctrlNode, layout.joystick); + // Jump button. + fcl.jumpRoot = this.createButtonVisual(ctrlNode, layout.jump, 'J', new Color(80, 200, 80, 180)); + // Shuriken button. + fcl.shurikenRoot = this.createButtonVisual(ctrlNode, layout.shuriken, 'S', new Color(80, 120, 220, 180)); + // Ninja-sword button. + fcl.ninjaSwordRoot = this.createButtonVisual(ctrlNode, layout.ninjaSword, 'K', new Color(220, 80, 80, 180)); + console.log('[LevelEntry] addControlLayer done — visuals built'); + } + + /** + * Ensure the FloatingControlLayer is always the top-most sibling of + * `this.node`. Dynamically-spawned sprites (projectiles / slash FX / + * re-created hero sprite) are appended to the end of the sibling list + * by default, pushing the ctrl layer down the Z-order. When that + * happens, touch events on attack/jump buttons can be intercepted by + * those sprites' UITransform bounding boxes — producing the + * "buttons occasionally unresponsive" symptom. + * + * Called in `update()` every frame as a cheap safety net. + */ + private promoteCtrlLayerToTop(): void { + const ctrl = this.ctrlLayerNode as any; + if (!ctrl || !ctrl.isValid) return; + const parent = ctrl.parent; + if (!parent) return; + const lastIndex = parent.children.length - 1; + if (ctrl.getSiblingIndex() !== lastIndex) { + ctrl.setSiblingIndex(lastIndex); + } + } + + /** Create a joystick visual (semi-transparent disc + smaller handle). */ + private createJoystickVisual(parent: Node, rect: { cx: number; cy: number; w: number; h: number }): Node { + const root = new Node('JoystickRoot'); + root.layer = parent.layer; + parent.addChild(root); + const rootUt = root.addComponent(UITransform); + rootUt.setContentSize(rect.w, rect.h); + root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0)); + + // Background disc. + const bg = new Node('JoystickBg'); + bg.layer = parent.layer; + root.addChild(bg); + const bgUt = bg.addComponent(UITransform); + const bgRadius = rect.w / 2; + bgUt.setContentSize(rect.w, rect.h); + const bgGfx = bg.addComponent(Graphics); + bgGfx.fillColor = new Color(255, 255, 255, 50); + bgGfx.strokeColor = new Color(255, 255, 255, 100); + bgGfx.circle(0, 0, bgRadius); + bgGfx.fill(); + bgGfx.stroke(); + + // Handle (smaller circle, offset to show direction). + const handle = new Node('JoystickHandle'); + handle.layer = parent.layer; + root.addChild(handle); + const handleRadius = bgRadius * 0.4; + const handleUt = handle.addComponent(UITransform); + handleUt.setContentSize(handleRadius * 2, handleRadius * 2); + const handleGfx = handle.addComponent(Graphics); + handleGfx.fillColor = new Color(255, 255, 255, 140); + handleGfx.strokeColor = new Color(255, 255, 255, 200); + handleGfx.circle(0, 0, handleRadius); + handleGfx.fill(); + handleGfx.stroke(); + + return root; + } + + /** Create a simple circular button with a text label. */ + private createButtonVisual( + parent: Node, + rect: { cx: number; cy: number; w: number; h: number }, + label: string, + tint: Color, + ): Node { + const root = new Node(`Btn_${label}`); + root.layer = parent.layer; + parent.addChild(root); + const radius = Math.min(rect.w, rect.h) / 2; + const rootUt = root.addComponent(UITransform); + rootUt.setContentSize(rect.w, rect.h); + root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0)); + + // Circle background. + const bg = new Node('Bg'); + bg.layer = parent.layer; + root.addChild(bg); + const bgUt = bg.addComponent(UITransform); + bgUt.setContentSize(rect.w, rect.h); + const bgGfx = bg.addComponent(Graphics); + bgGfx.fillColor = tint; + bgGfx.strokeColor = new Color(255, 255, 255, 120); + bgGfx.circle(0, 0, radius); + bgGfx.fill(); + bgGfx.stroke(); + + // Text label. + const lblNode = new Node('Label'); + lblNode.layer = parent.layer; + root.addChild(lblNode); + const lblUt = lblNode.addComponent(UITransform); + lblUt.setContentSize(rect.w, rect.h); + const lbl = lblNode.addComponent(Label); + lbl.string = label; + lbl.fontSize = 28; + lbl.lineHeight = 28; + lbl.color = new Color(255, 255, 255, 220); + + return root; + } + + // ====================================================================== + // Input event subscription + // ====================================================================== + + private subscribeInputEvents(): void { + // Defensive: clear any stale handlers left by a previous LevelEntry + // instance that was destroyed before unsubscribeInputEvents() ran. + // Only clear the events that LevelEntry registers — not ButtonVisualChanged + // or other events that might be registered by other components. + const eventsToClear = [ + InputEvents.JoystickMove, + InputEvents.JumpPressed, + InputEvents.JumpReleased, + InputEvents.ShurikenPressed, + InputEvents.ShurikenReleased, + InputEvents.NinjaSwordPressed, + InputEvents.NinjaSwordReleased, + ]; + for (const event of eventsToClear) { + globalEventBus.off(event); + } + + const onJoystickMove = (payload: { dx: number; dy: number; klass: JoystickAngleClass }) => { + console.log('[LevelEntry] JoystickMove:', payload.klass); + this.currentJoystickKlass = payload.klass; + if (payload.klass === 'none') { + this.currentHorizontalInput = 0; + } else if (payload.dx >= 0) { + this.currentHorizontalInput = 1; + } else { + this.currentHorizontalInput = -1; + } + }; + + const onJumpPressed = () => { + const nowMs = globalTimeMgr.realTime * 1000; + console.log('[LevelEntry] JumpPressed at nowMs=', nowMs, 'realTime=', globalTimeMgr.realTime); + this.jumpStateProvider.setLastJumpPressTs(nowMs); + this.jumpCtrl.pressJump(nowMs); + }; + + const onJumpReleased = (payload: { holdMs: number }) => { + const nowMs = globalTimeMgr.realTime * 1000; + console.log('[LevelEntry] JumpReleased holdMs=', payload.holdMs); + this.jumpCtrl.releaseJump(nowMs, this.currentJoystickKlass, this.psm.color); + }; + + const onShurikenPressed = () => { + const nowMs = globalTimeMgr.realTime * 1000; + console.log('[LevelEntry] ShurikenPressed at nowMs=', nowMs, 'active=', this.attackCtrl.getActive(), 'pressed=', this.attackCtrl.isPressed(WeaponType.Shuriken)); + this.attackCtrl.press(WeaponType.Shuriken, nowMs); + }; + + const onShurikenReleased = () => { + console.log('[LevelEntry] ShurikenReleased'); + this.attackCtrl.release(WeaponType.Shuriken); + }; + + const onNinjaSwordPressed = () => { + const nowMs = globalTimeMgr.realTime * 1000; + console.log('[LevelEntry] NinjaSwordPressed at nowMs=', nowMs); + this.attackCtrl.press(WeaponType.NinjaSword, nowMs); + }; + + const onNinjaSwordReleased = () => { + console.log('[LevelEntry] NinjaSwordReleased'); + this.attackCtrl.release(WeaponType.NinjaSword); + }; + + const handlers: Array<[string, (payload: any) => void]> = [ + [InputEvents.JoystickMove, onJoystickMove], + [InputEvents.JumpPressed, onJumpPressed], + [InputEvents.JumpReleased, onJumpReleased], + [InputEvents.ShurikenPressed, onShurikenPressed], + [InputEvents.ShurikenReleased, onShurikenReleased], + [InputEvents.NinjaSwordPressed, onNinjaSwordPressed], + [InputEvents.NinjaSwordReleased, onNinjaSwordReleased], + ]; + + for (const [event, handler] of handlers) { + globalEventBus.on(event, handler); + this.boundHandlers.push([event, handler]); + } + console.log('[LevelEntry] subscribeInputEvents done — registered', handlers.length, 'handlers. Listener counts:', + Object.values(InputEvents).map(e => `${e}=${globalEventBus.listenerCount(e)}`).join(', ')); + } + + private unsubscribeInputEvents(): void { + for (const [event, handler] of this.boundHandlers) { + globalEventBus.off(event, handler); + } + this.boundHandlers.length = 0; + } + + // ====================================================================== + // Visual sync — physics coords → Cocos node positions + // ====================================================================== + + /** Convert physics world coords (+y up) to Cocos node position (centred at 0,0). */ + private physicsToCocos(physX: number, physY: number): Vec3 { + return new Vec3( + physX - DESIGN_WIDTH / 2 - this.camera.offsetX, + physY - DESIGN_HEIGHT / 2 - this.camera.offsetY, + 0, + ); + } + + private syncHeroNode(): void { + if (!this.heroNode || !this.heroNode.isValid) return; + const pos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y); + this.heroNode.setPosition(pos); + + // Flip sprite based on horizontal velocity. + if (this.motion.vx > 0) { + this.heroNode.setScale(Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z); + } else if (this.motion.vx < 0) { + this.heroNode.setScale(-Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z); + } + + // Update color-state texture. + this.updateHeroTexture(); + } + + private _lastColorState: PlayerColorState = PlayerColorState.Red; + private updateHeroTexture(): void { + if (this.psm.color === this._lastColorState) return; + this._lastColorState = this.psm.color; + if (!this.heroNode || !this.heroNode.isValid) return; + + const texMap: Record = { + [PlayerColorState.Red]: 'textures/characters/kage_red', + [PlayerColorState.Green]: 'textures/characters/kage_green', + [PlayerColorState.Yellow]: 'textures/characters/kage_yellow', + }; + // Recreate the hero node with the new texture. + // Use the physics model position directly (avoids CC 3.x Node.position getter issues). + const cocosPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y); + const facingRight = this.motion.vx >= 0; + this.heroNode.destroy(); + this.heroNode = createSpriteSheetFrame( + this.node, + texMap[this.psm.color], + 0, + 16, 32, + cocosPos.x, cocosPos.y, + 16 * HERO_SCALE, 32 * HERO_SCALE, + { name: 'Hero' }, + ); + if (!facingRight) { + this.heroNode.setScale(-HERO_SCALE, HERO_SCALE, HERO_SCALE); + } + // Newly-added hero sprite would otherwise sit on top of the ctrl + // layer and shadow touch input — promote ctrl layer back to top. + this.promoteCtrlLayerToTop(); + } + + private syncEnemyNodes(): void { + for (const [enemy, node] of this.enemyNodes) { + if (!node.isValid || !enemy.alive) { + if (node.isValid) { + node.active = false; + } + continue; + } + const pos = this.physicsToCocos(enemy.pos.x, enemy.pos.y); + node.setPosition(pos); + } + } + + private syncParallax(): void { + for (const layer of PARALLAX_LAYERS) { + const node = this.bgNodes.get(layer); + if (!node || !node.isValid) continue; + const offset = this.camera.offsetForLayer(layer); + // Parallax scroll: camera moves right → bg shifts left; camera moves up → bg shifts down. + node.setPosition(new Vec3(-offset.x, -offset.y, 0)); + } + } + + // ====================================================================== + // Projectile handling (visual only — spawn & move) + // ====================================================================== + + private processEnemyActions(actions: IEnemyAction[]): void { + for (const action of actions) { + if (action.kind === 'fire_bullet' && action.originX !== undefined && action.originY !== undefined) { + this.spawnProjectile( + action.attackType ?? 'shuriken', + action.originX, + action.originY, + action.velX ?? 0, + action.velY ?? 0, + true, // enemy projectile → red tint + ); + } + } + } + + private processPlayerAttacks(attacks: IAttackDispatchEvent[]): void { + for (const atk of attacks) { + const heroX = this.motion.aabb.x; + const heroY = this.motion.aabb.y; + const direction = this.motion.vx >= 0 ? 1 : -1; + + if (atk.weapon === WeaponType.Shuriken) { + this.spawnProjectile( + 'shuriken', + heroX + direction * 20, + heroY + 10, + direction * 350, + 0, + false, // player projectile → white tint + ); + } else if (atk.weapon === WeaponType.NinjaSword) { + // Sword is melee — check hit against enemies directly. + this.processSwordHit(heroX, heroY, direction); + } + } + } + + private processSwordHit(heroX: number, heroY: number, direction: number): void { + const swordRange = 50; + const weaponCfg = this.cfg.weapon(WeaponType.NinjaSword); + for (const enemy of this.enemyMgr.all) { + if (!enemy.alive) continue; + const dx = enemy.pos.x - heroX; + const dy = enemy.pos.y - heroY; + if (Math.abs(dx) <= swordRange && Math.abs(dy) <= 40 && (direction >= 0 ? dx >= 0 : dx <= 0)) { + const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage); + enemy.hp = remainingHp; + if (remainingHp <= 0) { + this.enemyMgr.kill(enemy); + this.mgr.onEnemyKilled(enemy.type); + // Hide the visual node. + const node = this.enemyNodes.get(enemy); + if (node && node.isValid) node.active = false; + } + } + } + // Sword active parry window (req 3.7-3.8). + this.psm.setSwordActive(true); + // Deactivate after a short delay (handled by next frame reset). + setTimeout(() => this.psm.setSwordActive(false), 200); + + // Visual feedback: brief slash arc in front of the hero. + this.showSwordSlash(heroX, heroY, direction); + } + + /** Show a brief slash flash in front of the hero for visual feedback. */ + private showSwordSlash(physX: number, physY: number, direction: number): void { + const pos = this.physicsToCocos(physX + direction * 35, physY); + const slashNode = new Node('SwordSlash'); + slashNode.layer = this.node.layer; + this.node.addChild(slashNode); + const ut = slashNode.addComponent(UITransform); + ut.setContentSize(40, 50); + slashNode.setPosition(new Vec3(pos.x, pos.y, 0)); + const sp = slashNode.addComponent(Sprite); + sp.sizeMode = Sprite.SizeMode.CUSTOM; + sp.color = new Color(255, 255, 220, 200); + // Restore ctrl-layer Z-order after appending the slash sprite. + this.promoteCtrlLayerToTop(); + // Auto-remove after 120ms. + setTimeout(() => { + if (slashNode.isValid) { + slashNode.removeFromParent(); + slashNode.destroy(); + } + }, 120); + } + + private spawnProjectile( + attackType: string, + x: number, y: number, + velX: number, velY: number, + isEnemy: boolean, + ): void { + const resPath = attackType === 'fireball' + ? 'textures/fx/parry_spark' // reuse spark as fireball placeholder + : attackType === 'smoke_bomb' + ? 'textures/fx/jump_dust' // reuse dust as smoke placeholder + : isEnemy + ? 'textures/enemies/qing_ren' // enemy shuriken uses enemy sheet + : 'textures/characters/kage_red'; // player shuriken uses hero sheet + const projSize = attackType === 'smoke_bomb' ? 24 : 20; + const frameSize = 16; + const node = createSpriteSheetFrame( + this.node, + resPath, + 0, + frameSize, frameSize, + 0, 0, // position set in syncProjectiles + projSize, projSize, + { name: `Projectile_${attackType}`, flipX: isEnemy }, + ); + // Store physics state on the node's userData for movement tracking. + (node as any)._projState = { x, y, velX, velY, isEnemy, attackType, alive: true }; + this.projectileNodes.push(node); + // Restore ctrl-layer Z-order after appending the projectile sprite. + this.promoteCtrlLayerToTop(); + } + + private syncProjectiles(dt: number): void { + const toRemove: number[] = []; + for (let i = 0; i < this.projectileNodes.length; i++) { + const node = this.projectileNodes[i]; + const state = (node as any)._projState; + if (!state || !state.alive || !node.isValid) { + toRemove.push(i); + continue; + } + // Move the projectile. + state.x += state.velX * dt; + state.y += state.velY * dt; + const pos = this.physicsToCocos(state.x, state.y); + node.setPosition(pos); + + // Check bounds — remove if far outside the camera's culling rect. + const cull = this.camera.cullRect(); + const margin = 200; + if (state.x < cull.leftX - margin || state.x > cull.rightX + margin || + state.y < cull.bottomY - margin || state.y > cull.topY + margin) { + state.alive = false; + toRemove.push(i); + continue; + } + + // Check collision. + if (state.isEnemy) { + // Enemy projectile → check against player. + const dx = state.x - this.motion.aabb.x; + const dy = state.y - this.motion.aabb.y; + if (Math.abs(dx) < 20 && Math.abs(dy) < 30) { + const outcome = this.dmgSystem.applyToPlayer({ + attackType: state.attackType as any, + attackerX: state.x, + attackerY: state.y, + victimX: this.motion.aabb.x, + victimY: this.motion.aabb.y, + }); + // Only destroy the projectile if it actually dealt damage + // (downgraded or died). On 'no_effect' (i-frames / parry), + // let the projectile fly through the player unharmed. + if (outcome && outcome.kind !== 'no_effect') { + state.alive = false; + toRemove.push(i); + } + } + } else { + // Player projectile → check against enemies. + for (const enemy of this.enemyMgr.all) { + if (!enemy.alive) continue; + const dx = state.x - enemy.pos.x; + const dy = state.y - enemy.pos.y; + if (Math.abs(dx) < 20 && Math.abs(dy) < 20) { + const weaponCfg = this.cfg.weapon(WeaponType.Shuriken); + const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage); + enemy.hp = remainingHp; + if (remainingHp <= 0) { + this.enemyMgr.kill(enemy); + this.mgr.onEnemyKilled(enemy.type); + const eNode = this.enemyNodes.get(enemy); + if (eNode && eNode.isValid) eNode.active = false; + } + state.alive = false; + toRemove.push(i); + break; + } + } + } + } + // Remove dead projectiles (iterate in reverse). + for (let i = toRemove.length - 1; i >= 0; i--) { + const idx = toRemove[i]; + const node = this.projectileNodes[idx]; + if (node && node.isValid) { + node.removeFromParent(); + node.destroy(); + } + this.projectileNodes.splice(idx, 1); + } + } + + // ====================================================================== + // Scene flow + // ====================================================================== + private deriveNextScene(): string { const map: Record = { '1-1': 'Level_1_2', @@ -60,17 +935,18 @@ export class LevelEntry extends Component { return map[this.levelId] ?? 'Settlement'; } - private buildDefaultUI(): void { - ensureCanvasSize(this.node); - createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE); - this.hudNode = createLabel( - this.node, - 'Time: --', - 0, - DESIGN_HEIGHT / 2 - 90, - 22, - new Color(255, 220, 120, 255), - ); + private pickThemeFolder(): 'forest' | 'castle_wall' | 'demon_castle' { + switch (this.levelId) { + case '1-1': + case '1-2': + return 'forest'; + case '1-3': + case '1-4': + return 'castle_wall'; + case '1-5': + default: + return 'demon_castle'; + } } private refreshHud(): void { @@ -78,7 +954,8 @@ export class LevelEntry extends Component { const lb = this.hudNode.getComponent(Label); if (lb) { const r = this.mgr.result(); - lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}`; + const colorStr = this.psm.color.charAt(0).toUpperCase() + this.psm.color.slice(1); + lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)} Lives: ${this.psm.lives} [${colorStr}]`; } } } diff --git a/assets/scripts/scene_entries/MainMenuEntry.ts b/assets/scripts/scene_entries/MainMenuEntry.ts index d5c3e95..bb26b0e 100644 --- a/assets/scripts/scene_entries/MainMenuEntry.ts +++ b/assets/scripts/scene_entries/MainMenuEntry.ts @@ -1,4 +1,4 @@ -import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics } from 'cc'; +import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics, Sprite, SpriteFrame, Texture2D, resources, Rect, Size, ImageAsset } from 'cc'; import { UIFlowMgr, ISceneEnter } from '../ui/UIFlowMgr'; import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; @@ -28,6 +28,16 @@ const LEVEL_SCENE_MAP: Record = { '1-5-boss': 'Boss_ShuangHuanFang', }; +/** Level definitions for the level-select screen (chapter 1 MVP). */ +const LEVEL_DEFS: ReadonlyArray<{ id: string; label: string }> = [ + { id: '1-1', label: '1-1 森林入口' }, + { id: '1-2', label: '1-2 竹林深处' }, + { id: '1-3', label: '1-3 城墙之下' }, + { id: '1-4', label: '1-4 密道暗行' }, + { id: '1-5', label: '1-5 魔城前庭' }, + { id: '1-5-boss', label: 'Boss 双幻坊' }, +]; + /** * MainMenu scene entry (task 9.2 hookup). * @@ -35,9 +45,13 @@ const LEVEL_SCENE_MAP: Record = { * callback into a concrete `director.loadScene` call. Attach this component * to the root node of `MainMenu.scene`. * - * When `autoBuildUI` is enabled (default), two centered buttons (Start / - * Settings) and a title label are created programmatically so the scene is - * usable out of the box even before any art pass. + * Supports two display modes reusing the same scene: + * - **main_menu**: title + Start / Settings buttons + * - **level_select**: chapter heading + level buttons + Back button + * + * When `autoBuildUI` is enabled (default), all UI is created + * programmatically so the scene is usable out of the box even before + * any art pass. */ @ccclass('MainMenuEntry') export class MainMenuEntry extends Component { @@ -45,12 +59,16 @@ export class MainMenuEntry extends Component { public autoBuildUI: boolean = true; private flow: UIFlowMgr | undefined; + /** Nodes created in main-menu mode, so we can remove them when switching. */ + private menuNodes: Node[] = []; + /** Nodes created in level-select mode, so we can remove them when switching. */ + private levelSelectNodes: Node[] = []; protected onLoad(): void { this.flow = new UIFlowMgr(undefined, { onSceneEnter: (ev) => this.handleSceneEnter(ev), }); - if (this.autoBuildUI) this.buildDefaultUI(); + if (this.autoBuildUI) this.buildMainMenuUI(); } /** Bind this to the "Start" button's click event in the Inspector. */ @@ -63,8 +81,20 @@ export class MainMenuEntry extends Component { this.flow?.onOpenSettings(); } + /** Pick a level from the level-select screen. */ + public onPressLevel(levelId: string): void { + this.flow?.onPickLevel(levelId); + } + + /** Go back from level-select to main-menu. */ + public onPressBackToMenu(): void { + this.showMainMenu(); + } + private handleSceneEnter(ev: ISceneEnter): void { const payload = ev.payload ?? {}; + + // gameplay: always load a different scene. if (ev.scene === 'gameplay' && typeof payload.levelId === 'string') { const physical = LEVEL_SCENE_MAP[payload.levelId]; if (physical) { @@ -72,28 +102,100 @@ export class MainMenuEntry extends Component { return; } } + + // level_select: we reuse this scene — just swap UI panels. + if (ev.scene === 'level_select') { + this.showLevelSelect(); + return; + } + + // settings: for MVP, overlayed on MainMenu — just swap to menu mode. + if (ev.scene === 'settings') { + this.showMainMenu(); + return; + } + + // For any other scene (boot / story_intro / settlement / main_menu), + // load the physical scene normally. const physical = SCENE_MAP[ev.scene]; - if (physical) director.loadScene(physical); + if (physical && ev.scene !== 'main_menu') { + director.loadScene(physical); + } else if (ev.scene === 'main_menu') { + this.showMainMenu(); + } + } + + // ------------------------------------------------------------------ + // UI mode switching + // ------------------------------------------------------------------ + + private showMainMenu(): void { + this.clearNodes(this.levelSelectNodes); + if (this.menuNodes.length === 0 && this.autoBuildUI) { + this.buildMainMenuUI(); + } + } + + private showLevelSelect(): void { + this.clearNodes(this.menuNodes); + if (this.levelSelectNodes.length === 0 && this.autoBuildUI) { + this.buildLevelSelectUI(); + } + } + + private clearNodes(arr: Node[]): void { + for (const n of arr) { + if (n.isValid) { + n.removeFromParent(); + n.destroy(); + } + } + arr.length = 0; } // ------------------------------------------------------------------ // Auto-built UI (development affordance; art pass will replace it) // ------------------------------------------------------------------ - private buildDefaultUI(): void { + private buildMainMenuUI(): void { // Ensure the host node has a UITransform matching the design resolution. ensureCanvasSize(this.node); // Title. - createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE); + this.menuNodes.push(createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE)); // Start button (centered, 40 above origin). - createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart()); + this.menuNodes.push(createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart())); // Settings button (centered, 40 below origin). - createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings()); + this.menuNodes.push(createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings())); // Hint line at the bottom. - createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255)); + this.menuNodes.push(createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255))); + } + + private buildLevelSelectUI(): void { + ensureCanvasSize(this.node); + + // Chapter heading. + this.levelSelectNodes.push( + createLabel(this.node, '第一章 青叶之刃', 0, 220, 36, Color.WHITE), + ); + + // Level buttons — laid out vertically with even spacing. + const startY = 140; + const gapY = 55; + for (let i = 0; i < LEVEL_DEFS.length; i++) { + const def = LEVEL_DEFS[i]; + const y = startY - i * gapY; + this.levelSelectNodes.push( + createButton(this.node, def.label, 0, y, 280, 44, () => this.onPressLevel(def.id)), + ); + } + + // Back button at the bottom. + this.levelSelectNodes.push( + createButton(this.node, 'Back', 0, -200, 160, 44, () => this.onPressBackToMenu()), + ); } } @@ -179,4 +281,150 @@ function createButton( } // Re-export helpers so sibling Scene Entries can reuse them. -export { ensureCanvasSize, createLabel, createButton }; +export { ensureCanvasSize, createLabel, createButton, createSprite, createSpriteSheetFrame, createSolidRect }; + +// ====================================================================== +// Sprite helpers (task 10.1 hookup) — load a PNG from `resources/` and +// attach it to a fresh child Node. Returned synchronously; the Sprite's +// `spriteFrame` is assigned once `resources.load` resolves. +// +// Paths passed in are **relative to `assets/resources`** and **without +// extension**. Example: `textures/characters/kage_red` — the helper will +// append `/spriteFrame` automatically. +// ====================================================================== + +// ====================================================================== +// Internal: Load a PNG from `resources/` as a SpriteFrame. +// +// Strategy — try two paths so it works regardless of .meta type: +// 1. `resources.load(path, SpriteFrame)` — works when the .meta has +// `type: "sprite-frame"` (Creator auto-generates the sub-asset). +// 2. Fallback: `resources.load(path, ImageAsset)` → Texture2D → +// SpriteFrame — works when .meta has `type: "texture"`. +// ====================================================================== + +function _loadSpriteFrameFromImage( + resPath: string, + onReady: (sf: SpriteFrame) => void, +): void { + // Try the direct SpriteFrame path first (works with sprite-frame meta). + resources.load(resPath, SpriteFrame, (err, sf) => { + if (!err && sf) { + onReady(sf as SpriteFrame); + return; + } + // Fallback: load raw image and build SpriteFrame manually. + resources.load(resPath, ImageAsset, (err2, imgAsset) => { + if (err2 || !imgAsset) { + console.warn(`[_loadSpriteFrameFromImage] Failed to load '${resPath}' (both paths):`, err, err2); + return; + } + const tex = new Texture2D(); + tex.image = imgAsset as ImageAsset; + const frame = new SpriteFrame(); + frame.texture = tex; + frame.rect = new Rect(0, 0, tex.width, tex.height); + frame.originalSize = new Size(tex.width, tex.height); + onReady(frame); + }); + }); +} + +/** + * Create a child Node with a Sprite that displays the entire PNG at + * `resPath`. If `w` / `h` are omitted the node will be resized to the + * native image size once the texture finishes loading. + * + * NOTE: Loads via `ImageAsset` → `Texture2D` → `SpriteFrame` pipeline + * instead of relying on a `/spriteFrame` sub-asset, because PNG .meta + * files may not have `type: "sprite-frame"`. + */ +function createSprite( + parent: Node, + resPath: string, + x: number, + y: number, + w?: number, + h?: number, + opts?: { name?: string; color?: Color; flipX?: boolean } +): Node { + const n = new Node(opts?.name ?? `Sprite_${resPath.split('/').pop()}`); + n.layer = parent.layer; + parent.addChild(n); + const ut = n.addComponent(UITransform); + if (w != null && h != null) ut.setContentSize(w, h); + const sp = n.addComponent(Sprite); + sp.sizeMode = Sprite.SizeMode.CUSTOM; + if (opts?.color) sp.color = opts.color; + n.setPosition(new Vec3(x, y, 0)); + if (opts?.flipX) n.setScale(-Math.abs(n.scale.x), n.scale.y, n.scale.z); + + _loadSpriteFrameFromImage(resPath, (sf) => { + if (!n.isValid) return; + sp.spriteFrame = sf; + if (w == null || h == null) { + ut.setContentSize(sf.rect.width, sf.rect.height); + } + }); + return n; +} + +/** + * Create a child Node that renders a single frame out of a horizontal + * sprite-sheet PNG. `frameIndex` is 0-based; `frameW`/`frameH` describe + * each frame's pixel size; the sheet is expected to lay frames left-to- + * right in one row (matches `gen_pixel_art_assets.py`). + * + * The returned Node is shown at `displayW`×`displayH` (defaults to the + * native frame size — i.e. 1 CSS px per source px). + */ +function createSpriteSheetFrame( + parent: Node, + resPath: string, + frameIndex: number, + frameW: number, + frameH: number, + x: number, + y: number, + displayW?: number, + displayH?: number, + opts?: { name?: string; flipX?: boolean } +): Node { + const n = new Node(opts?.name ?? `Frame_${resPath.split('/').pop()}_${frameIndex}`); + n.layer = parent.layer; + parent.addChild(n); + const ut = n.addComponent(UITransform); + ut.setContentSize(displayW ?? frameW, displayH ?? frameH); + const sp = n.addComponent(Sprite); + sp.sizeMode = Sprite.SizeMode.CUSTOM; + n.setPosition(new Vec3(x, y, 0)); + if (opts?.flipX) n.setScale(-Math.abs(n.scale.x), n.scale.y, n.scale.z); + + _loadSpriteFrameFromImage(resPath, (sf) => { + if (!n.isValid) return; + // Slice a single frame out of the full sheet. + const sliced = new SpriteFrame(); + sliced.texture = sf.texture; + sliced.rect = new Rect(frameIndex * frameW, 0, frameW, frameH); + sliced.originalSize = new Size(frameW, frameH); + sp.spriteFrame = sliced; + }); + return n; +} + +/** + * Utility for semi-transparent overlays (e.g. story-page text backdrop). + */ +function createSolidRect(parent: Node, x: number, y: number, w: number, h: number, color: Color): Node { + const n = new Node('SolidRect'); + n.layer = parent.layer; + parent.addChild(n); + const ut = n.addComponent(UITransform); + ut.setContentSize(w, h); + const g = n.addComponent(Graphics); + g.fillColor = color; + g.rect(-w / 2, -h / 2, w, h); + g.fill(); + n.setPosition(new Vec3(x, y, 0)); + return n; +} diff --git a/assets/scripts/scene_entries/StorySceneEntry.ts b/assets/scripts/scene_entries/StorySceneEntry.ts index 9d640f5..540bffb 100644 --- a/assets/scripts/scene_entries/StorySceneEntry.ts +++ b/assets/scripts/scene_entries/StorySceneEntry.ts @@ -1,9 +1,16 @@ -import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform } from 'cc'; +import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform, Sprite, SpriteFrame, Texture2D, ImageAsset, Rect, Size, resources } from 'cc'; import { ConfigMgr } from '../data/ConfigMgr'; import { CCJsonLoader } from './CCJsonLoader'; import { StorySceneCtrl } from '../ui/StorySceneCtrl'; -import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry'; +import { + ensureCanvasSize, + createLabel, + createButton, + createSprite, + createSolidRect, +} from './MainMenuEntry'; import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; +import { IStoryPageConfig } from '../data/Interfaces'; const { ccclass, property } = _decorator; @@ -30,8 +37,15 @@ export class StorySceneEntry extends Component { public autoBuildUI: boolean = true; private ctrl: StorySceneCtrl | undefined; + private bgNode: Node | null = null; protected async onLoad(): Promise { + // Always ensure the illustration bgNode exists (even if the scene + // was hand-built in the editor and labelNode is already bound). + if (!this.bgNode) { + this.ensureBgNode(); + } + if (this.autoBuildUI && !this.labelNode) { this.buildDefaultUI(); } @@ -41,6 +55,7 @@ export class StorySceneEntry extends Component { const cfg = await this.loadStoryConfig(); this.ctrl = new StorySceneCtrl(cfg, undefined, { onTextChanged: (text) => this.updateLabel(text), + onPageEntered: (page) => this.swapIllustration(page), onFinished: () => director.loadScene('Level_1_1'), }); const outcome = this.ctrl.start(); @@ -75,16 +90,75 @@ export class StorySceneEntry extends Component { return mgr.story(this.storyId); } - private buildDefaultUI(): void { + /** Ensure the full-screen illustration layer exists. Called even when + * the scene was hand-built in the editor (labelNode already bound). */ + private ensureBgNode(): void { ensureCanvasSize(this.node); - // Ensure the root node can receive touch (size = design resolution). - // Central typewriter label. - this.labelNode = createLabel(this.node, '', 0, 0, 28, Color.WHITE); + this.bgNode = new Node('StoryIllustration'); + this.bgNode.layer = this.node.layer; + // Added before any scrim/label nodes so it renders behind them. + this.node.addChild(this.bgNode); + const bgUt = this.bgNode.addComponent(UITransform); + bgUt.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT); + const bgSp = this.bgNode.addComponent(Sprite); + bgSp.sizeMode = Sprite.SizeMode.CUSTOM; + this.bgNode.setPosition(0, 0, 0); + } + + private buildDefaultUI(): void { + // bgNode is already created by ensureBgNode() — just build the + // text overlay, scrim and skip button. + + // Dark scrim below the text area for readability. + createSolidRect( + this.node, + 0, + -DESIGN_HEIGHT / 2 + 110, + DESIGN_WIDTH, + 180, + new Color(0, 0, 0, 170), + ); + + // Central typewriter label (sits on top of the scrim). + this.labelNode = createLabel(this.node, '', 0, -DESIGN_HEIGHT / 2 + 110, 26, Color.WHITE); const ut = this.labelNode.getComponent(UITransform); - if (ut) ut.setContentSize(DESIGN_WIDTH - 80, DESIGN_HEIGHT - 120); + if (ut) ut.setContentSize(DESIGN_WIDTH - 80, 160); + const lb = this.labelNode.getComponent(Label); + if (lb) lb.enableWrapText = true; + // Skip button at bottom-right. const skipX = DESIGN_WIDTH / 2 - 90; - const skipY = -DESIGN_HEIGHT / 2 + 50; + const skipY = -DESIGN_HEIGHT / 2 + 40; createButton(this.node, 'Skip >>', skipX, skipY, 140, 50, () => this.onSkip()); } + + /** Swap the full-screen illustration when the controller enters a new page. */ + private swapIllustration(page: IStoryPageConfig): void { + if (!this.bgNode) return; + const sp = this.bgNode.getComponent(Sprite); + if (!sp) return; + const path = page.illustration; + // Strategy 1: direct SpriteFrame load (works with sprite-frame meta). + resources.load(path, SpriteFrame, (err, sf) => { + if (!err && sf) { + if (this.bgNode && this.bgNode.isValid) sp.spriteFrame = sf as SpriteFrame; + return; + } + // Strategy 2: ImageAsset → Texture2D → SpriteFrame fallback. + resources.load(path, ImageAsset, (err2, imgAsset) => { + if (err2 || !imgAsset) { + console.warn(`[StorySceneEntry] failed to load illustration '${path}':`, err, err2); + return; + } + if (!this.bgNode || !this.bgNode.isValid) return; + const tex = new Texture2D(); + tex.image = imgAsset as ImageAsset; + const frame = new SpriteFrame(); + frame.texture = tex; + frame.rect = new Rect(0, 0, tex.width, tex.height); + frame.originalSize = new Size(tex.width, tex.height); + sp.spriteFrame = frame; + }); + }); + } } diff --git a/assets/scripts/ui/FloatingControlLayer.ts b/assets/scripts/ui/FloatingControlLayer.ts index 26d4856..1f373f0 100644 --- a/assets/scripts/ui/FloatingControlLayer.ts +++ b/assets/scripts/ui/FloatingControlLayer.ts @@ -1,6 +1,6 @@ -import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view } from 'cc'; +import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view, input, Input } from 'cc'; import { globalEventBus, globalLogger } from '../common/index'; -import { PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants'; +import { DESIGN_WIDTH, DESIGN_HEIGHT, PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants'; import { ControlId, DEFAULT_LAYOUT, @@ -47,13 +47,43 @@ export class FloatingControlLayer extends Component { private layout: IFloatingLayout = DEFAULT_LAYOUT; private router: MultiTouchRouter = new MultiTouchRouter(DEFAULT_LAYOUT); + /** + * Convert a touch point from the engine's UI coordinate space back into + * the 960×540 design-coordinate space used by `InputModel`. + * + * Under `FIT_HEIGHT`, the engine may widen the visible area beyond 960px + * on ultra-wide screens (19.5:9 etc.). `getUILocation()` returns values + * in that wider space, but our layout & hit-test are anchored to 960×540. + * + * The mapping is: + * designX = (uiX - extraLeft) * 960 / visibleWidth + * designY = uiY * 540 / visibleHeight (always 1:1 under FIT_HEIGHT) + */ + private uiToDesign(uiX: number, uiY: number): { x: number; y: number } { + const vs = view.getVisibleSize(); + const scaleX = DESIGN_WIDTH / vs.width; + const scaleY = DESIGN_HEIGHT / vs.height; + // Under FIT_HEIGHT the design height always equals the visible height + // so scaleY ≈ 1, but we apply it anyway for robustness. + return { + x: uiX * scaleX, + y: uiY * scaleY, + }; + } + protected onLoad(): void { + console.log('[FloatingControlLayer] onLoad — node name=', this.node?.name, 'isValid=', this.node?.isValid); this.applyInitialLayout(); this.bindTouchEvents(); + console.log('[FloatingControlLayer] onLoad done — touch listeners bound'); } protected onDestroy(): void { + console.log('[FloatingControlLayer] onDestroy — unbinding touch listeners'); this.unbindTouchEvents(); + this.router.clear(); + this.processedPhases.clear(); + this.lastStartTs.clear(); } /** Public API — called by `UIFlowMgr` when safe-area changes. */ @@ -98,6 +128,33 @@ export class FloatingControlLayer extends Component { } private bindTouchEvents(): void { + // CRITICAL: Cocos Creator 3.x has a known issue where + // `input.off(type, cb, target)` may fail to remove listeners whose + // target was a destroyed Component. This leaves "ghost" handlers on + // the global input dispatcher that silently break the touch event + // chain after scene transitions. We defensively clear ALL listeners + // for our touch events first, then re-register — guaranteeing a + // clean state. + try { + (input as any).off(Input.EventType.TOUCH_START); + (input as any).off(Input.EventType.TOUCH_MOVE); + (input as any).off(Input.EventType.TOUCH_END); + (input as any).off(Input.EventType.TOUCH_CANCEL); + } catch (e) { + console.warn('[FloatingControlLayer] defensive input.off (no target) failed:', e); + } + + // PRIMARY channel: global input API — receives raw touch events + // directly from the engine, bypassing node-tree dispatch entirely. + input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); + input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); + input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); + input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); + console.log('[FloatingControlLayer] bindTouchEvents — global input listeners registered'); + + // SECONDARY (back-compat): node-level listeners so that + // `ev.propagationStopped` keeps any underlying gameplay sprite from + // also reacting to the same touch. this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this); this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); @@ -105,17 +162,58 @@ export class FloatingControlLayer extends Component { } private unbindTouchEvents(): void { + input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); + input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); + input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); + input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); + this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this); this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this); this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); } + /** + * Event de-dup. Because we listen on BOTH the global `input` API and the + * node-level `this.node.on` channel, the same touch may fire the same + * handler twice. This set records the `"${eventKind}:${touchId}"` string + * for touches already processed in this "phase"; the entry is cleared + * when the corresponding END/CANCEL arrives (or START starts a new + * phase for that id). + */ + private readonly processedPhases = new Set(); + private onTouchStart(ev: EventTouch): void { - const t = ev.getUILocation(); - const start = FloatingControlLayer.now(); const touchId = this.touchId(ev); - const hit = this.router.begin(touchId, t.x, t.y, start); + console.log('[FloatingControlLayer] onTouchStart — touchId=', touchId); + const key = `start:${touchId}`; + + // Defensive: if a stale `start` marker exists for this touchId AND + // the router no longer has an active slot for it, the marker leaked + // from a previous touch cycle (e.g. onTouchEnd was not called on + // both channels, or a TOUCH_CANCEL race left the set dirty). + // Clear it so this new touch is not silently swallowed. + if (this.processedPhases.has(key) && !this.router.isPressedById(touchId)) { + console.log('[FloatingControlLayer] onTouchStart — clearing stale start marker for touchId=', touchId); + this.processedPhases.delete(key); + } + + if (this.processedPhases.has(key)) { + console.log('[FloatingControlLayer] onTouchStart DEDUP-skip touchId=', touchId, + 'processedPhases=', JSON.stringify([...this.processedPhases])); + return; + } + this.processedPhases.add(key); + // A new TOUCH_START invalidates any stale END marker for the same id. + this.processedPhases.delete(`end:${touchId}`); + + const t = ev.getUILocation(); + const d = this.uiToDesign(t.x, t.y); + console.log('[FloatingControlLayer] onTouchStart uiLoc=(', t.x, ',', t.y, ') design=(', d.x.toFixed(1), ',', d.y.toFixed(1), ')'); + const start = FloatingControlLayer.now(); + const hit = this.router.begin(touchId, d.x, d.y, start); + console.log('[FloatingControlLayer] onTouchStart hit=', hit, + 'router.activeTouchCount=', this.router.activeTouchCount); this.recordLatency('input/touchStart', start); if (!hit) { // Let the touch fall through to the gameplay layer (req 1.3). @@ -133,7 +231,7 @@ export class FloatingControlLayer extends Component { globalEventBus.emit(InputEvents.NinjaSwordPressed, {}); break; case ControlId.Joystick: - this.broadcastJoystick(t.x, t.y); + this.broadcastJoystick(d.x, d.y); break; default: break; @@ -142,18 +240,36 @@ export class FloatingControlLayer extends Component { private onTouchMove(ev: EventTouch): void { const t = ev.getUILocation(); + const d = this.uiToDesign(t.x, t.y); const touchId = this.touchId(ev); - const bound = this.router.move(touchId, t.x, t.y); + const bound = this.router.move(touchId, d.x, d.y); if (bound === ControlId.Joystick) { - this.broadcastJoystick(t.x, t.y); + this.broadcastJoystick(d.x, d.y); } } private onTouchEnd(ev: EventTouch): void { const touchId = this.touchId(ev); + const key = `end:${touchId}`; + if (this.processedPhases.has(key)) { + console.log('[FloatingControlLayer] onTouchEnd DEDUP-skip touchId=', touchId); + return; + } + this.processedPhases.add(key); + // End of this phase — allow a future TOUCH_START with the same id. + this.processedPhases.delete(`start:${touchId}`); + const end = FloatingControlLayer.now(); const bound = this.router.end(touchId); - if (!bound) return; + console.log('[FloatingControlLayer] onTouchEnd touchId=', touchId, 'bound=', bound, + 'slots remaining=', this.router.activeTouchCount); + if (!bound) { + // Touch that missed every control — still clean up its timestamp + // record so the Map does not leak over long play sessions. + this.lastStartTs.delete(touchId); + this.processedPhases.delete(key); + return; + } switch (bound) { case ControlId.Jump: { const slotStart = this.lastStartTs.get(touchId); @@ -174,6 +290,9 @@ export class FloatingControlLayer extends Component { break; } this.lastStartTs.delete(touchId); + // Allow the same phase key to be re-used on a future touch id + // reassignment (some platforms recycle ids aggressively). + this.processedPhases.delete(key); } private broadcastJoystick(x: number, y: number): void { diff --git a/assets/scripts/ui/InputModel.ts b/assets/scripts/ui/InputModel.ts index a6f4af9..e00b87b 100644 --- a/assets/scripts/ui/InputModel.ts +++ b/assets/scripts/ui/InputModel.ts @@ -109,21 +109,45 @@ export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v; } +/** Hit-test tolerance (design px). Touches this far outside the visual + * bounding box still register as a hit. This compensates for finger + * imprecision on small touch targets (req 1.3, 20.3). + * Increased from 10→15 to better accommodate finger-pad size on mobile. */ +export const HIT_TOLERANCE = 15; + /** * Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left. - * Used by both `isInside` and the touch router. + * Used for the joystick which is rendered as a full rectangle. */ export function isInsideRect(rect: IHitRect, x: number, y: number): boolean { const halfW = rect.w / 2; const halfH = rect.h / 2; - return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH; + return Math.abs(x - rect.cx) <= halfW + HIT_TOLERANCE && Math.abs(y - rect.cy) <= halfH + HIT_TOLERANCE; } -/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */ +/** + * Returns true if `(x, y)` lies inside the **circle** inscribed by `rect`. + * Buttons are rendered as circles via `Graphics.circle`; using a circular + * hit-test ensures the visual shape and the touch area match — no "dead + * zones" in the corners of a rectangular hit rect that visually lie outside + * the circle, and no missing the upper/lower arc of the circle. + * + * The effective radius is `min(w, h) / 2 + HIT_TOLERANCE`. + */ +export function isInsideCircle(rect: IHitRect, x: number, y: number): boolean { + const radius = Math.min(rect.w, rect.h) / 2; + const dx = x - rect.cx; + const dy = y - rect.cy; + return (dx * dx + dy * dy) <= (radius + HIT_TOLERANCE) * (radius + HIT_TOLERANCE); +} + +/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. + * Buttons (jump, shuriken, ninjaSword) use circular hit-test to match their + * visual shape. The joystick retains rectangular hit-test for full-area coverage. */ export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null { - if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword; - if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken; - if (isInsideRect(layout.jump, x, y)) return ControlId.Jump; + if (isInsideCircle(layout.ninjaSword, x, y)) return ControlId.NinjaSword; + if (isInsideCircle(layout.shuriken, x, y)) return ControlId.Shuriken; + if (isInsideCircle(layout.jump, x, y)) return ControlId.Jump; if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick; return null; } @@ -268,6 +292,11 @@ export class MultiTouchRouter { return false; } + /** Check whether a specific touchId still has an active slot in the router. */ + public isPressedById(id: number): boolean { + return this.slots.has(id); + } + /** Returns how many simultaneous fingers are currently tracked. */ public get activeTouchCount(): number { return this.slots.size; diff --git a/package.json b/package.json index e4a20d3..6930702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kage-legend-mvp", - "version": "0.1.0", + "version": "3.8.8", "description": "Shadow Legend: Ninja Rescue Princess - MVP (Chapter 1, Landscape, WeChat Mini Game)", "private": true, "author": "KateLegend2 Team", diff --git a/scripts/gen_pixel_art_assets.py b/scripts/gen_pixel_art_assets.py new file mode 100644 index 0000000..cac0888 --- /dev/null +++ b/scripts/gen_pixel_art_assets.py @@ -0,0 +1,1129 @@ +#!/usr/bin/env python3 +""" +gen_pixel_art_assets.py +──────────────────────── +Procedural pixel-art generator for Chapter 1 assets. + +Purpose: + Replace the 1x1 solid-color placeholder PNGs produced by + `scripts/gen_placeholder_assets.js` with *real* (though still stylized) + pixel-art PNGs that match the spec in `assets/resources/ASSETS.md`. + Visual quality is intentionally simple; the goal is to let the game + boot with something *visible* on screen while waiting for the final art. + +Coverage (all PNGs in ASSETS.md): + 1. Protagonist : kage_red / kage_green / kage_yellow (176x32, 11 frames) + 2. Enemies : qing_ren / chi_ren / hei_ren / yao_fang + 3. Bosses : shuang_huan_fang / butterfly + 4. Scenes (3 x 4 layers): forest / castle_wall / demon_castle x far/mid/near/fx + 5. Story illustrations : ch1_page1..3 (480x270) + 6. FX textures : leaf_particle / jump_dust / parry_spark + +Usage: + python3 scripts/gen_pixel_art_assets.py +""" + +from __future__ import annotations +import math +import random +import sys +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont, ImageFilter +except ImportError: + print("Pillow is required. Install with: pip3 install Pillow", file=sys.stderr) + sys.exit(1) + + +PROJECT = Path(__file__).resolve().parent.parent +RES = PROJECT / "assets" / "resources" +TEX = RES / "textures" + + +# ───────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────── +def new_rgba(w, h): + return Image.new("RGBA", (w, h), (0, 0, 0, 0)) + + +def save_png(img: Image.Image, rel_path: str): + out = RES / rel_path + out.parent.mkdir(parents=True, exist_ok=True) + img.save(out, "PNG", optimize=True) + size_kb = out.stat().st_size / 1024 + print(f" + {rel_path} ({img.width}x{img.height}, {size_kb:.1f} KB)") + + +def _patch_meta_files(): + """Ensure every PNG .meta has type='sprite-frame' so Cocos Creator + generates a spriteFrame sub-asset for each image. This is idempotent + and safe to run every time the script executes.""" + import json + count = 0 + for meta_path in RES.rglob("*.png.meta"): + with open(meta_path, "r") as f: + meta = json.load(f) + ud = meta.setdefault("userData", {}) + if ud.get("type") != "sprite-frame": + ud["type"] = "sprite-frame" + with open(meta_path, "w") as f: + json.dump(meta, f, indent=2, ensure_ascii=False) + f.write("\n") + count += 1 + print(f" ~ {meta_path.relative_to(RES)} (type → sprite-frame)") + if count == 0: + print(" (all meta files already correct)") + else: + print(f" Patched {count} meta file(s).") + + +def plot(img: Image.Image, x: int, y: int, rgba): + """Safe single-pixel plot (ignores out-of-range).""" + if 0 <= x < img.width and 0 <= y < img.height: + img.putpixel((x, y), rgba) + + +def rect(img, x, y, w, h, rgba): + for j in range(h): + for i in range(w): + plot(img, x + i, y + j, rgba) + + +# ───────────────────────────────────────────────────────────────────── +# 1. Protagonist sprite sheet (176x32, 11 frames) +# ───────────────────────────────────────────────────────────────────── +# Shared outline/skin/hair palette indices; only clothing changes per variant. +BASE_PAL = { + "bg": (0, 0, 0, 0), + "outline": (20, 15, 15, 255), + "skin": (228, 188, 152, 255), + "skin_sh": (180, 140, 110, 255), + "hair": (30, 20, 20, 255), + "eye": (250, 250, 250, 255), + "shadow": (25, 20, 25, 255), +} + +VARIANT_PAL = { + "kage_red": { + "cloth_dark": (110, 20, 18, 255), + "cloth_mid": (170, 40, 34, 255), + "cloth_lit": (220, 80, 60, 255), + "scarf_dark": (60, 8, 8, 255), + "scarf_mid": (90, 12, 12, 255), + "accent": (230, 200, 120, 255), + }, + "kage_green": { + "cloth_dark": (20, 80, 35, 255), + "cloth_mid": (40, 130, 55, 255), + "cloth_lit": (80, 180, 90, 255), + "scarf_dark": (15, 50, 25, 255), + "scarf_mid": (25, 70, 35, 255), + "accent": (220, 210, 140, 255), + }, + "kage_yellow": { + "cloth_dark": (140, 110, 25, 255), + "cloth_mid": (200, 170, 40, 255), + "cloth_lit": (245, 220, 90, 255), + "scarf_dark": (90, 70, 15, 255), + "scarf_mid": (120, 95, 20, 255), + "accent": (230, 240, 200, 255), + }, +} + + +def draw_kage_frame(img, ox, variant_name, pose): + """Draw one 16x32 frame at offset ox into a sprite sheet image.""" + p = dict(BASE_PAL) + p.update(VARIANT_PAL[variant_name]) + O = p["outline"] + SK = p["skin"] + SS = p["skin_sh"] + HR = p["hair"] + EY = p["eye"] + CD = p["cloth_dark"] + CM = p["cloth_mid"] + CL = p["cloth_lit"] + SD = p["scarf_dark"] + SM = p["scarf_mid"] + AC = p["accent"] + SH = p["shadow"] + + # Y-offset for jump (whole body lifts) + yoff = 0 + if pose == "jump_up": + yoff = -3 + elif pose == "jump_down": + yoff = -1 + + def px(x, y, c): + plot(img, ox + x, y + yoff, c) + + # ── Head / hood band (rows 2..9) ───────────────────────────── + # Hood outline + for x in range(5, 11): + px(x, 2, O) + for y in range(3, 5): + px(4, y, O); px(11, y, O) + # Hood fill + for y in range(3, 5): + for x in range(5, 11): + px(x, y, CD) + # Forehead band + for x in range(4, 12): + px(x, 5, CM) + # Mask area (face) + for y in range(6, 9): + px(4, y, O); px(11, y, O) + for y in range(6, 9): + for x in range(5, 11): + px(x, y, SK) + # Eyes (row 7) — vary slightly per pose for life + eye_left, eye_right = 6, 9 + if pose in ("run2", "run4"): + eye_left, eye_right = 7, 10 + px(eye_left, 7, EY) + px(eye_right, 7, EY) + px(eye_left, 7, O) # pupil dot over white? keep outline dark + # Instead: pupil = outline, sclera = eye around it (simplified: just dark) + px(eye_left, 7, O) + px(eye_right, 7, O) + # Nose / shadow + px(8, 8, SS) + # Chin outline + for x in range(5, 11): + px(x, 9, O) + + # ── Scarf (rows 10..13) ────────────────────────────────────── + for x in range(3, 13): + px(x, 10, O) + for x in range(4, 12): + px(x, 11, SM) + for x in range(4, 12): + px(x, 12, SD) + # Trailing ends differ per pose + if pose in ("run1", "run3", "attack2", "attack3"): + px(2, 11, SD); px(2, 12, SD); px(1, 12, SD) + if pose in ("attack1",): + px(13, 11, SD); px(13, 12, SD) + + # ── Torso (rows 13..20) ────────────────────────────────────── + for y in range(13, 20): + px(4, y, O); px(11, y, O) + for y in range(13, 20): + for x in range(5, 11): + px(x, y, CM) + # Chest highlight + for y in range(14, 18): + px(6, y, CL) + px(7, y, CL) + # Belt + for x in range(4, 12): + px(x, 19, CD) + px(7, 19, AC) + px(8, 19, AC) + + # ── Arms (rows 12..19) ─────────────────────────────────────── + if pose == "idle1" or pose == "idle2": + # Both arms at sides + for y in range(13, 19): + px(3, y, O) + px(12, y, O) + for y in range(13, 18): + px(3, y, CM) if y % 2 == 0 else px(3, y, CD) + px(12, y, CM) if y % 2 == 0 else px(12, y, CD) + elif pose.startswith("run"): + # One arm forward, one back — swaps per frame + fwd_left = pose in ("run1", "run3") + if fwd_left: + # Left arm forward (screen-right since facing right) + for y in range(13, 18): + px(13, y, O) + for y in range(14, 17): + px(13, y, CM) + # Right arm back + for y in range(13, 19): + px(2, y, O) + px(2, y + 0, CD) if y < 18 else None + else: + for y in range(13, 18): + px(2, y, O) + for y in range(14, 17): + px(2, y, CM) + for y in range(13, 19): + px(13, y, O) + elif pose.startswith("jump"): + # Arms up + for y in range(11, 15): + px(2, y, O); px(13, y, O) + for y in range(12, 14): + px(2, y, CM); px(13, y, CM) + elif pose.startswith("attack"): + # Sword arm extended forward (right side) + for y in range(13, 17): + px(12, y, O); px(13, y, O) + for y in range(14, 16): + px(12, y, SK); px(13, y, SK) + # Sword blade + if pose == "attack1": + # Wind-up: blade raised diagonally up-right + for i in range(3): + px(14 + i, 13 - i, AC) + px(14 + i, 12 - i, O) + elif pose == "attack2": + # Full horizontal slash + for i in range(4): + px(13 + i, 14, O) if 13 + i < 16 else None + px(13 + i, 15, AC) if 13 + i < 16 else None + else: # attack3 follow-through down + for i in range(3): + px(13 + i, 15 + i, O) if (13 + i < 16 and 15 + i < 32) else None + px(13 + i, 14 + i, AC) if (13 + i < 16 and 14 + i < 32) else None + # Off-hand at hip + for y in range(14, 18): + px(3, y, O) + for y in range(15, 17): + px(3, y, CM) + + # ── Legs (rows 20..29) ─────────────────────────────────────── + def draw_leg(x_center, y_top, y_bot, bent=False): + for y in range(y_top, y_bot): + px(x_center, y, O) + px(x_center + 1, y, O) + for y in range(y_top, y_bot - 1): + px(x_center, y, CD) + px(x_center + 1, y, CD) + # Foot / sandal + for dx in range(-1, 3): + px(x_center + dx, y_bot - 1, SH) + + if pose in ("idle1", "idle2"): + # Two legs straight + draw_leg(5, 20, 30) + draw_leg(9, 20, 30) + elif pose == "run1": + # Left leg forward (raised) + draw_leg(4, 20, 27) + draw_leg(10, 22, 30) + elif pose == "run2": + # Passing pose + draw_leg(6, 20, 29) + draw_leg(9, 20, 29) + elif pose == "run3": + # Right leg forward + draw_leg(10, 20, 27) + draw_leg(5, 22, 30) + elif pose == "run4": + # Passing pose (mirror of run2) + draw_leg(5, 20, 29) + draw_leg(10, 20, 29) + elif pose == "jump_up": + # Legs tucked up + draw_leg(5, 21, 27) + draw_leg(9, 21, 27) + elif pose == "jump_down": + # Legs extended + draw_leg(4, 20, 30) + draw_leg(10, 20, 30) + elif pose.startswith("attack"): + # Attack stance: wide, one forward + draw_leg(4, 20, 30) + draw_leg(10, 21, 30) + + +def gen_kage_sheet(variant_name): + """Generate a 176x32 sprite sheet for one kage variant.""" + poses = [ + "idle1", "idle2", # 0..1 + "run1", "run2", "run3", "run4", # 2..5 + "jump_up", "jump_down", # 6..7 + "attack1", "attack2", "attack3", # 8..10 + ] + assert len(poses) == 11 + img = new_rgba(16 * 11, 32) + for i, pose in enumerate(poses): + draw_kage_frame(img, i * 16, variant_name, pose) + return img + + +# ───────────────────────────────────────────────────────────────────── +# 2. Enemies +# ───────────────────────────────────────────────────────────────────── +def gen_small_enemy_sheet(primary, secondary, frame_count, size=(16, 16)): + """ + Minimal stylized enemy sprite: + body (rounded square) + eyes + waist band; pose variations shift eyes/body. + """ + w, h = size + img = new_rgba(w * frame_count, h) + OUT = (15, 15, 20, 255) + SK = (230, 200, 170, 255) + EY = (250, 250, 250, 255) + for f in range(frame_count): + ox = f * w + # Body outline (rounded rectangle) + for y in range(3, h - 1): + plot(img, ox + 2, y, OUT) + plot(img, ox + w - 3, y, OUT) + for x in range(3, w - 3): + plot(img, ox + x, 2, OUT) + plot(img, ox + x, h - 1, OUT) + # Fill + for y in range(3, h - 1): + for x in range(3, w - 3): + plot(img, ox + x, y, primary) + # Head / mask + for x in range(4, w - 4): + plot(img, ox + x, 3, primary) + plot(img, ox + x, 4, SK) + # Eyes (shift right on even frames for run animation) + eye_shift = 1 if f % 2 == 0 else 0 + plot(img, ox + 5 + eye_shift, 4, OUT) + plot(img, ox + w - 6 + eye_shift, 4, OUT) + # Waist band + for x in range(3, w - 3): + plot(img, ox + x, 9, secondary) + plot(img, ox + x, 10, secondary) + # Legs — slight offset per frame for motion + leg_off = (f % 2) * 1 + plot(img, ox + 5, h - 1, OUT) + plot(img, ox + 6, h - 1, OUT) + plot(img, ox + w - 7, h - 1 - leg_off, OUT) + plot(img, ox + w - 6, h - 1 - leg_off, OUT) + # Accessory (weapon/shuriken hint) on throw/swing frames + if f >= frame_count - 2: + plot(img, ox + w - 2, 6, secondary) + plot(img, ox + w - 1, 6, secondary) + return img + + +def gen_qing_ren(): + # 16x16, 7 frames: idle 1 / run 2 / throw 2 / swing 2 + return gen_small_enemy_sheet( + primary=(70, 130, 180, 255), + secondary=(30, 70, 120, 255), + frame_count=7, + ) + + +def gen_chi_ren(): + # 16x16, 7 frames: idle 1 / run 2 / throw 2 / jump 2 + return gen_small_enemy_sheet( + primary=(190, 60, 60, 255), + secondary=(120, 20, 20, 255), + frame_count=7, + ) + + +def gen_hei_ren(): + # 20x24, 6 frames: idle 2 / run 2 / swing 2 + w, h = 20, 24 + frames = 6 + img = new_rgba(w * frames, h) + OUT = (10, 10, 12, 255) + BODY = (35, 35, 45, 255) + BODY_HI = (60, 60, 75, 255) + SK = (220, 190, 160, 255) + GOLD = (220, 180, 60, 255) + for f in range(frames): + ox = f * w + # Head + for y in range(2, 8): + plot(img, ox + 6, y, OUT); plot(img, ox + w - 7, y, OUT) + for x in range(6, w - 6): + plot(img, ox + x, 1, OUT); plot(img, ox + x, 8, OUT) + for y in range(2, 8): + for x in range(7, w - 7): + plot(img, ox + x, y, BODY) + # Mask (face strip) + for x in range(7, w - 7): + plot(img, ox + x, 5, SK) + # Eyes + plot(img, ox + 8, 5, OUT); plot(img, ox + w - 9, 5, OUT) + # Shoulders / torso + for y in range(9, 18): + plot(img, ox + 4, y, OUT); plot(img, ox + w - 5, y, OUT) + for y in range(9, 18): + for x in range(5, w - 5): + plot(img, ox + x, y, BODY) + # Body highlight + for y in range(10, 16): + plot(img, ox + 6, y, BODY_HI) + plot(img, ox + 7, y, BODY_HI) + # Belt w/ gold buckle (魔笛 hint) + for x in range(5, w - 5): + plot(img, ox + x, 17, OUT) + plot(img, ox + w // 2, 17, GOLD) + plot(img, ox + w // 2 - 1, 17, GOLD) + # Legs (motion) + leg_off = (f % 2) * 2 if 2 <= f <= 3 else 0 + for y in range(18, h - 1): + plot(img, ox + 7, y, OUT) + plot(img, ox + 8, y, BODY) + plot(img, ox + w - 9, y - leg_off, BODY) + plot(img, ox + w - 8, y - leg_off, OUT) + # Arm swing on last two frames + if f >= 4: + for i in range(4): + plot(img, ox + w - 5 + i, 10 - i, OUT) + plot(img, ox + w - 5 + i, 11 - i, BODY_HI) + return img + + +def gen_yao_fang(): + # 18x20, 4 frames: idle 1 / cast 3 + w, h = 18, 20 + frames = 4 + img = new_rgba(w * frames, h) + OUT = (15, 10, 20, 255) + ROBE = (120, 50, 140, 255) + ROBE_LIT = (170, 90, 190, 255) + SK = (220, 190, 160, 255) + FIRE_A = (255, 180, 60, 255) + FIRE_B = (255, 100, 40, 255) + for f in range(frames): + ox = f * w + # Wide robe (triangle-ish) + for y in range(4, h - 1): + width_at_y = 2 + int((y - 4) * 0.6) + x0 = w // 2 - width_at_y + x1 = w // 2 + width_at_y + plot(img, ox + x0, y, OUT) + plot(img, ox + x1 - 1, y, OUT) + for x in range(x0 + 1, x1 - 1): + plot(img, ox + x, y, ROBE) + # Robe highlight strip + for y in range(8, h - 2): + plot(img, ox + w // 2 - 1, y, ROBE_LIT) + plot(img, ox + w // 2, y, ROBE_LIT) + # Head (small skull under cowl) + for y in range(1, 5): + plot(img, ox + w // 2 - 2, y, OUT) + plot(img, ox + w // 2 + 1, y, OUT) + for x in range(w // 2 - 1, w // 2 + 1): + plot(img, ox + x, 0, OUT) + plot(img, ox + x, 4, OUT) + plot(img, ox + x, 2, SK) + plot(img, ox + x, 3, SK) + # Eyes + plot(img, ox + w // 2 - 1, 3, OUT) + plot(img, ox + w // 2, 3, OUT) + # Fireball charging on cast frames (f=1..3) + if f >= 1: + cx = ox + w - 3 + cy = 10 + radius = f # grows 1,2,3 + for dy in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + if dx * dx + dy * dy <= radius * radius: + col = FIRE_A if (dx + dy) % 2 == 0 else FIRE_B + plot(img, cx + dx, cy + dy, col) + return img + + +# ───────────────────────────────────────────────────────────────────── +# 3. Bosses +# ───────────────────────────────────────────────────────────────────── +def gen_shuang_huan_fang(): + """ + Per ASSETS.md: 32x32 (single body) OR 96x32 (双身 clone row). + We output 96x32 total = 3 frames x 32 wide (single body + 2 clones). + Actually spec says "idle 2 / clone 4 / fireball 3" — 9 frames. + We ship 9x32 = 288x32. + """ + fw, fh = 32, 32 + frames = 9 + img = new_rgba(fw * frames, fh) + OUT = (15, 5, 25, 255) + ROBE = (100, 30, 130, 255) + ROBE_D = (60, 15, 80, 255) + ROBE_L = (160, 70, 190, 255) + GOLD = (230, 200, 90, 255) + SK = (210, 180, 150, 255) + FIRE_A = (255, 160, 50, 255) + FIRE_B = (255, 80, 30, 255) + CLONE = (100, 30, 130, 140) # semi-transparent for clone frames + + for f in range(frames): + ox = f * fw + # Robe silhouette + for y in range(6, fh - 2): + width_at_y = 3 + int((y - 6) * 0.55) + x0 = fw // 2 - width_at_y + x1 = fw // 2 + width_at_y + for x in range(x0, x1): + plot(img, ox + x, y, ROBE) + plot(img, ox + x0 - 1, y, OUT) + plot(img, ox + x1, y, OUT) + # Robe shadow / highlight + for y in range(10, fh - 3): + plot(img, ox + fw // 2 - 2, y, ROBE_D) + plot(img, ox + fw // 2 + 1, y, ROBE_D) + plot(img, ox + fw // 2 - 1, y, ROBE_L) + plot(img, ox + fw // 2, y, ROBE_L) + # Head + hx, hy = fw // 2, 4 + for dy in range(-3, 4): + for dx in range(-3, 4): + if dx * dx + dy * dy <= 9: + plot(img, ox + hx + dx, hy + dy, SK) + for dy in range(-3, 4): + for dx in range(-3, 4): + if 9 - 2 <= dx * dx + dy * dy <= 9: + plot(img, ox + hx + dx, hy + dy, OUT) + # Eyes (glowing on fireball frames) + eye_col = GOLD if f >= 6 else OUT + plot(img, ox + hx - 1, hy, eye_col) + plot(img, ox + hx + 1, hy, eye_col) + # Crown / gold band + for x in range(hx - 3, hx + 4): + plot(img, ox + x, hy - 3, GOLD) + # Idle bob on frame 1 + # Clone frames (2..5): ghost duplicates drift outward + if 2 <= f <= 5: + offset = (f - 1) * 3 + for y in range(10, fh - 2): + for x in range(fw // 2 - 4, fw // 2 + 4): + plot(img, ox + x - offset, y, CLONE) + plot(img, ox + x + offset, y, CLONE) + # Fireball charge frames (6..8) + if f >= 6: + cr = f - 5 # 1..3 + cx, cy = ox + fw // 2, fh - 8 + for dy in range(-cr - 1, cr + 2): + for dx in range(-cr - 1, cr + 2): + d2 = dx * dx + dy * dy + if d2 <= (cr + 1) * (cr + 1): + col = FIRE_A if (dx + dy) % 2 == 0 else FIRE_B + plot(img, cx + dx, cy + dy, col) + return img + + +def gen_butterfly(): + # 16x16, 4 frames: fly (wing up/mid/down/mid) + w, h = 16, 16 + frames = 4 + img = new_rgba(w * frames, h) + OUT = (20, 10, 30, 255) + W1 = (255, 220, 100, 255) + W2 = (255, 160, 60, 255) + BODY = (40, 20, 50, 255) + for f in range(frames): + ox = f * w + # Body (center vertical line) + for y in range(4, 12): + plot(img, ox + w // 2, y, BODY) + plot(img, ox + w // 2 - 1, y, BODY) + # Wings — vertical stretch by frame + spread = [5, 6, 7, 6][f] + for dy in range(-spread // 2, spread // 2 + 1): + for dx in range(-spread, spread + 1): + # Elliptical wings + if (dx * dx) * 4 + (dy * dy) * 9 <= spread * spread * 4: + cx = ox + w // 2 + (dx + (2 if dx > 0 else -2)) + cy = h // 2 + dy + col = W1 if abs(dx) < spread - 1 else W2 + plot(img, cx, cy, col) + # Wing outlines + for dx in range(-spread - 1, spread + 2): + plot(img, ox + w // 2 + dx + (2 if dx > 0 else -2), h // 2 - spread // 2 - 1, OUT) + plot(img, ox + w // 2 + dx + (2 if dx > 0 else -2), h // 2 + spread // 2 + 1, OUT) + # Antennae + plot(img, ox + w // 2 - 1, 3, OUT) + plot(img, ox + w // 2 + 1, 3, OUT) + return img + + +# ───────────────────────────────────────────────────────────────────── +# 4. Scenes (parallax layers) +# ───────────────────────────────────────────────────────────────────── +SCENE_W, SCENE_H = 480, 270 + + +def gen_scene_forest(): + rng = random.Random(1001) + # Far: gradient sky + far = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H): + t = y / SCENE_H + r = int(180 - t * 60) + g = int(210 - t * 40) + b = int(220 - t * 30) + for x in range(SCENE_W): + far.putpixel((x, y), (r, g, b, 255)) + # Distant mountain silhouette + for x in range(SCENE_W): + height = int(40 + 30 * math.sin(x * 0.015) + 15 * math.sin(x * 0.04)) + for y in range(SCENE_H - 100 - height, SCENE_H - 100): + far.putpixel((x, y), (70, 100, 90, 255)) + + # Mid: distant trees + mid = new_rgba(SCENE_W, SCENE_H) + for _ in range(40): + tx = rng.randint(0, SCENE_W - 1) + th = rng.randint(60, 120) + tw = rng.randint(20, 40) + top_y = SCENE_H - 120 - th + # Trunk + for y in range(top_y + th - 20, SCENE_H - 80): + for x in range(tx - 2, tx + 3): + if 0 <= x < SCENE_W and 0 <= y < SCENE_H: + mid.putpixel((x, y), (70, 50, 30, 255)) + # Foliage (stacked triangles) + for layer in range(3): + lw = tw - layer * 4 + lh = 20 + ly = top_y + layer * 12 + for j in range(lh): + wj = int(lw * (1 - j / lh)) + for i in range(-wj, wj + 1): + x = tx + i + y = ly + j + if 0 <= x < SCENE_W and 0 <= y < SCENE_H: + mid.putpixel((x, y), (50 + layer * 15, 120 + layer * 15, 60, 255)) + + # Near: ground + close trees silhouettes + near = new_rgba(SCENE_W, SCENE_H) + # Ground + for y in range(SCENE_H - 80, SCENE_H): + shade = 40 + (SCENE_H - y) // 3 + for x in range(SCENE_W): + # Grass highlights + g = (30, 90, 50, 255) if ((x + y) % 7) != 0 else (60, 140, 70, 255) + near.putpixel((x, y), g) + # Grass blades + for _ in range(400): + x = rng.randint(0, SCENE_W - 1) + y = rng.randint(SCENE_H - 80, SCENE_H - 1) + h = rng.randint(2, 6) + for j in range(h): + if 0 <= y - j < SCENE_H: + near.putpixel((x, y - j), (20, 80, 40, 255)) + # A couple big tree silhouettes in foreground + for tx in [60, 300, 420]: + tw = 10 + top_y = SCENE_H - 200 + for y in range(top_y, SCENE_H - 80): + for x in range(tx - tw, tx + tw): + if 0 <= x < SCENE_W: + near.putpixel((x, y), (30, 20, 15, 255)) + # Canopy + for r in range(30): + for ang in range(0, 360, 5): + rad = math.radians(ang) + xx = tx + int(r * math.cos(rad) * 1.2) + yy = top_y - 10 + int(r * math.sin(rad)) + if 0 <= xx < SCENE_W and 0 <= yy < SCENE_H and r > 20: + near.putpixel((xx, yy), (25, 70, 40, 255)) + + # FX: subtle light rays / falling leaves + fx = new_rgba(SCENE_W, SCENE_H) + for _ in range(150): + x = rng.randint(0, SCENE_W - 1) + y = rng.randint(0, SCENE_H - 1) + fx.putpixel((x, y), (200, 230, 150, 90)) + # Diagonal light rays + for i in range(0, SCENE_W, 40): + for t in range(100): + x = i + t + y = t + if 0 <= x < SCENE_W and 0 <= y < SCENE_H: + fx.putpixel((x, y), (255, 245, 200, 40)) + + return far, mid, near, fx + + +def gen_scene_castle_wall(): + rng = random.Random(2002) + # Far: dusk sky + far = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H): + t = y / SCENE_H + r = int(60 + t * 40) + g = int(50 + t * 30) + b = int(90 + t * 20) + for x in range(SCENE_W): + far.putpixel((x, y), (r, g, b, 255)) + # Far towers + for tx in [80, 220, 360]: + for y in range(SCENE_H - 180, SCENE_H - 80): + for x in range(tx - 15, tx + 15): + far.putpixel((x, y), (50, 45, 65, 255)) + # Crenellations + for cx in range(tx - 15, tx + 15, 6): + for dy in range(5): + for dx in range(3): + if 0 <= cx + dx < SCENE_W: + far.putpixel((cx + dx, SCENE_H - 180 + dy), (70, 65, 85, 255)) + + # Mid: wall stones + mid = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H - 150, SCENE_H - 50): + for x in range(SCENE_W): + stone = ((x // 20 + y // 12) % 2) + base = 95 if stone else 110 + noise = rng.randint(-10, 10) + mid.putpixel((x, y), (base + noise, base + noise, base + 10 + noise, 255)) + # Mortar lines + for y in range(SCENE_H - 150, SCENE_H - 50, 12): + for x in range(SCENE_W): + mid.putpixel((x, y), (60, 55, 70, 255)) + for x in range(0, SCENE_W, 20): + for y in range(SCENE_H - 150, SCENE_H - 50): + mid.putpixel((x, y), (60, 55, 70, 255)) + + # Near: ground + torches + near = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H - 50, SCENE_H): + for x in range(SCENE_W): + near.putpixel((x, y), (45, 40, 55, 255)) + # Torches + for tx in [100, 280, 420]: + # Sconce + for y in range(SCENE_H - 110, SCENE_H - 80): + for x in range(tx - 2, tx + 2): + near.putpixel((x, y), (30, 20, 15, 255)) + # Flame + for y in range(SCENE_H - 130, SCENE_H - 110): + for x in range(tx - 5, tx + 5): + d = abs(x - tx) + abs(y - (SCENE_H - 120)) + if d < 6: + col = (255, 180, 60, 255) if d < 3 else (255, 100, 30, 255) + near.putpixel((x, y), col) + + # FX: fog / glow + fx = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H - 80, SCENE_H): + alpha = int((y - (SCENE_H - 80)) / 80 * 80) + for x in range(SCENE_W): + fx.putpixel((x, y), (180, 170, 190, alpha)) + + return far, mid, near, fx + + +def gen_scene_demon_castle(): + rng = random.Random(3003) + # Far: blood moon sky + far = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H): + t = y / SCENE_H + r = int(40 + t * 20) + g = int(10 + t * 10) + b = int(30 + t * 15) + for x in range(SCENE_W): + far.putpixel((x, y), (r, g, b, 255)) + # Moon + mx, my = 380, 70 + for dy in range(-30, 31): + for dx in range(-30, 31): + d = dx * dx + dy * dy + if d <= 900: + col = (220, 80, 80, 255) if d <= 700 else (180, 50, 50, 255) + far.putpixel((mx + dx, my + dy), col) + + # Mid: demon castle silhouette w/ spires + mid = new_rgba(SCENE_W, SCENE_H) + for x in range(SCENE_W): + base = SCENE_H - 100 + # Main body + if 100 < x < 380: + top = base - 80 + for y in range(top, SCENE_H - 60): + mid.putpixel((x, y), (25, 10, 35, 255)) + # Spires + for sx in [130, 200, 280, 350]: + height = rng.randint(100, 140) + for y in range(SCENE_H - 60 - height, SCENE_H - 60): + for x in range(sx - 8, sx + 8): + mid.putpixel((x, y), (30, 15, 40, 255)) + # Spike top + for j in range(20): + for x in range(sx - (8 - j // 3), sx + (8 - j // 3)): + mid.putpixel((x, SCENE_H - 60 - height - j), (30, 15, 40, 255)) + + # Near: floor + candles + near = new_rgba(SCENE_W, SCENE_H) + for y in range(SCENE_H - 60, SCENE_H): + for x in range(SCENE_W): + near.putpixel((x, y), (60, 20, 70, 255)) + # Floor tiles pattern + for x in range(0, SCENE_W, 32): + for y in range(SCENE_H - 60, SCENE_H): + near.putpixel((x, y), (30, 10, 40, 255)) + # Candles + for tx in [80, 200, 340, 440]: + for y in range(SCENE_H - 100, SCENE_H - 60): + near.putpixel((tx, y), (220, 200, 160, 255)) + # Flame + for dy in range(-6, 0): + for dx in range(-2, 3): + if abs(dx) + abs(dy) < 5: + near.putpixel((tx + dx, SCENE_H - 100 + dy), + (255, 200, 80, 255)) + + # FX: purple fog + sparks + fx = new_rgba(SCENE_W, SCENE_H) + for _ in range(200): + x = rng.randint(0, SCENE_W - 1) + y = rng.randint(0, SCENE_H - 1) + fx.putpixel((x, y), (200, 120, 220, 80)) + for y in range(SCENE_H - 60, SCENE_H): + alpha = int((y - (SCENE_H - 60)) / 60 * 100) + for x in range(0, SCENE_W, 2): + fx.putpixel((x, y), (160, 80, 180, alpha)) + + return far, mid, near, fx + + +# ───────────────────────────────────────────────────────────────────── +# 5. Story illustrations (480x270) +# ───────────────────────────────────────────────────────────────────── +def gen_story_page(title_text, bg_top, bg_bot, fg_color, draw_ninja=True, + draw_princess=False, draw_path=False, seed=0): + rng = random.Random(seed) + img = new_rgba(480, 270) + d = ImageDraw.Draw(img) + # Gradient background + for y in range(270): + t = y / 270 + r = int(bg_top[0] * (1 - t) + bg_bot[0] * t) + g = int(bg_top[1] * (1 - t) + bg_bot[1] * t) + b = int(bg_top[2] * (1 - t) + bg_bot[2] * t) + d.line([(0, y), (480, y)], fill=(r, g, b, 255)) + + # Distant silhouettes + for x in range(480): + h = int(50 + 25 * math.sin(x * 0.02)) + d.line([(x, 270 - 60 - h), (x, 270 - 60)], fill=(40, 30, 50, 255)) + # Ground + d.rectangle([(0, 210), (480, 270)], fill=(20, 15, 25, 255)) + + # Draw ninja (larger version of kage) + if draw_ninja: + # Scale factor: 5x -> 80x160 from 16x32 + # Position center-left + cx, cy = 160, 180 + # Body + d.rectangle([(cx - 20, cy - 50), (cx + 20, cy + 30)], fill=fg_color) + # Head + d.ellipse([(cx - 22, cy - 90), (cx + 22, cy - 40)], fill=fg_color) + # Mask + d.rectangle([(cx - 18, cy - 72), (cx + 18, cy - 58)], fill=(228, 188, 152, 255)) + # Eyes + d.rectangle([(cx - 10, cy - 68), (cx - 6, cy - 64)], fill=(20, 15, 15, 255)) + d.rectangle([(cx + 6, cy - 68), (cx + 10, cy - 64)], fill=(20, 15, 15, 255)) + # Scarf tail flowing + for i in range(40): + d.line([(cx + 20 + i, cy - 40 + i // 3), (cx + 22 + i, cy - 38 + i // 3)], + fill=(fg_color[0] - 40, fg_color[1] - 20, fg_color[2] - 20, 255)) + # Legs + d.rectangle([(cx - 15, cy + 30), (cx - 5, cy + 70)], fill=(40, 25, 25, 255)) + d.rectangle([(cx + 5, cy + 30), (cx + 15, cy + 70)], fill=(40, 25, 25, 255)) + # Sword on back + d.rectangle([(cx + 20, cy - 60), (cx + 40, cy - 55)], fill=(200, 200, 210, 255)) + d.rectangle([(cx + 15, cy - 55), (cx + 25, cy - 40)], fill=(100, 60, 30, 255)) + + # Draw princess (pink dress) + if draw_princess: + px, py = 340, 180 + # Body / dress (triangle) + points = [(px - 30, py + 30), (px + 30, py + 30), (px, py - 30)] + d.polygon(points, fill=(230, 130, 170, 255)) + # Head + d.ellipse([(px - 15, py - 60), (px + 15, py - 30)], fill=(255, 220, 190, 255)) + # Hair + d.ellipse([(px - 18, py - 65), (px + 18, py - 45)], fill=(80, 50, 30, 255)) + # Eyes (sad) + d.rectangle([(px - 8, py - 48), (px - 5, py - 45)], fill=(20, 15, 15, 255)) + d.rectangle([(px + 5, py - 48), (px + 8, py - 45)], fill=(20, 15, 15, 255)) + # Tear + d.rectangle([(px - 7, py - 42), (px - 6, py - 38)], fill=(100, 180, 230, 255)) + # Crown + for i in range(3): + d.polygon([(px - 10 + i * 10, py - 60), + (px - 7 + i * 10, py - 68), + (px - 4 + i * 10, py - 60)], + fill=(230, 200, 80, 255)) + # Captor shadow behind (blue enemy) + d.rectangle([(px - 5, py - 80), (px + 40, py - 20)], fill=(40, 80, 120, 180)) + + # Draw winding path to horizon + if draw_path: + for y in range(210, 270): + t = (y - 210) / 60 + width = int(100 * t) + cx = 240 + int(30 * math.sin(t * 3)) + d.line([(cx - width, y), (cx + width, y)], fill=(160, 130, 90, 255)) + # Footprints + for i in range(5): + fy = 220 + i * 10 + fx = 230 - i * 5 + d.ellipse([(fx, fy), (fx + 8, fy + 5)], fill=(80, 60, 40, 255)) + + # Title bar at top + d.rectangle([(0, 0), (480, 28)], fill=(0, 0, 0, 180)) + try: + font = ImageFont.load_default() + d.text((10, 7), title_text, fill=(240, 230, 200, 255), font=font) + except Exception: + d.text((10, 7), title_text, fill=(240, 230, 200, 255)) + + # Vignette + vignette = new_rgba(480, 270) + vd = ImageDraw.Draw(vignette) + for r in range(20): + alpha = int((20 - r) * 8) + vd.rectangle([(r, r), (480 - r, 270 - r)], outline=(0, 0, 0, alpha)) + img = Image.alpha_composite(img, vignette) + return img + + +# ───────────────────────────────────────────────────────────────────── +# 6. FX particle textures +# ───────────────────────────────────────────────────────────────────── +def gen_leaf_particle(): + img = new_rgba(16, 16) + # Simple leaf shape with stem + leaf_main = (110, 180, 70, 255) + leaf_lit = (160, 220, 100, 255) + stem = (80, 50, 30, 255) + vein = (70, 130, 50, 255) + for y in range(16): + for x in range(16): + dx = x - 8 + dy = y - 8 + # Elliptical leaf rotated 30 degrees + ang = math.radians(30) + rx = dx * math.cos(ang) - dy * math.sin(ang) + ry = dx * math.sin(ang) + dy * math.cos(ang) + if (rx * rx) / 25 + (ry * ry) / 9 <= 1: + if (rx * rx) / 16 + (ry * ry) / 4 <= 1: + img.putpixel((x, y), leaf_lit) + else: + img.putpixel((x, y), leaf_main) + # Stem + for i in range(5): + img.putpixel((11 + i // 2, 5 - i // 2), stem) + # Vein + for i in range(7): + img.putpixel((8 - i // 2, 8 + i // 2), vein) + return img + + +def gen_jump_dust(): + img = new_rgba(16, 16) + dust_a = (210, 210, 200, 255) + dust_b = (180, 175, 160, 180) + # Puff cloud + for (cx, cy, r) in [(8, 10, 4), (4, 12, 3), (12, 12, 3), (6, 8, 2), (11, 8, 2)]: + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + if dx * dx + dy * dy <= r * r: + if (dx + dy) % 2 == 0: + img.putpixel((cx + dx, cy + dy), dust_a) + else: + img.putpixel((cx + dx, cy + dy), dust_b) + return img + + +def gen_parry_spark(): + img = new_rgba(16, 16) + hot = (255, 245, 180, 255) + warm = (255, 200, 80, 255) + mid = (255, 120, 40, 255) + # 4-point star + for i in range(8): + img.putpixel((8, 8 - i), warm if i > 2 else hot) + img.putpixel((8, 8 + i), warm if i > 2 else hot) + img.putpixel((8 - i, 8), warm if i > 2 else hot) + img.putpixel((8 + i, 8), warm if i > 2 else hot) + # Diagonals + for i in range(5): + img.putpixel((8 + i, 8 + i), mid) + img.putpixel((8 - i, 8 - i), mid) + img.putpixel((8 + i, 8 - i), mid) + img.putpixel((8 - i, 8 + i), mid) + # Center + img.putpixel((8, 8), hot) + img.putpixel((7, 8), hot) + img.putpixel((9, 8), hot) + img.putpixel((8, 7), hot) + img.putpixel((8, 9), hot) + return img + + +# ───────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────── +def main(): + print("==== Procedural pixel-art asset generation ====\n") + print("[1/6] Protagonist sprites ...") + for variant in ("kage_red", "kage_green", "kage_yellow"): + save_png(gen_kage_sheet(variant), f"textures/characters/{variant}.png") + + print("\n[2/6] Enemies ...") + save_png(gen_qing_ren(), "textures/enemies/qing_ren.png") + save_png(gen_chi_ren(), "textures/enemies/chi_ren.png") + save_png(gen_hei_ren(), "textures/enemies/hei_ren.png") + save_png(gen_yao_fang(), "textures/enemies/yao_fang.png") + + print("\n[3/6] Bosses ...") + save_png(gen_shuang_huan_fang(), "textures/bosses/shuang_huan_fang.png") + save_png(gen_butterfly(), "textures/bosses/butterfly.png") + + print("\n[4/6] Scene parallax layers ...") + for theme_name, gen_func in ( + ("forest", gen_scene_forest), + ("castle_wall", gen_scene_castle_wall), + ("demon_castle", gen_scene_demon_castle), + ): + far, mid, near, fx = gen_func() + save_png(far, f"textures/scenes/{theme_name}/far.png") + save_png(mid, f"textures/scenes/{theme_name}/mid.png") + save_png(near, f"textures/scenes/{theme_name}/near.png") + save_png(fx, f"textures/scenes/{theme_name}/fx.png") + + print("\n[5/6] Story illustrations ...") + save_png( + gen_story_page( + "Chapter 1 - Page 1: The Ninja", + bg_top=(70, 100, 160), bg_bot=(30, 40, 80), + fg_color=(170, 40, 34, 255), + draw_ninja=True, seed=1, + ), + "textures/story/ch1_page1_ninja.png", + ) + save_png( + gen_story_page( + "Chapter 1 - Page 2: The Princess Taken", + bg_top=(140, 60, 100), bg_bot=(50, 20, 50), + fg_color=(110, 20, 18, 255), + draw_ninja=False, draw_princess=True, seed=2, + ), + "textures/story/ch1_page2_princess.png", + ) + save_png( + gen_story_page( + "Chapter 1 - Page 3: Departure", + bg_top=(200, 150, 100), bg_bot=(80, 60, 100), + fg_color=(170, 40, 34, 255), + draw_ninja=True, draw_path=True, seed=3, + ), + "textures/story/ch1_page3_depart.png", + ) + + print("\n[6/6] FX particle textures ...") + save_png(gen_leaf_particle(), "textures/fx/leaf_particle.png") + save_png(gen_jump_dust(), "textures/fx/jump_dust.png") + save_png(gen_parry_spark(), "textures/fx/parry_spark.png") + + print("\n[7/6] Patching .meta files to type=sprite-frame ...") + _patch_meta_files() + + print("\n==== Done. ====") + print("Audio files (wav / mp3) are left to `scripts/gen_placeholder_assets.js`.") + + +if __name__ == "__main__": + main() diff --git a/settings/v2/packages/cocos-service.json b/settings/v2/packages/cocos-service.json index 71bc50c..88cb04a 100644 --- a/settings/v2/packages/cocos-service.json +++ b/settings/v2/packages/cocos-service.json @@ -1,7 +1,7 @@ { "__version__": "3.0.9", "game": { - "name": "UNKNOW GAME", + "name": "未知游戏", "app_id": "UNKNOW", "c_id": "0" }, diff --git a/settings/v2/packages/information.json b/settings/v2/packages/information.json index 94848de..137902c 100644 --- a/settings/v2/packages/information.json +++ b/settings/v2/packages/information.json @@ -4,19 +4,19 @@ "customSplash": { "id": "customSplash", "label": "customSplash", - "enable": false, + "enable": true, "customSplash": { "complete": false, - "form": "https://creator-api.cocos.com/api/form/show?" + "form": "https://creator-api.cocos.com/api/form/show?sid=8fef34a64f75f23c72b8c90e2d0833f0" } }, "removeSplash": { "id": "removeSplash", "label": "removeSplash", - "enable": false, + "enable": true, "removeSplash": { "complete": false, - "form": "https://creator-api.cocos.com/api/form/show?" + "form": "https://creator-api.cocos.com/api/form/show?sid=8fef34a64f75f23c72b8c90e2d0833f0" } } } diff --git a/test_motion.ts b/test_motion.ts new file mode 100644 index 0000000..dd537de --- /dev/null +++ b/test_motion.ts @@ -0,0 +1,15 @@ +import { PlayerMotionModel } from './assets/scripts/logic/PlayerMotionModel'; +import { PlayerColorState, MOVE_SPEED } from './assets/scripts/common/Constants'; + +const m = new PlayerMotionModel({ + aabb: { x: 0, y: 16, w: 16, h: 32 }, + platforms: [{ topY: 0, leftX: -500, rightX: 500 }], + initialColorState: PlayerColorState.Red, + levelLengthPx: 2000, +}); +console.log('after constructor: grounded=', m.isGrounded, 'aabb=', m.aabb, 'vx=', m.vx); +m.update(0.016); +console.log('after settle: grounded=', m.isGrounded, 'aabb=', m.aabb, 'vx=', m.vx, 'vy=', m.vy); +m.setHorizontalInput(1); +m.update(1); +console.log('after move: aabb.x=', m.aabb.x, 'vx=', m.vx); diff --git a/tests/__mocks__/cc.ts b/tests/__mocks__/cc.ts index cdadff0..1afa006 100644 --- a/tests/__mocks__/cc.ts +++ b/tests/__mocks__/cc.ts @@ -54,17 +54,22 @@ export class Node { public name: string = ''; public active: boolean = true; public layer: number = 0; + public isValid: boolean = true; + public scale: Vec3 = new Vec3(1, 1, 1); constructor(name?: string) { this.name = name ?? ''; } public addChild(_child: Node): void {} + public removeFromParent(): void {} + public destroy(): void { this.isValid = false; } public on(_ev: string, _cb: (...args: unknown[]) => void, _target?: unknown): void {} public off(_ev: string, _cb?: (...args: unknown[]) => void): void {} public getComponent(_ctor: new (...args: unknown[]) => T): T | null { return null; } public addComponent(_ctor: new (...args: unknown[]) => T): T { return {} as T; } public setPosition(..._args: unknown[]): void {} + public setScale(..._args: unknown[]): void {} public static EventType = { TOUCH_START: 'touch-start', @@ -158,6 +163,7 @@ export class Label { public horizontalAlign: number = 0; public verticalAlign: number = 0; public useSystemFont: boolean = true; + public enableWrapText: boolean = true; } export class Button { @@ -181,16 +187,39 @@ export class Sprite { public spriteFrame: unknown = null; public type: number = 0; public sizeMode: number = 0; + public color: Color = Color.WHITE; public static Type = { SIMPLE: 0, SLICED: 1, TILED: 2, FILLED: 3 }; public static SizeMode = { CUSTOM: 0, TRIMMED: 1, RAW: 2 }; } +export class Rect { + public x: number; + public y: number; + public width: number; + public height: number; + constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) { + this.x = x; this.y = y; this.width = width; this.height = height; + } +} + +export class Size { + public width: number; + public height: number; + constructor(width: number = 0, height: number = 0) { + this.width = width; this.height = height; + } +} + export class SpriteFrame { public texture: unknown = null; + public rect: Rect = new Rect(); + public originalSize: Size = new Size(); } export class Texture2D { public image: unknown = null; + public width: number = 0; + public height: number = 0; public static PixelFormat = { RGBA8888: 35 }; } @@ -205,6 +234,21 @@ export class JsonAsset { export class Canvas {} export class EventTouch {} +/** Global input event source (Cocos Creator 3.8). */ +export const input = { + on: (_ev: string, _cb: (...args: unknown[]) => void, _target?: unknown): void => {}, + off: (_ev: string, _cb?: (...args: unknown[]) => void, _target?: unknown): void => {}, +}; + +export const Input = { + EventType: { + TOUCH_START: 'touch-start', + TOUCH_MOVE: 'touch-move', + TOUCH_END: 'touch-end', + TOUCH_CANCEL: 'touch-cancel', + }, +}; + export const resources = { load: (_path: string, _type: unknown, _cb: (err: Error | null, asset: unknown) => void): void => {}, }; diff --git a/tests/logic/BossSettlement.test.ts b/tests/logic/BossSettlement.test.ts index 8c0e79d..0f83367 100644 --- a/tests/logic/BossSettlement.test.ts +++ b/tests/logic/BossSettlement.test.ts @@ -10,8 +10,8 @@ const bossCfg: IBossConfig = { princessCutsceneAtHpRatio: 0.5, phases: [ { hpThreshold: 1.0, mode: 'pair_pincer', actionIntervalSec: 2.2 }, - { hpThreshold: 0.66, mode: 'fireball_spread', actionIntervalSec: 1.8 }, - { hpThreshold: 0.33, mode: 'clone_confuse', actionIntervalSec: 1.4 }, + { hpThreshold: 2 / 3, mode: 'fireball_spread', actionIntervalSec: 1.8 }, + { hpThreshold: 1 / 3, mode: 'clone_confuse', actionIntervalSec: 1.4 }, ], }; diff --git a/tests/logic/JumpController.test.ts b/tests/logic/JumpController.test.ts index 5e1de44..9209389 100644 --- a/tests/logic/JumpController.test.ts +++ b/tests/logic/JumpController.test.ts @@ -13,6 +13,7 @@ function newPair(color: PlayerColorState = PlayerColorState.Red) { aabb: { x: 0, y: 16, w: 16, h: 32 }, platforms: [{ topY: 0, leftX: -500, rightX: 500 }], initialColorState: color, + levelLengthPx: 2000, }); motion.update(0.016); // settle on ground const jump = new JumpController(motion); diff --git a/tests/logic/PlayerMotionModel.test.ts b/tests/logic/PlayerMotionModel.test.ts index 111d553..16f6fdf 100644 --- a/tests/logic/PlayerMotionModel.test.ts +++ b/tests/logic/PlayerMotionModel.test.ts @@ -7,9 +7,10 @@ function makeGroundPlatform(): IPlatform { function makeModel(color: PlayerColorState = PlayerColorState.Red) { return new PlayerMotionModel({ - aabb: { x: 0, y: 16, w: 16, h: 32 }, // character 16x32 resting on y=0 ground + aabb: { x: 100, y: 16, w: 16, h: 32 }, // character 16x32 resting on y=0 ground; x=100 avoids level-edge clamp platforms: [makeGroundPlatform()], initialColorState: color, + levelLengthPx: 2000, }); } @@ -27,7 +28,7 @@ describe('PlayerMotionModel — horizontal movement (req 2.1, 5.1-5.2)', () => { m.setHorizontalInput(1); m.update(1); expect(m.vx).toBe(MOVE_SPEED[PlayerColorState.Red]); - expect(m.aabb.x).toBeCloseTo(100, 1); + expect(m.aabb.x).toBeCloseTo(200, 1); }); it('moves at 150 px/s in yellow state', () => { diff --git a/tests/logic/PlayerStateMachine.test.ts b/tests/logic/PlayerStateMachine.test.ts index 5492742..38d1716 100644 --- a/tests/logic/PlayerStateMachine.test.ts +++ b/tests/logic/PlayerStateMachine.test.ts @@ -44,21 +44,22 @@ describe('PlayerStateMachine — damage (req 5.3-5.6, 10.4-10.5)', () => { expect(sm.color).toBe(PlayerColorState.Red); }); - it('Red + shuriken → death, consumes one life', () => { + it('Red + shuriken → life loss (downgraded) when lives > 1', () => { const sm = new PlayerStateMachine(2); const out = sm.takeHit('shuriken'); - expect(out.kind).toBe('died'); + expect(out.kind).toBe('downgraded'); expect(sm.lives).toBe(1); expect(sm.isDead).toBe(false); }); - it('fireball is always lethal regardless of color', () => { + it('fireball reduces one life regardless of color', () => { const sm = new PlayerStateMachine(2); sm.pickupCrystalJade(); sm.pickupCrystalJade(); const out = sm.takeHit('fireball'); - expect(out).toEqual({ kind: 'died', cause: 'fireball' }); - expect(sm.color).toBe(PlayerColorState.Red); + expect(out.kind).toBe('downgraded'); + expect(sm.lives).toBe(1); + expect(sm.isDead).toBe(false); }); it('smoke bomb is always lethal', () => { diff --git a/tests/ui/InputModel.test.ts b/tests/ui/InputModel.test.ts index 43439d9..6942c0c 100644 --- a/tests/ui/InputModel.test.ts +++ b/tests/ui/InputModel.test.ts @@ -5,6 +5,7 @@ import { applySafeArea, classifyDirection, hitTest, + isInsideCircle, isInsideRect, joystickDirection, ZERO_DIRECTION, @@ -21,8 +22,33 @@ describe('InputModel — layout geometry', () => { const r = { cx: 100, cy: 100, w: 40, h: 40 }; expect(isInsideRect(r, 100, 100)).toBe(true); expect(isInsideRect(r, 120, 120)).toBe(true); - expect(isInsideRect(r, 121, 100)).toBe(false); - expect(isInsideRect(r, 100, 79)).toBe(false); + // With HIT_TOLERANCE=15, the effective boundary is ±35 (=halfW/halfH + tolerance). + // 121 is outside the visual box (120) but within tolerance (135). + expect(isInsideRect(r, 121, 100)).toBe(true); + // 79 is below the visual bottom (80) but within tolerance (65). + expect(isInsideRect(r, 100, 79)).toBe(true); + // Far outside even the tolerance range. + expect(isInsideRect(r, 136, 100)).toBe(false); + expect(isInsideRect(r, 100, 64)).toBe(false); + }); + + it('isInsideCircle matches circular button shape', () => { + const r = { cx: 765, cy: 100, w: 90, h: 90 }; // shuriken button + // Centre — always a hit. + expect(isInsideCircle(r, 765, 100)).toBe(true); + // On the circle edge (radius = 45). + expect(isInsideCircle(r, 765 + 45, 100)).toBe(true); // right edge + expect(isInsideCircle(r, 765, 100 + 45)).toBe(true); // top edge (upper semicircle) + expect(isInsideCircle(r, 765 - 45, 100)).toBe(true); // left edge + expect(isInsideCircle(r, 765, 100 - 45)).toBe(true); // bottom edge + // Within HIT_TOLERANCE (15) outside the circle. + expect(isInsideCircle(r, 765 + 60, 100)).toBe(true); // right + tolerance + expect(isInsideCircle(r, 765, 100 + 60)).toBe(true); // top + tolerance (upper arc) + // Corner of the bounding rect but OUTSIDE the circle — should miss. + // Distance from centre to (765+45, 100+45) = sqrt(45²+45²) ≈ 63.6 > 45+15=60 + expect(isInsideCircle(r, 765 + 45, 100 + 45)).toBe(false); + // Well outside. + expect(isInsideCircle(r, 765 + 70, 100)).toBe(false); }); });