557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
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<string>(PHASER_SCAFFOLD_FILES);
|
||
|
||
const PHASER_SCAFFOLD_ALLOWED_DIRECTORY_PATHS = new Set<string>(
|
||
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', `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>${packageName}</title>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script type="module" src="/src/main.ts"></script>
|
||
</body>
|
||
</html>
|
||
`],
|
||
['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<string[]> {
|
||
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<string[]> {
|
||
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<string[]> {
|
||
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<ProjectScaffoldResult> {
|
||
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],
|
||
};
|
||
}
|