Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
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],
};
}