Makelore 2.0 initial clean snapshot
This commit is contained in:
556
electron/opencode/project-scaffold.ts
Normal file
556
electron/opencode/project-scaffold.ts
Normal 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 资产清单,每项使用稳定 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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user