Files
makelore/electron/opencode/project-scaffold.ts
2026-07-29 17:22:35 +08:00

557 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 资产清单,每项使用稳定 idMarkdown 表格只能作为展示说明,不能替代机器记录。
- 需要用户选择时,诚实区分候选与已选结果,并保留用户决定;不要把沉默或自己的判断写成用户确认。审核台先收集全部卡片决定,用户全部选完后通过一次确认提交一条汇总消息;不要把决定拆成逐卡聊天。审核台的用户 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],
};
}