import { mkdir, readdir, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, join, relative } from 'node:path'; import { buildInitialProductOverviewDocument, PRODUCT_OVERVIEW_FILENAME } from '../../shared/project-config'; import type { ProjectTemplateSnapshot } from '../../shared/project-template'; export type ProjectScaffoldResult = | { status: 'disabled' } | { status: 'generated'; files: string[] } | { status: 'skipped_non_empty'; entries: string[] } | { status: 'skipped_existing_files'; files: string[] }; const IGNORABLE_ROOT_ENTRIES = new Set(['.niancode', '.git', '.DS_Store', 'Thumbs.db']); const PHASER_SCAFFOLD_FILES = [ 'ASSET_PLAN.md', 'GDD.md', 'README.md', 'RELEASE_CHECKLIST.md', PRODUCT_OVERVIEW_FILENAME, 'TASKS.md', 'index.html', 'package.json', 'src/main.ts', 'src/styles.css', 'tsconfig.json', 'vite.config.ts', ] as const; const PHASER_SCAFFOLD_ROOTS = new Set(PHASER_SCAFFOLD_FILES.map((filePath) => filePath.split('/')[0] ?? filePath)); const PHASER_SCAFFOLD_ALLOWED_FILE_PATHS = new Set(PHASER_SCAFFOLD_FILES); const PHASER_SCAFFOLD_ALLOWED_DIRECTORY_PATHS = new Set( PHASER_SCAFFOLD_FILES.flatMap((filePath) => { const parts = filePath.split('/'); return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/')); }), ); function toPackageName(projectPath: string): string { const fallback = 'phaser-mini-game-course'; const normalized = basename(projectPath) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || fallback; } function buildPhaserScaffoldFiles(projectPath: string): Array<[string, string]> { const packageName = toPackageName(projectPath); return [ ['ASSET_PLAN.md', `# 素材计划 > 这是所有 Agent 共享的素材上下文。素材伙伴持续记录候选、选择状态、来源、授权、项目内路径、缺口、假设和接入建议;聊天总结不能代替本文件。 ## 用户决策 - 等待用户在审核台逐项决定候选素材。 ## 机器可核验记录 \`\`\`json { "schemaVersion": 1, "selectionStatus": "draft", "selectionDecision": { "mode": "pending", "userQuote": "" }, "confirmedAssetIds": [], "unresolvedRequiredAssetIds": ["TODO"], "assets": [ { "id": "TODO", "category": "visual", "requiredForCurrentTarget": true, "status": "candidate", "purpose": "TODO", "source": "TODO", "license": "TODO", "localPath": "assets/TODO.png", "previewPath": "assets/TODO.png", "coverPath": "", "manifestPaths": [], "technical": "TODO", "styleRationale": "TODO", "fallback": "TODO" } ] } \`\`\` ## 协作规则 - ASSET_PLAN.md 必须同时保留顶层为 {"assets":[...]} 的 fenced JSON 资产清单,每项使用稳定 id;Markdown 表格只能作为展示说明,不能替代机器记录。 - 需要用户选择时,诚实区分候选与已选结果,并保留用户决定;不要把沉默或自己的判断写成用户确认。审核台先收集全部卡片决定,用户全部选完后通过一次确认提交一条汇总消息;不要把决定拆成逐卡聊天。审核台的用户 approve 才是纳入开发的依据;discard/replace 的素材不得再次提交。 - 审核状态保存在项目内 \`.niancode/asset-review.json\`。Agent 每次输出审核标记都重新填充候选列表,不自动追加旧批次;用户退出后,未处理素材也不会自动带入下一次。 - 未决素材不阻塞可逆的占位工作,但正式开发只能接入审核状态中已批准的素材。下载、接入与运行验证由实际执行的 Agent 记录,不要预先声称完成。 - 为了让普通用户在桌面端直接浏览,图片/音频填写项目相对 \`localPath\`;需要单独预览图时填写 \`previewPath\`,动画或素材包填写 \`coverPath\` 和最多 100 条 \`manifestPaths\`。不要填写项目外绝对路径。 - 输出审核标记前,逐项确认上述项目内路径真实存在;没有可预览证据的候选先下载/整理预览或继续搜索,不要提交空预览批次。 - 候选准备好后必须在同一条回复末尾输出新的审核标记;禁止只问用户选择哪个方案或让用户回复文件名来代替审核台。 `], ['GDD.md', `# 游戏设计(持续维护) > 这里记录当前要做的真实游戏,而不是 Phaser 起步示例。先写清楚下一次可试玩验证需要的内容;每次得到试玩证据后更新它。 ## 状态 - 阶段:Concept / Prototype / Build / Ship - 当前可玩版本:尚未开始 - 最大当前风险:玩家是否能理解核心目标和操作? - 当前可试玩目标:先确认游戏想法。 ## 1. 游戏承诺 - 一句话游戏: - 玩家幻想与感受: - 目标玩家与无障碍需要: - 浏览器与输入方式: - 一局时长与自然结束点: ## 2. 支柱与边界 | 支柱 | 给玩家的可观察承诺 | 发生冲突时优先什么 | | --- | --- | --- | | TODO | TODO | TODO | ### 本次第一版不做什么 - TODO ## 3. 体验链与核心循环 - 玩家动词: - 核心循环:开始 → 行动 → 反馈 → 结果 → 重开或继续 - 成功条件: - 失败条件: - 重开时重置什么、保留什么: ## 4. 玩家模型与反馈 - 控制方式与即时反馈: - 玩家必须理解的规则: - 画面、声音、文字和无障碍提示: ## 5. 当前系统 ### SYS-001 — TODO - 玩家目的和选择: - 输入与前置条件: - 状态、规则和结果: - 反馈、边界情况和可调参数: - 可观察验收: ## 6. 第一版边界 - 必须有:一个完整的小循环、明确结果、可重开。 - 这次不做:TODO ## 7. 风险与当前实验 - 假设: - 要验证什么: - Keep / Change / Inconclusive:尚未验证 ## 8. 证据与决定 - 最近一次真实浏览器试玩:尚未验证 - 已确认决定:`], ['TASKS.md', `# TASKS Project Version: v0.1.0 Document Revision: 1 Last Updated By: game-design > 所有 Agent 共同读取和维护本文件。专业事实留在角色文档;这里保存任务状态、Owner、验收、证据摘要、阻塞、版本建议和链接。 ## Version Goal - 先确认游戏想法和下一次可试玩目标。 ## Now - [ ] TASK-001 明确当前版本目标 - Owner: game-design - Status: todo - Acceptance: v0.1.0 的玩家可感知目标、范围和验证方式已写清 - Evidence: pending ## Next - TODO ## Blocked - 无 ## Version Proposals - 无 ## Last verified - 尚未验证:还没有生产构建或真实浏览器试玩证据。 ## Done - 无`], ['README.md', `# ${packageName} Starter scaffold for the Phaser mini-game course. ## Commands - \`pnpm install\` - \`pnpm dev\` - \`pnpm build\` - \`pnpm preview\` ## Project Files - \`GDD.md\` and \`TASKS.md\` are the canonical game memory. - \`ASSET_PLAN.md\` tracks what to source or draw. - Keep one player-visible result in \`TASKS.md\`; reconcile it with the design after every verified increment. - \`RELEASE_CHECKLIST.md\` tracks testing and ship readiness. - \`${PRODUCT_OVERVIEW_FILENAME}\` is the canonical product operations introduction: product facts, user value, game explanation, reusable copy, display materials, and evidence status. `], ['RELEASE_CHECKLIST.md', `# Release Checklist ## Build - [ ] \`pnpm install\` - [ ] \`pnpm build\` - [ ] \`pnpm preview\` - [ ] Production preview opens in a real browser - [ ] Browser console has no new errors ## Gameplay QA - [ ] Win condition works - [ ] Lose condition works - [ ] Restart path works - [ ] Controls feel responsive - [ ] Core loop can be completed and restarted twice - [ ] Resize keeps the game playable ## Open Issues - TODO `], [PRODUCT_OVERVIEW_FILENAME, buildInitialProductOverviewDocument()], ['index.html', ` ${packageName}
`], ['package.json', `${JSON.stringify({ name: packageName, private: true, version: '0.0.0', type: 'module', scripts: { dev: 'vite --host 0.0.0.0', build: 'tsc && vite build', preview: 'vite preview --host 0.0.0.0', }, dependencies: { phaser: '3.90.0', }, devDependencies: { typescript: '^5.8.3', vite: '^7.0.0', }, }, null, 2)} `], ['src/main.ts', `import Phaser from 'phaser'; import './styles.css'; class StarterScene extends Phaser.Scene { private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private player!: Phaser.GameObjects.Rectangle; private goal!: Phaser.GameObjects.Rectangle; private obstacles!: Phaser.GameObjects.Group; private statusText!: Phaser.GameObjects.Text; private finished = false; constructor() { super('starter-scene'); } create() { this.finished = false; const { width, height } = this.scale; this.add.text(24, 20, 'Starter example', { fontFamily: 'Arial, sans-serif', fontSize: '24px', color: '#f8fafc', }); this.add.text(24, 52, 'Replace this with the game from GDD.md.', { fontFamily: 'Arial, sans-serif', fontSize: '14px', color: '#cbd5e1', }); this.player = this.add.rectangle(84, height / 2, 28, 28, 0x38bdf8); this.goal = this.add.rectangle(width - 84, height / 2, 32, 96, 0x22c55e); this.obstacles = this.add.group([ this.add.rectangle(width * 0.42, height * 0.3, 34, 160, 0xf97316), this.add.rectangle(width * 0.58, height * 0.7, 34, 160, 0xf97316), ]); this.statusText = this.add.text(24, height - 48, 'Use arrow keys to dodge obstacles.', { fontFamily: 'Arial, sans-serif', fontSize: '16px', color: '#f8fafc', }); this.cursors = this.input.keyboard?.createCursorKeys() ?? {}; } update(_time: number, delta: number) { if (this.finished) { if (Phaser.Input.Keyboard.JustDown(this.cursors.space!)) { this.scene.restart(); } return; } const speedPerSecond = 240; const dt = Math.min(delta, 50) / 1000; if (this.cursors.left?.isDown) this.player.x -= speedPerSecond * dt; if (this.cursors.right?.isDown) this.player.x += speedPerSecond * dt; if (this.cursors.up?.isDown) this.player.y -= speedPerSecond * dt; if (this.cursors.down?.isDown) this.player.y += speedPerSecond * dt; this.player.x = Phaser.Math.Clamp(this.player.x, 20, this.scale.width - 20); this.player.y = Phaser.Math.Clamp(this.player.y, 96, this.scale.height - 20); const playerBounds = this.player.getBounds(); const hitObstacle = this.obstacles.getChildren().some((obstacle) => ( Phaser.Geom.Intersects.RectangleToRectangle( playerBounds, (obstacle as Phaser.GameObjects.Rectangle).getBounds(), ) )); if (hitObstacle) { this.finish('You hit an obstacle. Press space to retry.'); return; } if (Phaser.Geom.Intersects.RectangleToRectangle(playerBounds, this.goal.getBounds())) { this.finish('Goal reached. Press space to play again.'); } } private finish(message: string) { this.finished = true; this.statusText.setText(message); this.player.setFillStyle(0xf8fafc); } } new Phaser.Game({ type: Phaser.AUTO, parent: 'app', width: 960, height: 540, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, }, backgroundColor: '#0f172a', scene: StarterScene, }); `], ['src/styles.css', `:root { color: #e2e8f0; background: #020617; font-family: Inter, Arial, sans-serif; } * { box-sizing: border-box; } html, body, #app { margin: 0; min-height: 100%; width: 100%; } body { min-height: 100vh; overflow: hidden; } #app { min-height: 100vh; } canvas { display: block; margin: 0 auto; max-width: 100vw; max-height: 100vh; } `], ['tsconfig.json', `${JSON.stringify({ compilerOptions: { target: 'ES2020', useDefineForClassFields: true, module: 'ESNext', moduleResolution: 'Bundler', lib: ['ES2020', 'DOM', 'DOM.Iterable'], strict: true, skipLibCheck: true, noEmit: true, }, include: ['src'], }, null, 2)} `], ['vite.config.ts', `import { defineConfig } from 'vite'; export default defineConfig({ server: { host: '0.0.0.0', }, preview: { host: '0.0.0.0', }, }); `], ]; } async function listRootEntries(projectPath: string): Promise { const entries = await readdir(projectPath, { withFileTypes: true }); return entries .map((entry) => entry.name) .filter((name) => !IGNORABLE_ROOT_ENTRIES.has(name)) .sort((left, right) => left.localeCompare(right)); } async function listUnexpectedEntries( projectPath: string, currentPath = projectPath, ): Promise { const entries = await readdir(currentPath, { withFileTypes: true }); const unexpected: string[] = []; for (const entry of entries) { const fullPath = join(currentPath, entry.name); const relativePath = relative(projectPath, fullPath).split('\\').join('/'); if (currentPath === projectPath && IGNORABLE_ROOT_ENTRIES.has(entry.name)) { continue; } const isAllowedDirectory = entry.isDirectory() && PHASER_SCAFFOLD_ALLOWED_DIRECTORY_PATHS.has(relativePath); const isAllowedFile = entry.isFile() && PHASER_SCAFFOLD_ALLOWED_FILE_PATHS.has(relativePath); if (!isAllowedDirectory && !isAllowedFile) { if (entry.isDirectory()) { const nestedUnexpected = await listUnexpectedEntries(projectPath, fullPath); unexpected.push(...(nestedUnexpected.length > 0 ? nestedUnexpected : [relativePath])); } else { unexpected.push(relativePath); } continue; } if (isAllowedDirectory) { unexpected.push(...await listUnexpectedEntries(projectPath, fullPath)); } } return unexpected.sort((left, right) => left.localeCompare(right)); } async function listExistingScaffoldFiles(projectPath: string): Promise { const existing: string[] = []; for (const relativePath of PHASER_SCAFFOLD_FILES) { try { await stat(join(projectPath, relativePath)); existing.push(relativePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } } return existing; } export async function maybeWriteProjectScaffold( projectPath: string, snapshot: ProjectTemplateSnapshot, ): Promise { if (snapshot.capabilities.scaffold?.enabled !== true) { return { status: 'disabled' }; } if (snapshot.capabilities.scaffold.kind !== 'phaser-2d-casual') { return { status: 'disabled' }; } const rootEntries = await listRootEntries(projectPath); const nonScaffoldEntries = rootEntries.filter((entry) => !PHASER_SCAFFOLD_ROOTS.has(entry)); if (nonScaffoldEntries.length > 0) { return { status: 'skipped_non_empty', entries: nonScaffoldEntries }; } const unexpectedEntries = await listUnexpectedEntries(projectPath); if (unexpectedEntries.length > 0) { return { status: 'skipped_non_empty', entries: unexpectedEntries }; } const existingFiles = await listExistingScaffoldFiles(projectPath); if (existingFiles.length > 0) { return { status: 'skipped_existing_files', files: existingFiles }; } const scaffoldFiles = buildPhaserScaffoldFiles(projectPath); for (const [relativePath, content] of scaffoldFiles) { const filePath = join(projectPath, relativePath); await mkdir(dirname(filePath), { recursive: true }); try { await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx', }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { return { status: 'skipped_existing_files', files: [relativePath] }; } throw error; } } return { status: 'generated', files: [...PHASER_SCAFFOLD_FILES], }; }