实现小游戏与小程序项目创建发布

This commit is contained in:
2026-08-09 13:31:06 +08:00
parent a6fe14a8d6
commit 493b31cff0
26 changed files with 1827 additions and 39 deletions

View File

@@ -48,7 +48,11 @@ import {
upsertProjectSessionMetadata,
type ProjectConversationState,
} from '../../../shared/project-conversations';
import type { ProjectConfig } from '../../../shared/project-config';
import {
isProjectType,
type ProjectConfig,
type ProjectType,
} from '../../../shared/project-config';
import { initializeProjectDirectory } from '../../opencode/project-directory-initialization';
const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
@@ -82,6 +86,13 @@ const COMMAND_IMAGE_DATA_URL_PATTERN =
/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/iu;
const STRICT_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
function parseOptionalProjectType(value: unknown): ProjectType | undefined {
if (value === undefined) return undefined;
if (!isProjectType(value)) {
throw new Error('Invalid project type');
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -956,8 +967,10 @@ export async function handleOpencodeRoutes(
projectPath?: string;
parentPath?: string;
projectName?: string;
projectType?: unknown;
defaultModel?: unknown;
}>(req);
const projectType = parseOptionalProjectType(body.projectType);
const defaultModel = body.defaultModel === undefined || body.defaultModel === null
? null
: typeof body.defaultModel === 'string' && body.defaultModel.trim()
@@ -972,6 +985,7 @@ export async function handleOpencodeRoutes(
: buildNewProjectPath(body.parentPath, body.projectName);
const { config } = await initializeProjectDirectory({
projectPath,
projectType,
defaultModel,
allowExistingDirectory: useSelectedDirectory,
});

View File

@@ -2,11 +2,14 @@ import path from 'node:path';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import {
createProjectConfig,
isProjectType,
validateAgentConfigs,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
type ProjectType,
} from '../../shared/project-config';
import { createPublishableProjectTemplate } from './project-template';
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
@@ -22,6 +25,12 @@ function normalizeStringList(value: unknown): string[] {
.filter(Boolean))];
}
function normalizeProjectType(value: unknown): ProjectType {
if (value === undefined) return 'custom';
if (isProjectType(value)) return value;
throw new Error('Invalid project type');
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
@@ -86,6 +95,7 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
}
return {
schemaVersion: 1,
projectType: normalizeProjectType(raw.projectType),
initialized,
superpowersEnabled: raw.superpowersEnabled === true,
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
@@ -231,9 +241,9 @@ Last Updated By: user
export async function createInitialProjectConfig(
projectPath: string,
options?: { defaultModel?: string | null },
options?: { defaultModel?: string | null; projectType?: ProjectType },
): Promise<ProjectConfig> {
const config = createProjectConfig();
const config = createProjectConfig(new Date().toISOString(), options?.projectType ?? 'custom');
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
config.defaultModel = options.defaultModel.trim();
}
@@ -242,15 +252,25 @@ export async function createInitialProjectConfig(
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(config.createdAt), { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' });
if (config.projectType !== 'custom') {
await createPublishableProjectTemplate(projectPath, config.projectType);
}
return config;
}
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
const previous = await readProjectConfig(projectPath);
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
const requestedProjectType = value && typeof value === 'object' && !Array.isArray(value)
? (value as { projectType?: unknown }).projectType
: undefined;
if (requestedProjectType !== undefined && requestedProjectType !== previous.config.projectType) {
throw new Error('已有项目类型不可更改');
}
const config = normalizeProjectConfig({
...(value as object),
schemaVersion: 1,
projectType: previous.config.projectType,
createdAt: previous.config.createdAt,
updatedAt: new Date().toISOString(),
});

View File

@@ -11,7 +11,7 @@ import {
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import type { ProjectConfig } from '../../shared/project-config';
import type { ProjectConfig, ProjectType } from '../../shared/project-config';
import { createInitialProjectConfig, readProjectConfig } from './project-config';
type StagedProjectEntries = {
@@ -27,6 +27,7 @@ export type ProjectDirectoryInitializationResult = {
export type ProjectDirectoryInitializationInput = {
projectPath: string;
defaultModel?: string | null;
projectType?: ProjectType;
allowExistingDirectory: boolean;
};
@@ -210,6 +211,9 @@ export async function initializeProjectDirectory(
await requireExistingDirectory(input.projectPath);
const existing = await readProjectConfig(input.projectPath);
if (existing.status === 'valid') {
if (input.projectType !== undefined && input.projectType !== existing.config.projectType) {
throw new Error('已有项目类型不可更改');
}
return { config: existing.config, reusedExistingConfig: true };
}
if (existing.status === 'invalid') {
@@ -228,6 +232,7 @@ export async function initializeProjectDirectory(
try {
config = await createInitialProjectConfig(stagingPath, {
defaultModel: input.defaultModel,
projectType: input.projectType,
});
} catch (error) {
throw new Error(

View File

@@ -0,0 +1,139 @@
import { dirname, join } from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import type { ProjectType } from '../../shared/project-config';
import vitePackageJson from './project-templates/vite/package.json';
import vitePackageLock from './project-templates/vite/package-lock.json';
const VITE_CONFIG = `import { defineConfig } from 'vite';
export default defineConfig({
base: './',
});
`;
const MINI_GAME_FILES: Record<string, string> = {
'index.html': `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Makelore 小游戏</title>
<style>
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #10233d; }
body { display: grid; place-items: center; font-family: system-ui, sans-serif; }
#game { width: 100%; height: 100%; touch-action: none; }
</style>
</head>
<body>
<canvas id="game" aria-label="Makelore 小游戏画布"></canvas>
<script type="module" src="/game.js"></script>
</body>
</html>
`,
'game.json': `${JSON.stringify({ deviceOrientation: 'portrait', showStatusBar: false }, null, 2)}\n`,
'game.js': `const canvas = document.querySelector('#game');
const context = canvas.getContext('2d');
function render() {
const ratio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#10233d');
gradient.addColorStop(1, '#3a5578');
context.fillStyle = gradient;
context.fillRect(0, 0, width, height);
context.textAlign = 'center';
context.fillStyle = '#ffffff';
context.font = '700 28px system-ui, sans-serif';
context.fillText('Makelore 小游戏', width / 2, height / 2 - 10);
context.fillStyle = '#f6b45f';
context.font = '16px system-ui, sans-serif';
context.fillText('点击画布,开始创造', width / 2, height / 2 + 26);
}
canvas.addEventListener('pointerdown', (event) => {
context.beginPath();
context.fillStyle = '#f26a3d';
context.arc(event.clientX, event.clientY, 18, 0, Math.PI * 2);
context.fill();
});
window.addEventListener('resize', render);
render();
`,
};
const MINI_PROGRAM_FILES: Record<string, string> = {
'index.html': `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Makelore 小程序</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/pages/index/index.js"></script>
</body>
</html>
`,
'app.json': `${JSON.stringify({ pages: ['pages/index/index'], window: { navigationBarTitleText: 'Makelore 小程序' } }, null, 2)}\n`,
'app.js': `export const app = {
globalData: {
platform: 'makelore',
},
};
`,
'pages/index/index.json': `${JSON.stringify({ navigationBarTitleText: '首页' }, null, 2)}\n`,
'pages/index/index.css': `:root {
color: #24364b;
background: #f4f7fb;
font-family: system-ui, sans-serif;
}
body { margin: 0; }
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(420px, 100%); border: 1px solid #d8e1ec; border-radius: 20px; background: #fff; padding: 28px; text-align: center; box-shadow: 0 16px 48px rgb(36 54 75 / 10%); }
button { border: 0; border-radius: 12px; background: #3a5578; color: #fff; padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
`,
'pages/index/index.js': `import { app } from '../../app.js';
import './index.css';
const root = document.querySelector('#app');
let count = 0;
root.innerHTML = \`<section class="page"><div class="card"><p>运行于 \${app.globalData.platform}</p><h1>Makelore 小程序</h1><p id="count">已点击 0 次</p><button type="button">点我试试</button></div></section>\`;
root.querySelector('button').addEventListener('click', () => {
count += 1;
root.querySelector('#count').textContent = \`已点击 \${count}\`;
});
`,
};
function serializeJson(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
export async function createPublishableProjectTemplate(
projectPath: string,
projectType: Exclude<ProjectType, 'custom'>,
): Promise<void> {
const files = projectType === 'mini_game' ? MINI_GAME_FILES : MINI_PROGRAM_FILES;
const templateFiles: Record<string, string> = {
'package.json': serializeJson(vitePackageJson),
'package-lock.json': serializeJson(vitePackageLock),
'vite.config.js': VITE_CONFIG,
...files,
};
for (const [relativePath, content] of Object.entries(templateFiles)) {
const filePath = join(projectPath, relativePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' });
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "makelore-project",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "npm@10.9.2",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"devDependencies": {
"vite": "7.3.1"
}
}

View File

@@ -10,19 +10,26 @@ import {
writeFile,
} from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
import type { ProjectType } from '../../shared/project-config';
import { readProjectConfig } from '../opencode/project-config';
const require = createRequire(import.meta.url);
const AdmZip = require('adm-zip') as typeof import('adm-zip');
const GENERATED_MANIFEST = (
'schema_version: 1\n'
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
+ ' preset: vite\n'
+ ' package_manager: npm\n'
+ ' entry: index.html\n'
);
type PublishableProjectType = Exclude<ProjectType, 'custom'>;
function generatedManifest(projectType: PublishableProjectType): string {
return (
'schema_version: 1\n'
+ `project_type: ${projectType}\n`
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
+ ' preset: vite\n'
+ ' package_manager: npm\n'
+ ' entry: index.html\n'
);
}
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
const FILE_MODE = 0o100644;
const DEFAULT_LIMITS: ProjectPackageLimits = {
@@ -125,6 +132,7 @@ export type StaticProjectPackageSummary = {
excludedPaths: string[];
manifest: {
schema_version: 1;
project_type: PublishableProjectType;
kind: 'web';
runtime: 'static';
build: {
@@ -177,9 +185,10 @@ export async function createStaticProjectPackage(input: {
}): Promise<StaticProjectPackageSummary> {
const limits = input.limits ?? DEFAULT_LIMITS;
const projectPath = await resolveProjectDirectory(input.projectPath);
const projectType = await readPublishableProjectType(projectPath);
await validateViteProject(projectPath, limits);
const scan = await scanProject(projectPath, limits);
const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8');
const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8');
const sourceBytes = scan.sourceBytes + manifestBytes.length;
if (sourceBytes > limits.maxSourceBytes) {
throw new ProjectPackageError(
@@ -228,6 +237,7 @@ export async function createStaticProjectPackage(input: {
excludedPaths: scan.excludedPaths,
manifest: {
schema_version: 1,
project_type: projectType,
kind: 'web',
runtime: 'static',
build: {
@@ -239,6 +249,23 @@ export async function createStaticProjectPackage(input: {
};
}
async function readPublishableProjectType(projectPath: string): Promise<PublishableProjectType> {
const result = await readProjectConfig(projectPath);
if (result.status === 'invalid') {
throw new ProjectPackageError(
'PROJECT_CONFIG_INVALID',
'项目配置无效,无法确认发布类型',
);
}
if (result.status === 'missing' || result.config.projectType === 'custom') {
throw new ProjectPackageError(
'PROJECT_TYPE_UNPUBLISHABLE',
'自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
);
}
return result.config.projectType;
}
async function resolveProjectDirectory(projectPath: string): Promise<string> {
try {
const resolved = await realpath(projectPath);