diff --git a/docs/roadmap.md b/docs/roadmap.md index 68ee6da..fb5e67b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -453,7 +453,7 @@ PNG/OGG/数据文件,代码用标准 pygame 读取。不依赖 AI,未登录 | PWA 与离线 | 未实现 | 中 | ❌ 缺口 | ❌ 缺口 | 不适用 | ❌ 缺口(更新失败回到旧版本) | 无 | | MCP / WebMCP 外部协作 | 已有 | 高(外部写入、权限) | ✅(开启协作、显示状态与 MCP URL、外部提案生成候选、采用写入并支持撤销;授权用户检索素材目录、提议一份素材成为候选) | ✅(未登录拒绝开启、丢弃提案后项目不变、无效 Token 拒绝;没有令牌不能检索;不存在的素材 id 不产生候选;项目里已有同路径时不能盖掉) | ✅ 匿名页面可检索和做候选但不调 `/api/`;MCP 通道要令牌。已登录未授权沿用身份门拒绝 | ✅(丢弃提案项目不变;采用后可撤销;冲突的素材提议不写入) | `tests/e2e/collab.spec.ts`(4 条)、`tests/e2e/recipes.spec.ts`、`worker/mcp.test.ts` | | 游戏配方与教程 | 已有(阶段 E) | 中(覆盖旧项目) | ✅(匿名从平台跳跃配方新建项目,舞台打出 `RECIPE-OK platform`;旧项目还在) | ✅(未改之前检查失败;改高了但控制台仍是旧标记也失败;`time.sleep` 失败) | ✅ 匿名可用,不发 `/api/`。已登录未授权仍可做配方。小助手对话仍要授权 | ✅(挑战候选在采用前不改 `JUMP`;丢掉后仍是 -9;采用后检查通过;撤销后再次失败) | `tests/e2e/recipes.spec.ts`;`src/lib/recipes/recipes.test.ts` | -| 内置素材库 | 已有(阶段 A:森林冒险,以及 Kenney CC0 的平台跳跃、迷宫地牢、太空射击、竞速、益智、界面、音效。跨项目个人图片未做;动画见阶段 B,地图见阶段 C,检索见阶段 E) | 高(覆盖作品、版权) | ✅(匿名浏览、筛选、预览许可与来源 → 复制进项目 → pygame 在舞台加载 → 源码包和网页包里都有这份文件;Kenney 地牢图块同样走这条路;加入时没有 `/api/` 请求;做成候选再采用也会复制;图块可开始画地图,角色帧可打开动画编辑器,音效可在声音编辑器试听) | ✅(同名先问再保留/另存/替换;重复复制不写第二份;hash 不符或许可/来源为空则校验失败,构建会被挡住;候选遇到不同字节则拒绝;瓦片网格对不上编辑器尺寸也不能构建) | ✅ 匿名可用。小助手和 WebMCP 不能在文件里写二进制,也不能改孩子手写的 `assets/library.json`;点名素材只进候选 | ✅(替换后撤销回到旧文件;复制后撤销去掉这份;删进回收站可以恢复;丢掉候选不留下文件) | `tests/e2e/library.spec.ts`;`tests/e2e/kenney.spec.ts`;`tests/e2e/recipes.spec.ts`;`src/lib/assets/*.test.ts`;构建时 `vite.config.ts` 的素材包检查 | +| 内置素材库 | 已有(阶段 A:森林冒险,以及 Kenney CC0 的平台跳跃、迷宫地牢、太空射击、竞速、益智、界面、音效。跨项目个人图片未做;动画见阶段 B,地图见阶段 C,检索见阶段 E) | 高(覆盖作品、版权) | ✅(匿名浏览、筛选(声音卡片都有中文名,同名筛选只出现一次,选中声音能看到时长)、预览许可与来源 → 复制进项目 → pygame 在舞台加载 → 源码包和网页包里都有这份文件;Kenney 地牢图块同样走这条路;加入时没有 `/api/` 请求;做成候选再采用也会复制;图块可开始画地图,角色帧可打开动画编辑器,音效可在声音编辑器试听) | ✅(同名先问再保留/另存/替换;重复复制不写第二份;hash 不符或许可/来源为空则校验失败,构建会被挡住;候选遇到不同字节则拒绝;瓦片网格对不上编辑器尺寸也不能构建) | ✅ 匿名可用。小助手和 WebMCP 不能在文件里写二进制,也不能改孩子手写的 `assets/library.json`;点名素材只进候选 | ✅(替换后撤销回到旧文件;复制后撤销去掉这份;删进回收站可以恢复;丢掉候选不留下文件) | `tests/e2e/library.spec.ts`(含声音卡片名字、筛选去重、播放器时长);`tests/e2e/kenney.spec.ts`;`tests/e2e/recipes.spec.ts`;`src/lib/assets/*.test.ts`;构建时 `vite.config.ts` 的素材包检查 | | 像素编辑器 / 新建图片 | 已有(单张图片的画笔、保存和撤销;多帧见下一行) | 高(覆盖孩子已有素材、作品丢失) | ✅(新建 → 画 → 保存 → 项目里出现 PNG、预览里 pygame 加载;打开已有 PNG → 改 → 保存 → 撤销还原;另存为新图片;触控与笔;导出包字节一致;生成素材继续改像素) | ✅(非法尺寸被拒;同名不覆盖、自动加序号;未保存关闭先问、继续画/不保存/保存;写入失败后画布保留、可重试;缓冲区/填充越界与死循环、PNG 往返逐字节、alpha 保持、解压体积受表头限制、撤销占用有上限的单元测试;生成素材里「保存并关闭」不会再弹出空的生成面板) | ✅ 匿名可用(不发任何 `/api/` 请求;不新增 AI 工具,小助手与 WebMCP 没有改 assets/ 的能力) | ✅(写入失败不留半成品,重试成功;覆盖可用工作室撤销还原;新图保存后可撤销) | `tests/e2e/pixel.spec.ts`(14 条);`src/lib/pixel/*.test.ts`(单张图画笔仍由 `buffer`、`png`、`rules`、`history`、`session`、`save`、`export` 覆盖;多帧见下一行) | | 精灵与动画编辑器 | 已有(阶段 B) | 高(覆盖已有素材、帧和说明对不上) | ✅(匿名:画多帧 → 设时长和动作 → 保存 → 重新打开后帧序和时长还在 → PNG 与 JSON 的像素、alpha、帧序可比对 → 源码包里两份文件都在 → pygame 在舞台打出 `ANIM-OK` 和 `ANIM-PIXEL`) | ✅(坏的动画说明按整张图打开且不改文件;写入失败时 PNG 和 JSON 都不留下,画布还在,重试成功;选区移动、图层显隐、洋葱皮、复制/删除帧、精灵表导入可撤销) | ✅ 匿名可用,不发 `/api/`。小助手和 WebMCP 不能改写 `*.anim.json` | ✅(失败写入不留半成品,重试成功;试播先确认,工作室撤销回到试播前的代码,精灵表还在) | `tests/e2e/anim.spec.ts`;`src/lib/pixel/anim.test.ts`、`film.test.ts`、`save.test.ts`;`src/lib/ai.test.ts`;`worker/mcp.test.ts` | | 瓦片地图编辑器 | 已有(阶段 C) | 高(覆盖已有关卡、半截文件) | ✅(匿名:画关 → 碰撞、出生点、物件 → 保存 → 重新打开还原 → 导出地图包再导入一致 → 源码包里有 JSON/CSV/PNG → pygame 打出 `MAP-OK` 碰撞和出生点;加入时没有 `/api/` 请求) | ✅(非法地图说明原因且原文件不变;写入失败时不留半截文件,画面还在,重试成功) | ✅ 匿名可用,不发 `/api/`。小助手和 WebMCP 不能改写 `*.map.json`、`maps/*.csv`、`*.tileset.png` | ✅(失败写入不留半成品,重试成功;试玩先确认,工作室撤销回到试玩前的代码,地图文件还在) | `tests/e2e/map.spec.ts`;`src/lib/map/*.test.ts`;`src/lib/ai.test.ts`;`worker/mcp.test.ts` | diff --git a/docs/runtime.md b/docs/runtime.md index 6b0ef50..c34b10f 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -39,6 +39,8 @@ await asyncio.sleep(0) 预览和导出都打包项目里的这份副本(和别的 `assets/` 文件一样)。成品不请求素材库目录,也不请求 GameKit 站点。素材库以后升级,不会改掉已经复制进项目、或已经导出的那一份。模板本身没有为素材库改过。 +素材库里听声音时,能播 OGG 的浏览器播 Kenney 原来的文件。Safari 播不了 OGG,所以每个 OGG 旁边有一份 `*.preview.wav`,只给素材库播放器当预览。复制进项目的仍是清单里的那份 OGG 或 WAV。预览 wav 不进项目,也不进导出包。模板没有为这个预览改过。 + ## 离线导出 阶段 G 核对的是导出的游戏,不是把整个工作室变成断网应用。 diff --git a/public/asset-packs/kenney-dungeon/sounds/door.preview.wav b/public/asset-packs/kenney-dungeon/sounds/door.preview.wav new file mode 100644 index 0000000..67afae3 Binary files /dev/null and b/public/asset-packs/kenney-dungeon/sounds/door.preview.wav differ diff --git a/public/asset-packs/kenney-dungeon/sounds/step.preview.wav b/public/asset-packs/kenney-dungeon/sounds/step.preview.wav new file mode 100644 index 0000000..7039cff Binary files /dev/null and b/public/asset-packs/kenney-dungeon/sounds/step.preview.wav differ diff --git a/public/asset-packs/kenney-platform/sounds/jump.preview.wav b/public/asset-packs/kenney-platform/sounds/jump.preview.wav new file mode 100644 index 0000000..045a148 Binary files /dev/null and b/public/asset-packs/kenney-platform/sounds/jump.preview.wav differ diff --git a/public/asset-packs/kenney-platform/sounds/step.preview.wav b/public/asset-packs/kenney-platform/sounds/step.preview.wav new file mode 100644 index 0000000..5af5a69 Binary files /dev/null and b/public/asset-packs/kenney-platform/sounds/step.preview.wav differ diff --git a/public/asset-packs/kenney-puzzle/sounds/ok.preview.wav b/public/asset-packs/kenney-puzzle/sounds/ok.preview.wav new file mode 100644 index 0000000..99136fd Binary files /dev/null and b/public/asset-packs/kenney-puzzle/sounds/ok.preview.wav differ diff --git a/public/asset-packs/kenney-puzzle/sounds/place.preview.wav b/public/asset-packs/kenney-puzzle/sounds/place.preview.wav new file mode 100644 index 0000000..f95a195 Binary files /dev/null and b/public/asset-packs/kenney-puzzle/sounds/place.preview.wav differ diff --git a/public/asset-packs/kenney-racing/sounds/clank.preview.wav b/public/asset-packs/kenney-racing/sounds/clank.preview.wav new file mode 100644 index 0000000..13c1c98 Binary files /dev/null and b/public/asset-packs/kenney-racing/sounds/clank.preview.wav differ diff --git a/public/asset-packs/kenney-racing/sounds/crash.preview.wav b/public/asset-packs/kenney-racing/sounds/crash.preview.wav new file mode 100644 index 0000000..978e032 Binary files /dev/null and b/public/asset-packs/kenney-racing/sounds/crash.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/back.preview.wav b/public/asset-packs/kenney-sfx/sounds/back.preview.wav new file mode 100644 index 0000000..2aa2506 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/back.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/boom.preview.wav b/public/asset-packs/kenney-sfx/sounds/boom.preview.wav new file mode 100644 index 0000000..dad73be Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/boom.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/click.preview.wav b/public/asset-packs/kenney-sfx/sounds/click.preview.wav new file mode 100644 index 0000000..3f61ce2 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/click.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/coin.preview.wav b/public/asset-packs/kenney-sfx/sounds/coin.preview.wav new file mode 100644 index 0000000..f95a195 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/coin.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/confirm.preview.wav b/public/asset-packs/kenney-sfx/sounds/confirm.preview.wav new file mode 100644 index 0000000..99136fd Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/confirm.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/door.preview.wav b/public/asset-packs/kenney-sfx/sounds/door.preview.wav new file mode 100644 index 0000000..67afae3 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/door.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/hit.preview.wav b/public/asset-packs/kenney-sfx/sounds/hit.preview.wav new file mode 100644 index 0000000..08a672c Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/hit.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/jump-soft.preview.wav b/public/asset-packs/kenney-sfx/sounds/jump-soft.preview.wav new file mode 100644 index 0000000..c7447b0 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/jump-soft.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/jump.preview.wav b/public/asset-packs/kenney-sfx/sounds/jump.preview.wav new file mode 100644 index 0000000..045a148 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/jump.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/laser-small.preview.wav b/public/asset-packs/kenney-sfx/sounds/laser-small.preview.wav new file mode 100644 index 0000000..de89ac7 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/laser-small.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/laser.preview.wav b/public/asset-packs/kenney-sfx/sounds/laser.preview.wav new file mode 100644 index 0000000..9145354 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/laser.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/power.preview.wav b/public/asset-packs/kenney-sfx/sounds/power.preview.wav new file mode 100644 index 0000000..eba17fb Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/power.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/punch.preview.wav b/public/asset-packs/kenney-sfx/sounds/punch.preview.wav new file mode 100644 index 0000000..461d3fd Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/punch.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/step-stone.preview.wav b/public/asset-packs/kenney-sfx/sounds/step-stone.preview.wav new file mode 100644 index 0000000..7039cff Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/step-stone.preview.wav differ diff --git a/public/asset-packs/kenney-sfx/sounds/step.preview.wav b/public/asset-packs/kenney-sfx/sounds/step.preview.wav new file mode 100644 index 0000000..5af5a69 Binary files /dev/null and b/public/asset-packs/kenney-sfx/sounds/step.preview.wav differ diff --git a/public/asset-packs/kenney-space/sounds/hit.preview.wav b/public/asset-packs/kenney-space/sounds/hit.preview.wav new file mode 100644 index 0000000..08a672c Binary files /dev/null and b/public/asset-packs/kenney-space/sounds/hit.preview.wav differ diff --git a/public/asset-packs/kenney-space/sounds/laser.preview.wav b/public/asset-packs/kenney-space/sounds/laser.preview.wav new file mode 100644 index 0000000..9145354 Binary files /dev/null and b/public/asset-packs/kenney-space/sounds/laser.preview.wav differ diff --git a/public/asset-packs/kenney-ui/sounds/back.preview.wav b/public/asset-packs/kenney-ui/sounds/back.preview.wav new file mode 100644 index 0000000..2aa2506 Binary files /dev/null and b/public/asset-packs/kenney-ui/sounds/back.preview.wav differ diff --git a/public/asset-packs/kenney-ui/sounds/click.preview.wav b/public/asset-packs/kenney-ui/sounds/click.preview.wav new file mode 100644 index 0000000..3f61ce2 Binary files /dev/null and b/public/asset-packs/kenney-ui/sounds/click.preview.wav differ diff --git a/src/lib/assets/check-disk.ts b/src/lib/assets/check-disk.ts index 6b48f6f..a811253 100644 --- a/src/lib/assets/check-disk.ts +++ b/src/lib/assets/check-disk.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { oggPreviewWavPath } from "./preview-audio"; import { catalogIndexSchema } from "./schema"; import { validateAssetPack, type PackIssue } from "./validate"; @@ -61,6 +62,13 @@ export async function checkAssetPacks(root: string): Promise { } catch { // validateAssetPack reports the missing file. } + const preview = oggPreviewWavPath(asset.path); + if (!preview) continue; + try { + files[preview] = readFileSync(join(dir, preview)); + } catch { + // validateAssetPack reports the missing WAV preview. + } } } const checked = await validateAssetPack(manifest, files); diff --git a/src/lib/assets/display.ts b/src/lib/assets/display.ts new file mode 100644 index 0000000..39577f7 --- /dev/null +++ b/src/lib/assets/display.ts @@ -0,0 +1,10 @@ +/** + * The name on a library card. + * + * Manifest `title` is that name. Official packs store a Chinese title there; + * the ship gate rejects an empty one (`missing-title`) and a title with no + * Chinese (`title-language`). The card renders this string, not the file name. + */ +export function assetDisplayName(asset: { title: string }): string { + return asset.title.trim(); +} diff --git a/src/lib/assets/filter.test.ts b/src/lib/assets/filter.test.ts new file mode 100644 index 0000000..36ba8b9 --- /dev/null +++ b/src/lib/assets/filter.test.ts @@ -0,0 +1,58 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { text } from "../../ui/text"; +import { filterAssets, libraryFilterGroups } from "./filter"; +import type { AssetPack, CatalogAsset } from "./schema"; + +function shippedAssets(): CatalogAsset[] { + const root = join(process.cwd(), "public", "asset-packs"); + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => (JSON.parse(readFileSync(join(root, entry.name, "manifest.json"), "utf8")) as AssetPack).assets); +} + +const DUPLICATES = ["音效", "角色", "界面", "地形", "益智", "平台跳跃", "迷宫地牢", "音乐", "图块", "特效", "道具"]; + +describe("library filter groups", () => { + const assets = shippedAssets(); + const groups = libraryFilterGroups(assets, text.library.categories); + + it("shows each of the old duplicate labels once", () => { + const labels = [ + ...groups.categories.map((chip) => chip.label), + ...groups.themes, + ...groups.tags, + ...groups.moreTags, + ]; + expect(new Set(labels).size).toBe(labels.length); + for (const label of DUPLICATES) expect(labels.filter((item) => item === label)).toEqual([label]); + expect(groups.categories.map((chip) => chip.label)).toContain("音效"); + expect(groups.themes).not.toContain("音效"); + expect([...groups.tags, ...groups.moreTags]).not.toContain("音效"); + expect(groups.themes).toContain("平台跳跃"); + expect([...groups.tags, ...groups.moreTags]).not.toContain("平台跳跃"); + expect([...groups.tags, ...groups.moreTags]).toContain("跳跃"); + expect(groups.moreTags.length).toBeGreaterThan(0); + expect(groups.tags).toHaveLength(12); + }); + + it("keeps the type, theme, and tag results distinct", () => { + const sounds = filterAssets(assets, { text: "", category: "sfx", theme: "all", tag: "all" }); + expect(sounds.every((asset) => asset.category === "sfx")).toBe(true); + expect(sounds.map((asset) => asset.id)).toContain("sfx-boom"); + expect(sounds.map((asset) => asset.id)).toContain("jump-sfx"); + expect(sounds.map((asset) => asset.id)).not.toContain("hero"); + + const platform = filterAssets(assets, { text: "", category: "all", theme: "平台跳跃", tag: "all" }); + expect(platform.map((asset) => asset.id)).toContain("platform-walker"); + expect(platform.map((asset) => asset.id)).not.toContain("sfx-boom"); + + const jumps = filterAssets(assets, { text: "", category: "all", theme: "all", tag: "跳跃" }); + expect(jumps.map((asset) => asset.id)).toContain("jump-sfx"); + expect(jumps.map((asset) => asset.id)).not.toContain("hero"); + + const both = filterAssets(assets, { text: "", category: "sfx", theme: "迷宫地牢", tag: "all" }); + expect(both.map((asset) => asset.id).sort()).toEqual(["dungeon-door-sfx", "dungeon-step"]); + }); +}); diff --git a/src/lib/assets/filter.ts b/src/lib/assets/filter.ts index b8ab6cc..eb17dd1 100644 --- a/src/lib/assets/filter.ts +++ b/src/lib/assets/filter.ts @@ -1,4 +1,16 @@ -import type { AssetCategory, CatalogAsset } from "./schema"; +import { ASSET_CATEGORIES, type AssetCategory, type CatalogAsset } from "./schema"; + +/** How many tag chips stay on the row before the rest fold under 更多. */ +export const LIBRARY_VISIBLE_TAGS = 12; + +export type LibraryCategoryChip = { id: AssetCategory; label: string }; + +export type LibraryFilterGroups = { + categories: LibraryCategoryChip[]; + themes: string[]; + tags: string[]; + moreTags: string[]; +}; export type AssetQuery = { text: string; @@ -16,6 +28,51 @@ export function relatedAssets(assets: CatalogAsset[], asset: CatalogAsset): Cata return assets.filter((other) => other !== asset && other.id !== asset.id && other.tags.some((tag) => tags.has(tag))); } +/** + * Chips for the library dialog, one label each. + * + * Category, theme, and tag used to share one row, so 音效 / 角色 / 界面 and + * the other overlapping names were drawn two or three times. A label is kept + * in the highest group that owns it (类型, then 主题, then 标签). The chip + * still filters only that group: 类型 is `category`, 主题 is `theme`, 标签 is + * `tag`. `filterAssets` is unchanged, so the remaining chip matches the same + * assets the old button of that group matched. + */ +export function libraryFilterGroups( + assets: CatalogAsset[], + categoryLabels: Readonly>, + tagLimit = LIBRARY_VISIBLE_TAGS, +): LibraryFilterGroups { + const used = new Set(); + const categories: LibraryCategoryChip[] = []; + for (const id of ASSET_CATEGORIES) { + if (!assets.some((asset) => asset.category === id)) continue; + const label = categoryLabels[id]; + categories.push({ id, label }); + used.add(label); + } + + const themes: string[] = []; + const seenThemes = new Set(); + for (const asset of assets) { + if (seenThemes.has(asset.theme)) continue; + seenThemes.add(asset.theme); + if (used.has(asset.theme)) continue; + themes.push(asset.theme); + used.add(asset.theme); + } + + const counts = new Map(); + for (const asset of assets) { + for (const tag of asset.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + const tags = [...counts.keys()] + .filter((tag) => !used.has(tag)) + .sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b, "zh-CN")); + const limit = Math.max(0, tagLimit); + return { categories, themes, tags: tags.slice(0, limit), moreTags: tags.slice(limit) }; +} + /** Client-side filter. The pack JSON is already loaded; this does not fetch. */ export function filterAssets(assets: CatalogAsset[], query: AssetQuery): CatalogAsset[] { const needle = query.text.trim().toLowerCase(); diff --git a/src/lib/assets/kenney.test.ts b/src/lib/assets/kenney.test.ts index 7045b55..b5a40c3 100644 --- a/src/lib/assets/kenney.test.ts +++ b/src/lib/assets/kenney.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { inspectAudio } from "../audio/format"; import { framesFromDocument, parseSpriteAnim } from "../pixel/anim"; import { decodePng } from "../pixel/png"; +import { assetDisplayName } from "./display"; import { checkAssetPacks } from "./check-disk"; import type { AssetPack } from "./schema"; import { librarySpriteNote } from "./sprite-note"; @@ -49,6 +50,8 @@ describe("Kenney CC0 packs", () => { expect(asset.source).toMatch(/^https:\/\/kenney\.nl\//); expect(asset.attribution.length).toBeGreaterThan(0); expect(asset.tags.some((tag) => /[\u4e00-\u9fff]/.test(tag))).toBe(true); + expect(assetDisplayName(asset).length).toBeGreaterThan(0); + expect(/[\u4e00-\u9fff]/.test(assetDisplayName(asset))).toBe(true); const bytes = new Uint8Array(readFileSync(join(root, id, asset.path))); if (asset.mediaType === "audio/ogg") expect(inspectAudio(asset.path, bytes)?.ok).toBe(true); } diff --git a/src/lib/assets/preview-audio.test.ts b/src/lib/assets/preview-audio.test.ts new file mode 100644 index 0000000..e9cdf86 --- /dev/null +++ b/src/lib/assets/preview-audio.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { formatHeardLength, oggPreviewWavPath, planLibraryAudio, supportsOggVorbis } from "./preview-audio"; + +describe("library audio preview", () => { + it("points an ogg file at a sibling wav preview", () => { + expect(oggPreviewWavPath("sounds/jump.ogg")).toBe("sounds/jump.preview.wav"); + expect(oggPreviewWavPath("sounds/jump.wav")).toBeNull(); + }); + + it("plays wav and ogg directly, and falls back when ogg is unsupported", () => { + expect( + planLibraryAudio({ mediaType: "audio/wav", sourceUrl: "/jump.wav", fallbackUrl: null, canPlayOgg: false }), + ).toEqual({ src: "/jump.wav", usingFallback: false, blocked: false }); + expect( + planLibraryAudio({ + mediaType: "audio/ogg", + sourceUrl: "/boom.ogg", + fallbackUrl: "/boom.preview.wav", + canPlayOgg: true, + }), + ).toEqual({ src: "/boom.ogg", usingFallback: false, blocked: false }); + expect( + planLibraryAudio({ + mediaType: "audio/ogg", + sourceUrl: "/boom.ogg", + fallbackUrl: "/boom.preview.wav", + canPlayOgg: false, + }), + ).toEqual({ src: "/boom.preview.wav", usingFallback: true, blocked: false }); + expect( + planLibraryAudio({ mediaType: "audio/ogg", sourceUrl: "/boom.ogg", fallbackUrl: null, canPlayOgg: false }), + ).toEqual({ src: "", usingFallback: false, blocked: true }); + }); + + it("treats a blank canPlayType as unsupported ogg", () => { + expect(supportsOggVorbis(() => "")).toBe(false); + expect(supportsOggVorbis(() => "probably")).toBe(true); + expect(supportsOggVorbis(() => "maybe")).toBe(true); + }); + + it("does not draw a sub-second sound as zero", () => { + expect(formatHeardLength(0)).toBeNull(); + expect(formatHeardLength(Number.NaN)).toBeNull(); + expect(formatHeardLength(0.22)).toBe("0.2 秒"); + expect(formatHeardLength(1.002)).toBe("1.0 秒"); + expect(formatHeardLength(65)).toBe("1:05"); + }); +}); diff --git a/src/lib/assets/preview-audio.ts b/src/lib/assets/preview-audio.ts new file mode 100644 index 0000000..f4b8a84 --- /dev/null +++ b/src/lib/assets/preview-audio.ts @@ -0,0 +1,57 @@ +/** + * What the library player should load. + * + * Chrome and Firefox play Kenney's original OGG Vorbis. Safari does not. + * Each OGG ships a sibling PCM WAV (`jump.ogg` → `jump.preview.wav`) that is + * only a preview: copying into a project still uses the OGG listed in the manifest. + */ + +const OGG_VORBIS = 'audio/ogg; codecs="vorbis"'; + +export function oggPreviewWavPath(assetPath: string): string | null { + if (!assetPath.endsWith(".ogg")) return null; + return `${assetPath.slice(0, -4)}.preview.wav`; +} + +export function supportsOggVorbis(canPlayType: (mime: string) => string): boolean { + const result = canPlayType(OGG_VORBIS); + return result === "probably" || result === "maybe"; +} + +export type AudioPreviewPlan = { + src: string; + usingFallback: boolean; + /** The browser cannot play this sound, and there is no WAV preview. */ + blocked: boolean; +}; + +export function planLibraryAudio(input: { + mediaType: string; + sourceUrl: string; + fallbackUrl: string | null; + canPlayOgg: boolean; +}): AudioPreviewPlan { + if (!input.sourceUrl) return { src: "", usingFallback: false, blocked: false }; + const ogg = input.mediaType === "audio/ogg"; + if (ogg && !input.canPlayOgg) { + if (input.fallbackUrl) return { src: input.fallbackUrl, usingFallback: true, blocked: false }; + return { src: "", usingFallback: false, blocked: true }; + } + return { src: input.sourceUrl, usingFallback: false, blocked: false }; +} + +/** + * Kid-facing length. Native controls draw sub-second sounds as 0:00 / 0:00, + * which looks like the file never loaded. Tenths of a second stay visible. + */ +export function formatHeardLength(seconds: number): string | null { + if (!Number.isFinite(seconds) || seconds <= 0) return null; + if (seconds < 60) { + const tenths = Math.max(1, Math.round(seconds * 10)); + return `${(tenths / 10).toFixed(1)} 秒`; + } + const total = Math.round(seconds); + const minutes = Math.floor(total / 60); + const remain = total % 60; + return `${minutes}:${String(remain).padStart(2, "0")}`; +} diff --git a/src/lib/assets/sound-mark.test.ts b/src/lib/assets/sound-mark.test.ts new file mode 100644 index 0000000..1fa193f --- /dev/null +++ b/src/lib/assets/sound-mark.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { soundMark } from "./sound-mark"; + +describe("sound marks", () => { + it("picks a mark from the specific tag, not the generic 音效 tag", () => { + expect(soundMark(["音效", "跳跃", "平台跳跃"])).toBe("jump"); + expect(soundMark(["音效", "射击", "激光"])).toBe("laser"); + expect(soundMark(["音效", "爆炸", "射击"])).toBe("boom"); + expect(soundMark(["音乐", "森林", "循环"])).toBe("music"); + expect(soundMark(["音效"])).toBe("tone"); + }); +}); diff --git a/src/lib/assets/sound-mark.ts b/src/lib/assets/sound-mark.ts new file mode 100644 index 0000000..48366d3 --- /dev/null +++ b/src/lib/assets/sound-mark.ts @@ -0,0 +1,36 @@ +/** + * A small mark so two sounds are not the same gray square. + * The card still shows the Chinese title and the pack name; the mark is extra. + */ + +export const SOUND_MARKS = ["jump", "coin", "laser", "boom", "click", "step", "door", "hit", "music", "power", "tone"] as const; + +export type SoundMark = (typeof SOUND_MARKS)[number]; + +const BY_TAG: Record = { + 跳跃: "jump", + 金币: "coin", + 激光: "laser", + 射击: "laser", + 爆炸: "boom", + 点击: "click", + 确认: "click", + 返回: "click", + 走路: "step", + 门: "door", + 撞击: "hit", + 音乐: "music", + 升级: "power", +}; + +const GENERIC = new Set(["音效", "变体", "我做的"]); + +/** First specific tag wins. Generic tags like 音效 are skipped so a jump stays a jump. */ +export function soundMark(tags: readonly string[]): SoundMark { + for (const tag of tags) { + if (GENERIC.has(tag)) continue; + const mark = BY_TAG[tag]; + if (mark) return mark; + } + return "tone"; +} diff --git a/src/lib/assets/validate.test.ts b/src/lib/assets/validate.test.ts index 17a8818..ad97b8d 100644 --- a/src/lib/assets/validate.test.ts +++ b/src/lib/assets/validate.test.ts @@ -88,6 +88,31 @@ describe("forest adventure pack", () => { } }); + it("blocks an asset with no display name", async () => { + const built = await buildForestPack(); + const manifest = structuredClone(built.manifest); + manifest.assets[0].title = " "; + const checked = await validateAssetPack(manifest, built.files); + expect(checked.ok).toBe(false); + if (!checked.ok) expect(checked.issues.map((issue) => issue.code)).toContain("missing-title"); + }); + + it("blocks an ogg that has no wav preview", async () => { + const id = "kenney-sfx"; + const manifest = JSON.parse(readFileSync(join(root, id, "manifest.json"), "utf8")) as AssetPack; + const files: Record = {}; + for (const asset of manifest.assets) { + files[asset.path] = new Uint8Array(readFileSync(join(root, id, asset.path))); + const preview = asset.path.replace(/\.ogg$/, ".preview.wav"); + files[preview] = new Uint8Array(readFileSync(join(root, id, preview))); + } + expect((await validateAssetPack(manifest, files)).ok).toBe(true); + delete files[manifest.assets[0].path.replace(/\.ogg$/, ".preview.wav")]; + const missing = await validateAssetPack(manifest, files); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.issues.map((issue) => issue.code)).toContain("audio-preview"); + }); + it("blocks an audio file the browser runtime cannot play", async () => { const sounds = await buildSoundPack(); const manifest = structuredClone(sounds.manifest); diff --git a/src/lib/assets/validate.ts b/src/lib/assets/validate.ts index 8834800..652bc8e 100644 --- a/src/lib/assets/validate.ts +++ b/src/lib/assets/validate.ts @@ -1,6 +1,8 @@ import { inspectAudio } from "../audio/format"; import { MAX_TILES, isTileSize } from "../map/model"; +import { assetDisplayName } from "./display"; import { sha256Hex } from "./hash"; +import { oggPreviewWavPath } from "./preview-audio"; import { assetPackSchema, type AssetPack, type CatalogAsset } from "./schema"; /** @@ -22,7 +24,9 @@ export type PackIssueCode = | "preview" | "tag-language" | "title-language" - | "audio-format"; + | "missing-title" + | "audio-format" + | "audio-preview"; export type PackIssue = { code: PackIssueCode; message: string; path?: string }; @@ -33,6 +37,13 @@ function hasCjk(value: string): boolean { return CJK.test(value); } +/** Asset titles are the card's display name. An empty one is its own gate, not a generic schema miss. */ +function issueCode(path: string): PackIssueCode { + if (PROVENANCE.test(path)) return "provenance"; + if (/^assets\.\d+\.title$/.test(path)) return "missing-title"; + return "schema"; +} + function fits(x: number, y: number, width: number, height: number, boxWidth: number, boxHeight: number): boolean { return x + width <= boxWidth && y + height <= boxHeight; } @@ -110,7 +121,7 @@ export async function validateAssetPack( const issues: PackIssue[] = parsed.error.issues.map((issue) => { const path = issue.path.join("."); return { - code: PROVENANCE.test(path) ? "provenance" : "schema", + code: issueCode(path), path, message: `${path || "manifest"}: ${issue.message}`, }; @@ -133,7 +144,9 @@ export async function validateAssetPack( if (paths.has(asset.path)) issues.push({ code: "duplicate-path", path: asset.path, message: `Duplicate path ${asset.path}.` }); paths.add(asset.path); - if (!hasCjk(asset.title)) { + if (!assetDisplayName(asset)) { + issues.push({ code: "missing-title", path: asset.id, message: `${asset.id} needs a non-empty display name.` }); + } else if (!hasCjk(asset.title)) { issues.push({ code: "title-language", path: asset.id, message: `${asset.id} needs a Chinese title.` }); } if (!asset.tags.some(hasCjk)) { @@ -160,6 +173,19 @@ export async function validateAssetPack( } } + if (asset.mediaType === "audio/ogg") { + const previewPath = oggPreviewWavPath(asset.path); + const preview = previewPath ? files[previewPath] : undefined; + const previewVerdict = previewPath ? inspectAudio(previewPath, preview) : null; + if (!previewPath || !previewVerdict?.ok || previewVerdict.format !== "wav") { + issues.push({ + code: "audio-preview", + path: previewPath ?? asset.path, + message: `${asset.id} needs a PCM WAV preview next to the OGG so Safari can play it in the library.`, + }); + } + } + checkAnimation(asset, issues); checkCollision(asset, issues); checkTileset(asset, issues); diff --git a/src/styles.css b/src/styles.css index 1562219..09da5a5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -853,7 +853,9 @@ button:disabled { opacity: 0.45; cursor: default; } min-height: 32px; font-weight: 600; } -.library-filters { display: flex; flex-wrap: wrap; gap: 4px; } +.library-filters { display: grid; gap: 6px; } +.library-filter-row { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } +.library-filter-label { font-size: 12px; color: var(--muted); min-width: 2.5em; } .library-filters button { border: 1px solid var(--line); background: white; @@ -870,22 +872,39 @@ button:disabled { opacity: 0.45; cursor: default; } overflow: hidden; } .library-body { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr); gap: 12px; min-height: 0; } -.library-grid { overflow: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; align-content: start; } +.library-grid { overflow: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; align-content: start; align-items: start; } +/* display:grid on a - {categories.map((item) => ( - - ))} - {themes.map((item) => ( - - ))} - {tags.map((item) => ( - - ))} + {groups.categories.map((item) => ( + + ))} + + {groups.themes.length ? ( +
+ {text.library.groups.theme} + {groups.themes.map((item) => ( + + ))} +
+ ) : null} + {groups.tags.length || groups.moreTags.length ? ( +
+ {text.library.groups.tag} + {visibleTags.map((item) => ( + + ))} + {groups.moreTags.length ? ( + + ) : null} +
+ ) : null} {loading ?

{text.library.loading}

: null} {error ? ( @@ -316,37 +366,69 @@ export function LibraryDialog() { ) : null}
- {visible.map((row) => ( - - ))} + {visible.map((row) => { + const name = assetDisplayName(row.asset) || row.asset.id; + const audio = row.asset.mediaType.startsWith("audio/"); + const same = selected?.asset.id === row.asset.id; + return ( + + ); + })} {!loading && !visible.length ?

{text.library.empty}

: null}
{selected ? (
{selected.asset.mediaType.startsWith("audio/") ? ( -
); } + +function SoundGlyph({ mark }: { mark: SoundMark }) { + const common = { viewBox: "0 0 28 28", "aria-hidden": true as const }; + switch (mark) { + case "jump": + return ( + + + + + ); + case "coin": + return ( + + + + + ); + case "laser": + return ( + + + + + ); + case "boom": + return ( + + + + ); + case "click": + return ( + + + + + ); + case "step": + return ( + + + + + ); + case "door": + return ( + + + + + ); + case "hit": + return ( + + + + ); + case "music": + return ( + + + + + + ); + case "power": + return ( + + + + + ); + default: + return ( + + + + ); + } +} diff --git a/src/ui/LibraryPlayer.tsx b/src/ui/LibraryPlayer.tsx new file mode 100644 index 0000000..2249e81 --- /dev/null +++ b/src/ui/LibraryPlayer.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState, type RefObject } from "react"; +import { formatHeardLength, planLibraryAudio, supportsOggVorbis } from "../lib/assets/preview-audio"; +import { text } from "./text"; + +/** + * Detail player for one library sound. + * The native control stays, and a text length sits under it: sub-second + * sounds otherwise look like 0:00 / 0:00. Safari, which cannot play OGG, + * is handed the PCM preview instead. + */ +export function LibraryPlayer({ + sourceUrl, + fallbackUrl, + mediaType, + playerRef, + onPlayState, +}: { + sourceUrl: string; + fallbackUrl: string | null; + mediaType: string; + playerRef: RefObject; + onPlayState: (playing: boolean) => void; +}) { + const [canOgg, setCanOgg] = useState(true); + const [forceFallback, setForceFallback] = useState(false); + const [heard, setHeard] = useState(null); + const [track, setTrack] = useState(sourceUrl); + if (track !== sourceUrl) { + setTrack(sourceUrl); + setHeard(null); + setForceFallback(false); + } + + useEffect(() => { + const probe = document.createElement("audio"); + setCanOgg(supportsOggVorbis((mime) => probe.canPlayType(mime))); + }, []); + + const plan = planLibraryAudio({ + mediaType, + sourceUrl, + fallbackUrl, + canPlayOgg: canOgg && !forceFallback, + }); + const length = heard === null ? null : formatHeardLength(heard); + + if (!sourceUrl) return

{text.library.preparing}

; + + return ( +
+ {plan.src ? ( +
+ ); +} diff --git a/src/ui/text.ts b/src/ui/text.ts index aded97a..e3de2a6 100644 --- a/src/ui/text.ts +++ b/src/ui/text.ts @@ -530,6 +530,14 @@ export const text = { copy: "挑一张放进项目。放进去的是复制的一份,不联网也能玩,也可以撤销。", search: "搜索素材", all: "全部", + groups: { category: "类型", theme: "主题", tag: "标签" }, + more: "更多", + less: "收起", + duration: (length: string) => `时长 ${length}`, + durationPending: "正在读取时长…", + oggPreview: "这个浏览器播不了原来的 OGG。先听一段能播的预览。放进项目的还是原来的声音。", + oggBlocked: "这个浏览器播不了这种声音。", + preparing: "正在准备声音…", categories: { character: "角色", terrain: "地形", diff --git a/tests/e2e/library.spec.ts b/tests/e2e/library.spec.ts index a786d91..58c2860 100644 --- a/tests/e2e/library.spec.ts +++ b/tests/e2e/library.spec.ts @@ -189,3 +189,77 @@ test("the copied picture runs in pygame and is inside the offline export", async const inner = unzipSync(outer["gamekit.apk"]); expect(Buffer.from(inner[`assets/${HERO}`]).equals(Buffer.from(libraryPng))).toBe(true); }); + +test("sound cards show names, filters are unique, and the player has a duration", async ({ page }) => { + const hits: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/")) hits.push(request.url()); + }); + await page.goto("/"); + await openLibrary(page); + const overlapped = await page.locator(".library-card").evaluateAll((nodes) => { + const boxes = nodes.map((node) => { + const rect = node.getBoundingClientRect(); + return { x: rect.x, y: rect.y, right: rect.right, bottom: rect.bottom }; + }); + let count = 0; + for (let i = 0; i < boxes.length; i += 1) { + for (let j = i + 1; j < boxes.length; j += 1) { + const a = boxes[i]; + const b = boxes[j]; + if (a.x < b.right - 1 && b.x < a.right - 1 && a.y < b.bottom - 1 && b.y < a.bottom - 1) count += 1; + } + } + return count; + }); + expect(overlapped).toBe(0); + await page.getByTestId("library-category-sfx").click(); + + const cards = await page.locator(".library-card").evaluateAll((nodes) => + nodes.map((node) => { + const name = node.querySelector(".library-name"); + const pack = node.querySelector(".library-pack"); + const card = node.getBoundingClientRect(); + const box = name?.getBoundingClientRect(); + return { + name: name?.textContent?.trim() ?? "", + pack: pack?.textContent?.trim() ?? "", + inside: !!box && box.height > 8 && box.top >= card.top - 1 && box.bottom <= card.bottom + 1, + }; + }), + ); + expect(cards.length).toBeGreaterThan(10); + expect(cards.every((card) => card.name.length > 0 && card.pack.length > 0 && card.inside)).toBe(true); + + await page.getByTestId("library-filters-more").click(); + const labels = await page.locator(".library-filters button").evaluateAll((buttons) => + buttons.map((button) => button.textContent?.trim() ?? "").filter((label) => label && !["全部", "更多", "收起"].includes(label)), + ); + expect(new Set(labels).size).toBe(labels.length); + for (const label of ["音效", "角色", "界面", "地形", "益智", "平台跳跃", "迷宫地牢", "音乐", "图块", "特效", "道具"]) { + expect(labels.filter((item) => item === label)).toEqual([label]); + } + + await expect(page.getByTestId("library-audio-duration")).toContainText("秒"); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.duration)).toBeGreaterThan(0); + await expect(page.getByTestId("library-preview")).toHaveAttribute("src", /\.wav$/); + await expect(page.getByTestId("library-preview")).toHaveAttribute("data-preview", "original"); + + await page.getByTestId("library-asset-sfx-boom").click(); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.currentSrc)).toContain("boom.ogg"); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.duration)).toBeGreaterThan(0.5); + await expect(page.getByTestId("library-audio-duration")).toContainText("秒"); + await expect(page.getByTestId("library-audio-unsupported")).toHaveCount(0); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0); + + await page.getByTestId("library-asset-sfx-laser").click(); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.currentSrc)).toContain("laser.ogg"); + await expect.poll(async () => page.getByTestId("library-preview").evaluate((el: HTMLAudioElement) => el.duration)).toBeGreaterThan(0.5); + + await page.getByTestId("library-filter-all").click(); + await page.getByTestId("library-theme-平台跳跃").click(); + await expect(page.getByTestId("library-asset-platform-walker")).toBeVisible(); + await expect(page.getByTestId("library-asset-sfx-boom")).toHaveCount(0); + await expect(page.getByTestId("library-asset-hero")).toHaveCount(0); + expect(hits).toEqual([]); +});