Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ await asyncio.sleep(0)

预览和导出都打包项目里的这份副本(和别的 `assets/` 文件一样)。成品不请求素材库目录,也不请求 GameKit 站点。素材库以后升级,不会改掉已经复制进项目、或已经导出的那一份。模板本身没有为素材库改过。

素材库里听声音时,能播 OGG 的浏览器播 Kenney 原来的文件。Safari 播不了 OGG,所以每个 OGG 旁边有一份 `*.preview.wav`,只给素材库播放器当预览。复制进项目的仍是清单里的那份 OGG 或 WAV。预览 wav 不进项目,也不进导出包。模板没有为这个预览改过。

## 离线导出

阶段 G 核对的是导出的游戏,不是把整个工作室变成断网应用。
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
8 changes: 8 additions & 0 deletions src/lib/assets/check-disk.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -61,6 +62,13 @@ export async function checkAssetPacks(root: string): Promise<PackIssue[]> {
} 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);
Expand Down
10 changes: 10 additions & 0 deletions src/lib/assets/display.ts
Original file line number Diff line number Diff line change
@@ -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();
}
58 changes: 58 additions & 0 deletions src/lib/assets/filter.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
59 changes: 58 additions & 1 deletion src/lib/assets/filter.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Record<AssetCategory, string>>,
tagLimit = LIBRARY_VISIBLE_TAGS,
): LibraryFilterGroups {
const used = new Set<string>();
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<string>();
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<string, number>();
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();
Expand Down
3 changes: 3 additions & 0 deletions src/lib/assets/kenney.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
48 changes: 48 additions & 0 deletions src/lib/assets/preview-audio.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
57 changes: 57 additions & 0 deletions src/lib/assets/preview-audio.ts
Original file line number Diff line number Diff line change
@@ -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")}`;
}
Loading
Loading