@myvillage/cli 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/commands/create-game.js +2 -2
- package/src/commands/init-game.js +200 -0
- package/src/index.js +17 -0
- package/src/utils/game-init.js +379 -0
- package/src/utils/game-toolkit.js +32 -4
- package/src/utils/templates.js +119 -18
package/README.md
CHANGED
|
@@ -19,6 +19,10 @@ myvillage login
|
|
|
19
19
|
# Create a new game project
|
|
20
20
|
myvillage create-game
|
|
21
21
|
|
|
22
|
+
# Or bring a web game you already have into MyVillage
|
|
23
|
+
# (shows what will change and asks first; --dry-run only shows)
|
|
24
|
+
myvillage init-game ./my-game
|
|
25
|
+
|
|
22
26
|
# Deploy to the platform
|
|
23
27
|
myvillage deploy
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -61,8 +61,8 @@ export async function createGameCommand(options = {}) {
|
|
|
61
61
|
name: 'orientation',
|
|
62
62
|
message: 'Where will this game play?',
|
|
63
63
|
choices: [
|
|
64
|
-
{ name: 'Portrait — muni mobile app + web
|
|
65
|
-
{ name: 'Landscape —
|
|
64
|
+
{ name: 'Portrait — muni mobile app + web', value: 'portrait' },
|
|
65
|
+
{ name: 'Landscape — muni mobile app (turned sideways) + web; Unity: web only', value: 'landscape' },
|
|
66
66
|
],
|
|
67
67
|
},
|
|
68
68
|
])
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import { brand } from '../utils/brand.js';
|
|
6
|
+
import {
|
|
7
|
+
inspectGameFolder,
|
|
8
|
+
convertToGame,
|
|
9
|
+
defaultCopyDir,
|
|
10
|
+
updatePlatformFiles,
|
|
11
|
+
GAME_CATEGORIES,
|
|
12
|
+
TARGET_AGES,
|
|
13
|
+
} from '../utils/game-init.js';
|
|
14
|
+
|
|
15
|
+
// `myvillage init-game [dir]` — bring an existing web game into MyVillage, or
|
|
16
|
+
// bring a MyVillage game's platform files up to this CLI's. Shows what will
|
|
17
|
+
// change and asks before writing anything (--yes skips the question, --dry-run
|
|
18
|
+
// only shows). MVP Studio runs the same code in-process (src/utils/game-init.js).
|
|
19
|
+
//
|
|
20
|
+
// A web game is converted into a new folder next to it (<name>-myvillage) so
|
|
21
|
+
// the original stays as it was; --into picks the folder, --in-place converts
|
|
22
|
+
// the original instead.
|
|
23
|
+
|
|
24
|
+
function out(json, payload) {
|
|
25
|
+
if (json) console.log(JSON.stringify(payload));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fail(json, msg) {
|
|
29
|
+
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
30
|
+
else console.log(chalk.red(`\n ✗ ${msg}`));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printChanges(changes) {
|
|
35
|
+
for (const c of changes) {
|
|
36
|
+
const verb = c.action === 'create' ? brand.green('+ add ') : brand.gold('~ update');
|
|
37
|
+
console.log(` ${verb} ${c.path} ${brand.teal(c.summary)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function printNotes(notes) {
|
|
42
|
+
if (!notes.length) return;
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(brand.gold(' Still needed (left for you):'));
|
|
45
|
+
for (const n of notes) console.log(` • ${n}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function confirm(message) {
|
|
49
|
+
const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: true }]);
|
|
50
|
+
return ok;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function initGameCommand(dirArg, options = {}) {
|
|
54
|
+
const json = !!options.json;
|
|
55
|
+
const dryRun = !!options.dryRun;
|
|
56
|
+
// JSON means a program is driving: never prompt.
|
|
57
|
+
const ask = !json && !options.yes;
|
|
58
|
+
const dir = resolve(dirArg || options.dir || process.cwd());
|
|
59
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return fail(json, `No such folder: ${dir}`);
|
|
60
|
+
|
|
61
|
+
let info;
|
|
62
|
+
try {
|
|
63
|
+
info = inspectGameFolder(dir);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return fail(json, err.message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (info.kind === 'unsupported') {
|
|
69
|
+
if (!json) return fail(json, info.reason);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return out(json, { ok: false, kind: info.kind, dir, error: info.reason });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (info.kind === 'unity') {
|
|
75
|
+
if (json) return out(json, { ok: true, kind: 'unity', dir, dryRun, changes: [], notes: [] });
|
|
76
|
+
console.log(brand.green(`\n ✓ ${info.name} is a MyVillage Unity game. Nothing to set up here.`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Already a MyVillage game: refresh its platform files ──
|
|
81
|
+
if (info.kind === 'myvillage') {
|
|
82
|
+
if (dryRun || !info.changes.length) {
|
|
83
|
+
if (json) return out(json, { ok: true, kind: 'myvillage', dir, name: info.name, dryRun: true, changes: info.changes, notes: [] });
|
|
84
|
+
if (!info.changes.length) {
|
|
85
|
+
console.log(brand.green(`\n ✓ ${info.name} already has the latest MyVillage platform files.`));
|
|
86
|
+
} else {
|
|
87
|
+
console.log(`\n ${info.name} has older MyVillage platform files:\n`);
|
|
88
|
+
printChanges(info.changes);
|
|
89
|
+
console.log(brand.teal('\n Run without --dry-run to update them.'));
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (ask) {
|
|
94
|
+
console.log(`\n ${info.name} has older MyVillage platform files. This will:\n`);
|
|
95
|
+
printChanges(info.changes);
|
|
96
|
+
console.log();
|
|
97
|
+
if (!(await confirm('Update them?'))) return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const res = updatePlatformFiles(dir);
|
|
101
|
+
if (json) return out(json, { ok: true, kind: 'myvillage', dir, name: info.name, dryRun: false, changes: res.changes, notes: [] });
|
|
102
|
+
console.log(brand.green(`\n ✓ Updated ${res.changes.map((c) => c.path).join(', ')}`));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
fail(json, err.message);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── A web project: convert it ──
|
|
110
|
+
const given = {
|
|
111
|
+
name: options.name,
|
|
112
|
+
description: options.description,
|
|
113
|
+
category: options.category,
|
|
114
|
+
orientation: options.orientation,
|
|
115
|
+
targetAge: options.targetAge,
|
|
116
|
+
};
|
|
117
|
+
let details = { ...info.defaults };
|
|
118
|
+
for (const [k, v] of Object.entries(given)) if (v !== undefined) details[k] = String(v);
|
|
119
|
+
if (options.into && options.inPlace) return fail(json, 'Use --into or --in-place, not both.');
|
|
120
|
+
let into = options.inPlace ? undefined : resolve(options.into || defaultCopyDir(dir));
|
|
121
|
+
|
|
122
|
+
if (ask && !dryRun) {
|
|
123
|
+
console.log(`\n ${brand.gold(dir)} is a web project that isn't a MyVillage game yet.\n`);
|
|
124
|
+
const prompts = [];
|
|
125
|
+
if (given.name === undefined) prompts.push({ type: 'input', name: 'name', message: 'Game name:', default: details.name });
|
|
126
|
+
if (given.description === undefined) prompts.push({ type: 'input', name: 'description', message: 'Description:', default: details.description });
|
|
127
|
+
if (given.category === undefined)
|
|
128
|
+
prompts.push({ type: 'list', name: 'category', message: 'What kind of game is it?', choices: GAME_CATEGORIES, default: details.category });
|
|
129
|
+
if (given.orientation === undefined)
|
|
130
|
+
prompts.push({
|
|
131
|
+
type: 'list',
|
|
132
|
+
name: 'orientation',
|
|
133
|
+
message: 'Where will it play?',
|
|
134
|
+
choices: [
|
|
135
|
+
{ name: 'Portrait — muni mobile app + web', value: 'portrait' },
|
|
136
|
+
{ name: 'Landscape — muni mobile app (turned sideways) + web', value: 'landscape' },
|
|
137
|
+
],
|
|
138
|
+
default: details.orientation,
|
|
139
|
+
});
|
|
140
|
+
if (given.targetAge === undefined)
|
|
141
|
+
prompts.push({ type: 'list', name: 'targetAge', message: 'Target age group:', choices: TARGET_AGES, default: details.targetAge });
|
|
142
|
+
if (!options.into && !options.inPlace)
|
|
143
|
+
prompts.push({
|
|
144
|
+
type: 'list',
|
|
145
|
+
name: 'where',
|
|
146
|
+
message: 'Where should the MyVillage version go?',
|
|
147
|
+
choices: [
|
|
148
|
+
{ name: `A new folder: ${basename(into)} (your original stays as it is)`, value: 'copy' },
|
|
149
|
+
{ name: 'This folder (change it in place)', value: 'in-place' },
|
|
150
|
+
],
|
|
151
|
+
default: 'copy',
|
|
152
|
+
});
|
|
153
|
+
const { where, ...answers } = await inquirer.prompt(prompts);
|
|
154
|
+
details = { ...details, ...answers };
|
|
155
|
+
if (where === 'in-place') into = undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let plan;
|
|
159
|
+
try {
|
|
160
|
+
plan = convertToGame(dir, details, { dryRun: true, into });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return fail(json, err.message);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (dryRun) {
|
|
166
|
+
if (json) return out(json, { ok: true, kind: 'web', source: dir, name: details.name, defaults: info.defaults, dryRun: true, ...plan });
|
|
167
|
+
console.log(`\n Bringing ${details.name} into MyVillage would${into ? ` copy it to ${brand.gold(into)} and then` : ''}:\n`);
|
|
168
|
+
printChanges(plan.changes);
|
|
169
|
+
printNotes(plan.notes);
|
|
170
|
+
console.log(brand.teal('\n Run without --dry-run to make these changes.'));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (ask) {
|
|
175
|
+
console.log(into ? `\n This will copy the game to ${brand.gold(into)}, then in the copy:\n` : `\n This will:\n`);
|
|
176
|
+
printChanges(plan.changes);
|
|
177
|
+
printNotes(plan.notes);
|
|
178
|
+
console.log(brand.teal(into ? '\n The original folder isn\'t changed. node_modules, build output and git history aren\'t copied.' : "\n Your game's own code isn't changed."));
|
|
179
|
+
console.log();
|
|
180
|
+
if (!(await confirm('Make these changes?'))) return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const res = convertToGame(dir, details, { into });
|
|
185
|
+
if (json) return out(json, { ok: true, kind: 'web', source: dir, name: details.name, dryRun: false, ...res });
|
|
186
|
+
console.log(brand.green(`\n ✓ ${details.name} is set up as a MyVillage game${into ? ` in ${res.dir}` : ''}.`));
|
|
187
|
+
if (into) console.log(brand.teal(` cd ${res.dir} && npm install`));
|
|
188
|
+
printNotes(res.notes);
|
|
189
|
+
console.log();
|
|
190
|
+
console.log(brand.teal(' Next, connect the game itself (or let MVP Studio\'s AI do it: open the game → Design):'));
|
|
191
|
+
console.log(` 1. ${brand.gold("import { MyVillageGame } from './myvillage-game.js'")} where the game starts`);
|
|
192
|
+
console.log(` 2. ${brand.gold('MyVillageGame.ready()')} once it's playable`);
|
|
193
|
+
console.log(` 3. ${brand.gold('MyVillageGame.complete({ score, won })')} on every way it can end`);
|
|
194
|
+
console.log(` 4. ${brand.gold('npm run build')}, then ${brand.gold('myvillage deploy')}`);
|
|
195
|
+
console.log(brand.teal(' AGENTS.md has the full rules (phone layout, play time).'));
|
|
196
|
+
console.log();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
fail(json, err.message);
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
apiKeyShowScopesCommand,
|
|
17
17
|
} from './commands/api-key.js';
|
|
18
18
|
import { createGameCommand } from './commands/create-game.js';
|
|
19
|
+
import { initGameCommand } from './commands/init-game.js';
|
|
19
20
|
import { createCommand } from './commands/create-app.js';
|
|
20
21
|
import { deployCommand } from './commands/deploy.js';
|
|
21
22
|
import { publishBookCommand } from './commands/publish-book.js';
|
|
@@ -229,6 +230,22 @@ export function run() {
|
|
|
229
230
|
.option('--json', 'Output the result as JSON (implies non-interactive)')
|
|
230
231
|
.action(createGameCommand);
|
|
231
232
|
|
|
233
|
+
program
|
|
234
|
+
.command('init-game [dir]')
|
|
235
|
+
.description('Bring an existing web game into MyVillage, or update a game\'s MyVillage platform files')
|
|
236
|
+
.option('--name <name>', 'Game name (defaults to package.json)')
|
|
237
|
+
.option('--description <text>', 'Game description')
|
|
238
|
+
.option('--category <category>', 'education | action | adventure | puzzle | quiz | exploration | narrative | other')
|
|
239
|
+
.option('--orientation <orientation>', 'portrait | landscape')
|
|
240
|
+
.option('--target-age <age>', 'under-5 | 6-12 | 13-and-over')
|
|
241
|
+
.option('--dir <path>', 'Project folder (defaults to cwd)')
|
|
242
|
+
.option('--into <path>', 'Convert into this new folder (default: <folder>-myvillage next to it)')
|
|
243
|
+
.option('--in-place', 'Convert the folder itself instead of a copy')
|
|
244
|
+
.option('--dry-run', 'Show what would change and write nothing')
|
|
245
|
+
.option('-y, --yes', "Don't ask: use the defaults and make the changes")
|
|
246
|
+
.option('--json', 'Output the result as JSON (implies --yes)')
|
|
247
|
+
.action(initGameCommand);
|
|
248
|
+
|
|
232
249
|
program
|
|
233
250
|
.command('create')
|
|
234
251
|
.description('Create a new application or game')
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, cpSync, readdirSync, rmSync } from 'fs';
|
|
2
|
+
import { join, basename, dirname, resolve, relative, isAbsolute, sep } from 'path';
|
|
3
|
+
import { installGameToolkit, refreshGameToolkit } from './game-toolkit.js';
|
|
4
|
+
import { generateGameAgents, generateMinimalViteConfig, starterThumbnailPath } from './templates.js';
|
|
5
|
+
|
|
6
|
+
// Bringing a web game that wasn't made with MyVillage into the platform
|
|
7
|
+
// (`myvillage init-game`), and keeping a MyVillage game's platform files current.
|
|
8
|
+
// MVP Studio imports this module in-process, so it must stay free of chalk,
|
|
9
|
+
// inquirer and anything else that prints or prompts.
|
|
10
|
+
//
|
|
11
|
+
// Every entry point takes `dryRun`: the same code computes what would change
|
|
12
|
+
// and makes the change, so what Studio shows before asking is exactly what
|
|
13
|
+
// gets written.
|
|
14
|
+
|
|
15
|
+
export const GAME_CATEGORIES = ['education', 'action', 'adventure', 'puzzle', 'quiz', 'exploration', 'narrative', 'other'];
|
|
16
|
+
export const TARGET_AGES = ['under-5', '6-12', '13-and-over'];
|
|
17
|
+
export const ORIENTATIONS = ['portrait', 'landscape'];
|
|
18
|
+
|
|
19
|
+
const VITE_CONFIGS = ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cjs', 'vite.config.cts'];
|
|
20
|
+
const OUR_AGENTS = /^# AGENTS\.md — working on this MyVillage/;
|
|
21
|
+
const AGENTS_START = '<!-- myvillage:agents:start -->';
|
|
22
|
+
const AGENTS_END = '<!-- myvillage:agents:end -->';
|
|
23
|
+
|
|
24
|
+
function readJson(path) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeJson(path, data) {
|
|
33
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deps(pkg) {
|
|
37
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function titleFromPackageName(raw) {
|
|
41
|
+
const bare = String(raw || '').replace(/^@[^/]+\//, '');
|
|
42
|
+
return bare
|
|
43
|
+
.split(/[-_.\s]+/)
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
46
|
+
.join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What kind of folder this is, and what `init-game` would do to it.
|
|
51
|
+
*
|
|
52
|
+
* kind 'web' a package.json project with no `myvillage` block: `changes`
|
|
53
|
+
* is the conversion plan, `defaults` the prefilled details
|
|
54
|
+
* kind 'myvillage' already a MyVillage JS game: `changes` lists platform
|
|
55
|
+
* files that are older than this CLI's
|
|
56
|
+
* kind 'unity' a MyVillage Unity game (nothing to update here)
|
|
57
|
+
* kind 'unsupported' anything else; `reason` says what is supported
|
|
58
|
+
*/
|
|
59
|
+
export function inspectGameFolder(dir) {
|
|
60
|
+
const unity = readJson(join(dir, 'myvillage.json'));
|
|
61
|
+
if (unity && unity.engine === 'unity') {
|
|
62
|
+
return { kind: 'unity', dir, name: unity.name || basename(dir), changes: [], notes: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pkgPath = join(dir, 'package.json');
|
|
66
|
+
const pkg = readJson(pkgPath);
|
|
67
|
+
if (!pkg) {
|
|
68
|
+
const looksUnity = existsSync(join(dir, 'Assets')) && existsSync(join(dir, 'ProjectSettings'));
|
|
69
|
+
const reason = existsSync(pkgPath)
|
|
70
|
+
? "package.json isn't valid JSON, so this folder can't be read as a web game. Fix it and try again."
|
|
71
|
+
: looksUnity
|
|
72
|
+
? 'This looks like a Unity project that wasn\'t made with MyVillage. Unity games start from New game with Engine set to Unity; bring your scenes and scripts into that project.'
|
|
73
|
+
: 'This folder isn\'t a game Studio can bring in. Studio supports web games with a package.json (built with Vite, or any tool whose `npm run build` writes a dist/ folder), and Unity games made with MyVillage.';
|
|
74
|
+
return { kind: 'unsupported', dir, reason, changes: [], notes: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (pkg.myvillage && typeof pkg.myvillage === 'object') {
|
|
78
|
+
const { changes } = updatePlatformFiles(dir, { dryRun: true });
|
|
79
|
+
return { kind: 'myvillage', dir, name: pkg.myvillage.name || pkg.name || basename(dir), changes, notes: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const defaults = conversionDefaults(dir, pkg);
|
|
83
|
+
const { changes, notes } = convertToGame(dir, defaults, { dryRun: true });
|
|
84
|
+
return { kind: 'web', dir, name: defaults.name, defaults, changes, notes, copyTo: defaultCopyDir(dir) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Converting into a copy ──────────────────────────────────────────────────
|
|
88
|
+
// By default a game is converted into a new folder next to the original, so the
|
|
89
|
+
// original stays exactly as it was. Installed packages and build output aren't
|
|
90
|
+
// copied (npm install / npm run build make them again), and neither is git
|
|
91
|
+
// history: the copy is a new project.
|
|
92
|
+
|
|
93
|
+
const SKIP_ANYWHERE = new Set(['node_modules', '.git', '.DS_Store']);
|
|
94
|
+
const SKIP_AT_TOP = new Set(['dist', 'build', '.vite', '.cache', '.parcel-cache', '.turbo', '.next', 'coverage']);
|
|
95
|
+
|
|
96
|
+
/** A free sibling folder for the converted copy: `<name>-myvillage`, then -2, -3… */
|
|
97
|
+
export function defaultCopyDir(dir) {
|
|
98
|
+
const base = join(dirname(resolve(dir)), `${basename(resolve(dir))}-myvillage`);
|
|
99
|
+
if (!existsSync(base)) return base;
|
|
100
|
+
for (let i = 2; ; i++) if (!existsSync(`${base}-${i}`)) return `${base}-${i}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isInside(child, parent) {
|
|
104
|
+
const rel = relative(parent, child);
|
|
105
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Copy a project into `to` (which must not exist yet, or be empty). */
|
|
109
|
+
export function copyGameProject(from, to) {
|
|
110
|
+
const src = resolve(from);
|
|
111
|
+
const dest = resolve(to);
|
|
112
|
+
if (isInside(dest, src)) throw new Error('The new folder can\'t be inside the game\'s own folder.');
|
|
113
|
+
if (existsSync(dest) && readdirSync(dest).length) {
|
|
114
|
+
throw new Error(`${dest} already exists and isn't empty. Pick a new folder.`);
|
|
115
|
+
}
|
|
116
|
+
cpSync(src, dest, {
|
|
117
|
+
recursive: true,
|
|
118
|
+
filter: (path) => {
|
|
119
|
+
const rel = relative(src, path);
|
|
120
|
+
if (!rel) return true;
|
|
121
|
+
const parts = rel.split(sep);
|
|
122
|
+
if (parts.some((p) => SKIP_ANYWHERE.has(p))) return false;
|
|
123
|
+
return !SKIP_AT_TOP.has(parts[0]);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The game details to prefill, from what package.json already says. */
|
|
129
|
+
export function conversionDefaults(dir, pkg = readJson(join(dir, 'package.json')) || {}) {
|
|
130
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.map((k) => String(k).toLowerCase()) : [];
|
|
131
|
+
return {
|
|
132
|
+
name: String(pkg.productName || '').trim() || titleFromPackageName(pkg.name) || titleFromPackageName(basename(dir)) || 'My Game',
|
|
133
|
+
description: typeof pkg.description === 'string' ? pkg.description : '',
|
|
134
|
+
category: GAME_CATEGORIES.find((c) => keywords.includes(c)) || 'education',
|
|
135
|
+
// Portrait plays everywhere (the muni app and the web).
|
|
136
|
+
orientation: 'portrait',
|
|
137
|
+
targetAge: '6-12',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeDetails(details) {
|
|
142
|
+
const name = String(details.name || '').trim();
|
|
143
|
+
if (!name) throw new Error('The game needs a name.');
|
|
144
|
+
const category = String(details.category || 'education').toLowerCase();
|
|
145
|
+
if (!GAME_CATEGORIES.includes(category)) throw new Error(`Category must be one of: ${GAME_CATEGORIES.join(', ')}`);
|
|
146
|
+
const orientation = String(details.orientation || 'portrait').toLowerCase();
|
|
147
|
+
if (!ORIENTATIONS.includes(orientation)) throw new Error('Orientation must be portrait or landscape.');
|
|
148
|
+
const targetAge = String(details.targetAge || '6-12').toLowerCase();
|
|
149
|
+
if (!TARGET_AGES.includes(targetAge)) throw new Error(`Target age must be one of: ${TARGET_AGES.join(', ')}`);
|
|
150
|
+
const description = typeof details.description === 'string' ? details.description.trim() : '';
|
|
151
|
+
return { name, category, orientation, targetAge, description };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Turn a package.json web project into a MyVillage game: the `myvillage`
|
|
156
|
+
* block, the toolkit, AGENTS.md, a relative Vite base + build script, and a
|
|
157
|
+
* starter thumbnail. Never touches the game's own code, and never rewrites a
|
|
158
|
+
* build it doesn't understand (a note says what's needed instead).
|
|
159
|
+
*
|
|
160
|
+
* With `into`, the project is first copied there (see copyGameProject) and the
|
|
161
|
+
* copy is converted; the original folder isn't touched. Without it, the folder
|
|
162
|
+
* itself is converted.
|
|
163
|
+
*
|
|
164
|
+
* Returns { dir, changes: [{ path, action: 'create'|'update', summary }], notes },
|
|
165
|
+
* where `dir` is the folder that was (or would be) converted.
|
|
166
|
+
*/
|
|
167
|
+
export function convertToGame(dir, details, { dryRun = false, into } = {}) {
|
|
168
|
+
if (!into) return { dir, ...convertInPlace(dir, details, { dryRun }) };
|
|
169
|
+
// The copy would get exactly the original's plan (it's the same files).
|
|
170
|
+
if (dryRun) return { dir: resolve(into), ...convertInPlace(dir, details, { dryRun: true }) };
|
|
171
|
+
// Check the details before copying anything.
|
|
172
|
+
normalizeDetails(details);
|
|
173
|
+
const dest = resolve(into);
|
|
174
|
+
const existed = existsSync(dest);
|
|
175
|
+
copyGameProject(dir, dest);
|
|
176
|
+
try {
|
|
177
|
+
return { dir: dest, ...convertInPlace(dest, details) };
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Don't leave a half-converted copy behind.
|
|
180
|
+
if (!existed) rmSync(dest, { recursive: true, force: true });
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function convertInPlace(dir, details, { dryRun = false } = {}) {
|
|
186
|
+
const pkgPath = join(dir, 'package.json');
|
|
187
|
+
const pkg = readJson(pkgPath);
|
|
188
|
+
if (!pkg) throw new Error('No readable package.json in this folder.');
|
|
189
|
+
if (pkg.myvillage) throw new Error('This folder is already a MyVillage game.');
|
|
190
|
+
const game = normalizeDetails(details);
|
|
191
|
+
|
|
192
|
+
const changes = [];
|
|
193
|
+
const notes = [];
|
|
194
|
+
const change = (path, action, summary) => changes.push({ path, action, summary });
|
|
195
|
+
|
|
196
|
+
const isVite = !!deps(pkg).vite || VITE_CONFIGS.some((f) => existsSync(join(dir, f)));
|
|
197
|
+
|
|
198
|
+
// ── package.json ──
|
|
199
|
+
const pkgSummary = ['add the MyVillage details (name, kind, orientation, ages)'];
|
|
200
|
+
const scripts = { ...(pkg.scripts || {}) };
|
|
201
|
+
if (isVite) {
|
|
202
|
+
if (!scripts.build) {
|
|
203
|
+
scripts.build = 'vite build';
|
|
204
|
+
pkgSummary.push('add a `build` script (vite build)');
|
|
205
|
+
} else if (!/\bvite\b/.test(scripts.build)) {
|
|
206
|
+
notes.push(`The \`build\` script ("${scripts.build}") doesn't run Vite. Deploy runs \`npm run build\` and uploads the dist/ folder it makes, so check that it still does.`);
|
|
207
|
+
}
|
|
208
|
+
if (!scripts.dev) {
|
|
209
|
+
scripts.dev = 'vite';
|
|
210
|
+
pkgSummary.push('add a `dev` script for the preview');
|
|
211
|
+
}
|
|
212
|
+
} else if (!scripts.build) {
|
|
213
|
+
notes.push("This project doesn't use Vite and has no `build` script. Deploy runs `npm run build` and uploads the dist/ folder it makes (with an index.html and relative asset paths), so add a build that does that. Vite is the easiest way.");
|
|
214
|
+
} else {
|
|
215
|
+
notes.push(`This project doesn't use Vite, so its build was left alone. Deploy runs \`npm run build\` ("${scripts.build}") and uploads dist/: make sure it writes there, with index.html at the top and asset paths that don't start with "/".`);
|
|
216
|
+
}
|
|
217
|
+
if (!isVite && !scripts.dev) {
|
|
218
|
+
notes.push('There is no `dev` script, so Studio can\'t show a live preview until one is added.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const banner = ['assets/banner.png', 'assets/banner.jpg'].find((p) => existsSync(join(dir, p)));
|
|
222
|
+
const myvillage = {
|
|
223
|
+
name: game.name,
|
|
224
|
+
gameId: null,
|
|
225
|
+
gameType: 'custom',
|
|
226
|
+
category: game.category,
|
|
227
|
+
engine: deps(pkg).three ? 'three' : 'web',
|
|
228
|
+
// Brought in with init-game (not scaffolded): picks the AGENTS.md guide.
|
|
229
|
+
converted: true,
|
|
230
|
+
targetAge: game.targetAge,
|
|
231
|
+
orientation: game.orientation,
|
|
232
|
+
aspectRatio: game.orientation === 'portrait' ? '9:16' : '16:9',
|
|
233
|
+
assets: { thumbnail: 'assets/thumbnail.png', ...(banner && { banner }) },
|
|
234
|
+
villages: [],
|
|
235
|
+
lastDeployed: null,
|
|
236
|
+
};
|
|
237
|
+
const nextPkg = { ...pkg };
|
|
238
|
+
if (game.description && game.description !== pkg.description) nextPkg.description = game.description;
|
|
239
|
+
if (Object.keys(scripts).length) nextPkg.scripts = scripts;
|
|
240
|
+
nextPkg.myvillage = myvillage;
|
|
241
|
+
change('package.json', 'update', pkgSummary.join('; '));
|
|
242
|
+
|
|
243
|
+
// ── Vite config ──
|
|
244
|
+
if (isVite) {
|
|
245
|
+
const configFile = VITE_CONFIGS.find((f) => existsSync(join(dir, f)));
|
|
246
|
+
if (!configFile) {
|
|
247
|
+
const file = pkg.type === 'module' ? 'vite.config.js' : 'vite.config.mjs';
|
|
248
|
+
change(file, 'create', "use relative asset paths (base: './') so the game loads from MyVillage");
|
|
249
|
+
if (!dryRun) writeFileSync(join(dir, file), generateMinimalViteConfig());
|
|
250
|
+
} else {
|
|
251
|
+
const fixed = withRelativeBase(readFileSync(join(dir, configFile), 'utf8'));
|
|
252
|
+
if (fixed.text) {
|
|
253
|
+
change(configFile, 'update', fixed.summary);
|
|
254
|
+
if (!dryRun) writeFileSync(join(dir, configFile), fixed.text);
|
|
255
|
+
} else if (fixed.note) {
|
|
256
|
+
notes.push(`${configFile}: ${fixed.note}`);
|
|
257
|
+
}
|
|
258
|
+
const outDir = /\boutDir\s*:\s*(['"`])([^'"`]+)\1/.exec(readFileSync(join(dir, configFile), 'utf8'))?.[2];
|
|
259
|
+
if (outDir && !['dist', './dist', 'dist/', './dist/'].includes(outDir)) {
|
|
260
|
+
notes.push(`${configFile} builds into "${outDir}", but deploy uploads dist/. Change build.outDir to 'dist'.`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Toolkit ──
|
|
266
|
+
const hadToolkit = new Set(['src/myvillage-game.js', 'src/myvillage-realtime.js'].filter((p) => existsSync(join(dir, p))));
|
|
267
|
+
const toolkit = installGameToolkit(dir, { dryRun });
|
|
268
|
+
for (const path of toolkit.changed) {
|
|
269
|
+
change(path, hadToolkit.has(path) ? 'update' : 'create',
|
|
270
|
+
path.endsWith('myvillage-game.js')
|
|
271
|
+
? 'the MyVillage toolkit: ready(), complete() and automatic play time'
|
|
272
|
+
: 'multiplayer support the toolkit loads when a game asks for it');
|
|
273
|
+
}
|
|
274
|
+
for (const path of toolkit.conflicts) {
|
|
275
|
+
notes.push(`${path} already exists and isn't the MyVillage toolkit, so it was left alone. Rename it, then run this again.`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── AGENTS.md ──
|
|
279
|
+
const agents = agentsMdChange(dir, myvillage, { slug: pkg.name, description: nextPkg.description, dryRun });
|
|
280
|
+
if (agents) change('AGENTS.md', agents.action, agents.summary);
|
|
281
|
+
|
|
282
|
+
// ── Cover art ──
|
|
283
|
+
if (!existsSync(join(dir, 'assets', 'thumbnail.png'))) {
|
|
284
|
+
change('assets/thumbnail.png', 'create', 'starter cover art (swap it for your own any time)');
|
|
285
|
+
if (!dryRun) {
|
|
286
|
+
mkdirSync(join(dir, 'assets'), { recursive: true });
|
|
287
|
+
copyFileSync(starterThumbnailPath(), join(dir, 'assets', 'thumbnail.png'));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// package.json last, so a failure above leaves the folder still unconverted.
|
|
292
|
+
if (!dryRun) writeJson(pkgPath, nextPkg);
|
|
293
|
+
return { changes, notes };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Bring a MyVillage JS game's platform files (the toolkit and AGENTS.md) up to
|
|
298
|
+
* this CLI's. `deploy` already refreshes the toolkit; AGENTS.md only changes here.
|
|
299
|
+
*/
|
|
300
|
+
export function updatePlatformFiles(dir, { dryRun = false } = {}) {
|
|
301
|
+
const pkg = readJson(join(dir, 'package.json'));
|
|
302
|
+
if (!pkg || !pkg.myvillage) throw new Error('Not a MyVillage web game (no `myvillage` block in package.json).');
|
|
303
|
+
|
|
304
|
+
const changes = [];
|
|
305
|
+
for (const path of refreshGameToolkit(dir, { dryRun })) {
|
|
306
|
+
changes.push({ path, action: 'update', summary: 'the latest MyVillage toolkit' });
|
|
307
|
+
}
|
|
308
|
+
const agents = agentsMdChange(dir, pkg.myvillage, { slug: pkg.name, description: pkg.description, dryRun });
|
|
309
|
+
if (agents) changes.push({ path: 'AGENTS.md', ...agents });
|
|
310
|
+
return { changes, notes: [] };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Write (or plan) the AGENTS.md this CLI generates for the game. A file that
|
|
315
|
+
* is ours is replaced; someone else's AGENTS.md keeps its text and gets ours as
|
|
316
|
+
* a marked section at the end. Returns null when it's already current.
|
|
317
|
+
*/
|
|
318
|
+
function agentsMdChange(dir, myvillage, { slug, description, dryRun }) {
|
|
319
|
+
const path = join(dir, 'AGENTS.md');
|
|
320
|
+
const latest = generateGameAgents(myvillage, { slug, description });
|
|
321
|
+
const section = `${AGENTS_START}\n${latest.trimEnd()}\n${AGENTS_END}\n`;
|
|
322
|
+
|
|
323
|
+
let next;
|
|
324
|
+
let action;
|
|
325
|
+
let summary;
|
|
326
|
+
if (!existsSync(path)) {
|
|
327
|
+
next = latest;
|
|
328
|
+
action = 'create';
|
|
329
|
+
summary = 'a guide for AI helpers: the game contract and phone layout rules';
|
|
330
|
+
} else {
|
|
331
|
+
const current = readFileSync(path, 'utf8');
|
|
332
|
+
const start = current.indexOf(AGENTS_START);
|
|
333
|
+
const end = current.indexOf(AGENTS_END);
|
|
334
|
+
if (OUR_AGENTS.test(current)) {
|
|
335
|
+
if (current === latest) return null;
|
|
336
|
+
next = latest;
|
|
337
|
+
summary = 'replace with the latest guide for AI helpers';
|
|
338
|
+
} else if (start >= 0 && end > start) {
|
|
339
|
+
const tail = current.slice(end + AGENTS_END.length).replace(/^\n/, '');
|
|
340
|
+
next = current.slice(0, start) + section + tail;
|
|
341
|
+
if (next === current) return null;
|
|
342
|
+
summary = 'refresh the MyVillage section (your own text is kept)';
|
|
343
|
+
} else {
|
|
344
|
+
next = current.trimEnd() + '\n\n' + section;
|
|
345
|
+
summary = 'add a MyVillage section at the end (your own text is kept)';
|
|
346
|
+
}
|
|
347
|
+
action = 'update';
|
|
348
|
+
}
|
|
349
|
+
if (!dryRun) writeFileSync(path, next);
|
|
350
|
+
return { action, summary };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Make a Vite config use base: './'. Returns { text, summary } with the new
|
|
355
|
+
* config, { note } when it can't be changed safely (a computed base, a config
|
|
356
|
+
* shape we don't recognise), or {} when it's already right.
|
|
357
|
+
*/
|
|
358
|
+
export function withRelativeBase(config) {
|
|
359
|
+
const literal = /\bbase\s*:\s*(['"`])([^'"`]*)\1/.exec(config);
|
|
360
|
+
if (literal) {
|
|
361
|
+
if (literal[2] === './' || literal[2] === '') return {};
|
|
362
|
+
return {
|
|
363
|
+
text: config.slice(0, literal.index) + "base: './'" + config.slice(literal.index + literal[0].length),
|
|
364
|
+
summary: `use relative asset paths: base '${literal[2]}' becomes './'`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (/\bbase\s*:/.test(config)) {
|
|
368
|
+
return { note: "`base` is computed, so it was left alone. Make sure the build uses base: './' (relative asset paths); MyVillage serves the game from its own folder." };
|
|
369
|
+
}
|
|
370
|
+
const open = /(defineConfig\(\s*(?:\([^)]*\)\s*=>\s*\(\s*)?\{|export\s+default\s+\{|module\.exports\s*=\s*\{)/.exec(config);
|
|
371
|
+
if (!open) {
|
|
372
|
+
return { note: "couldn't find where to add base: './'. Add it to the config so asset paths are relative; MyVillage serves the game from its own folder." };
|
|
373
|
+
}
|
|
374
|
+
const at = open.index + open[0].length;
|
|
375
|
+
return {
|
|
376
|
+
text: config.slice(0, at) + "\n base: './'," + config.slice(at),
|
|
377
|
+
summary: "use relative asset paths (base: './') so the game loads from MyVillage",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs';
|
|
2
2
|
import { join, dirname, extname } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
@@ -28,9 +28,10 @@ const TOOLKIT_FILES = [
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Bring a game's platform files up to the toolkit bundled with this CLI.
|
|
31
|
-
* Returns the paths it changed
|
|
31
|
+
* Returns the paths it changed (or, with `dryRun`, would change). Does nothing
|
|
32
|
+
* for a game without our bridge.
|
|
32
33
|
*/
|
|
33
|
-
export function refreshGameToolkit(projectDir) {
|
|
34
|
+
export function refreshGameToolkit(projectDir, { dryRun = false } = {}) {
|
|
34
35
|
const bridge = join(projectDir, 'src/myvillage-game.js');
|
|
35
36
|
if (!existsSync(bridge) || !TOOLKIT_FILES[0].isOurs(readFileSync(bridge, 'utf8'))) return [];
|
|
36
37
|
|
|
@@ -44,12 +45,39 @@ export function refreshGameToolkit(projectDir) {
|
|
|
44
45
|
} else if (!file.createIfMissing) {
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
|
-
writeFileSync(target, latest);
|
|
48
|
+
if (!dryRun) writeFileSync(target, latest);
|
|
48
49
|
changed.push(file.target);
|
|
49
50
|
}
|
|
50
51
|
return changed;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Add the toolkit to a game that doesn't have it yet (`init-game`). Never
|
|
56
|
+
* replaces a file of the same name that isn't ours; those come back in
|
|
57
|
+
* `conflicts` so the caller can say so.
|
|
58
|
+
*/
|
|
59
|
+
export function installGameToolkit(projectDir, { dryRun = false } = {}) {
|
|
60
|
+
const changed = [];
|
|
61
|
+
const conflicts = [];
|
|
62
|
+
for (const file of TOOLKIT_FILES) {
|
|
63
|
+
const target = join(projectDir, file.target);
|
|
64
|
+
if (existsSync(target)) {
|
|
65
|
+
if (!file.isOurs(readFileSync(target, 'utf8'))) conflicts.push(file.target);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!dryRun) {
|
|
69
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
70
|
+
writeFileSync(target, readFileSync(file.source, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
changed.push(file.target);
|
|
73
|
+
}
|
|
74
|
+
// A bridge that was already there may be an older copy of ours.
|
|
75
|
+
if (!conflicts.includes(TOOLKIT_FILES[0].target)) {
|
|
76
|
+
changed.push(...refreshGameToolkit(projectDir, { dryRun }).filter((p) => !changed.includes(p)));
|
|
77
|
+
}
|
|
78
|
+
return { changed, conflicts };
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
/**
|
|
54
82
|
* Whether the game's own code (not the toolkit) ever calls complete(). A game
|
|
55
83
|
* that never does can't record a finished game or pay its reward.
|
package/src/utils/templates.js
CHANGED
|
@@ -700,20 +700,7 @@ function createNarrativeProject(targetDir, options) {
|
|
|
700
700
|
function createArenaProject(targetDir, options) {
|
|
701
701
|
const { name, description, ageGroup, orientation = 'landscape' } = options;
|
|
702
702
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
703
|
-
const
|
|
704
|
-
const vars = {
|
|
705
|
-
NAME: name,
|
|
706
|
-
SLUG: slug,
|
|
707
|
-
DESCRIPTION: description || 'A real-time multiplayer arena game.',
|
|
708
|
-
ORIENTATION: orientation,
|
|
709
|
-
GOLD: BRAND.gold,
|
|
710
|
-
DARK: BRAND.darkBrown,
|
|
711
|
-
CONTAINER_CSS: orientation === 'portrait'
|
|
712
|
-
? `#game-container { position: relative; width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; }
|
|
713
|
-
@media (min-aspect-ratio: 3/4) { #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; } }`
|
|
714
|
-
: '#game-container { position: relative; width: 100%; height: 100%; overflow: hidden; }',
|
|
715
|
-
};
|
|
716
|
-
const fill = (file) => readFileSync(join(src, file), 'utf8').replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m));
|
|
703
|
+
const fill = arenaTemplateFiller(name, slug, description, orientation);
|
|
717
704
|
|
|
718
705
|
for (const dir of ['', 'src', 'assets', 'public']) {
|
|
719
706
|
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
@@ -731,6 +718,24 @@ function createArenaProject(targetDir, options) {
|
|
|
731
718
|
copyTemplateArt(targetDir, 'arena', orientation);
|
|
732
719
|
}
|
|
733
720
|
|
|
721
|
+
// Reads one arena template file with its {{VARS}} filled in.
|
|
722
|
+
function arenaTemplateFiller(name, slug, description, orientation) {
|
|
723
|
+
const src = join(ASSETS_DIR, 'templates', 'arena');
|
|
724
|
+
const vars = {
|
|
725
|
+
NAME: name,
|
|
726
|
+
SLUG: slug,
|
|
727
|
+
DESCRIPTION: description || 'A real-time multiplayer arena game.',
|
|
728
|
+
ORIENTATION: orientation,
|
|
729
|
+
GOLD: BRAND.gold,
|
|
730
|
+
DARK: BRAND.darkBrown,
|
|
731
|
+
CONTAINER_CSS: orientation === 'portrait'
|
|
732
|
+
? `#game-container { position: relative; width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; }
|
|
733
|
+
@media (min-aspect-ratio: 3/4) { #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; } }`
|
|
734
|
+
: '#game-container { position: relative; width: 100%; height: 100%; overflow: hidden; }',
|
|
735
|
+
};
|
|
736
|
+
return (file) => readFileSync(join(src, file), 'utf8').replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m));
|
|
737
|
+
}
|
|
738
|
+
|
|
734
739
|
function generateArenaPackageJson(name, description, slug, ageGroup, orientation) {
|
|
735
740
|
const pkg = JSON.parse(generatePackageJson(name, description || 'A real-time multiplayer arena game.', slug, 'arena', ageGroup, orientation));
|
|
736
741
|
pkg.myvillage.category = 'action';
|
|
@@ -1154,7 +1159,7 @@ function generate2DGameAgents(name, gameType, orientation) {
|
|
|
1154
1159
|
'',
|
|
1155
1160
|
'## Conventions',
|
|
1156
1161
|
'',
|
|
1157
|
-
'- Orientation is **' + orientation + '** (set in `package.json` -> `myvillage.orientation`).
|
|
1162
|
+
'- Orientation is **' + orientation + '** (set in `package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
|
|
1158
1163
|
'- Vanilla JS, no framework — keep it dependency-light.',
|
|
1159
1164
|
'',
|
|
1160
1165
|
].join('\n');
|
|
@@ -1248,12 +1253,108 @@ function generate3DGameAgents(name, type, orientation) {
|
|
|
1248
1253
|
'',
|
|
1249
1254
|
'## Conventions',
|
|
1250
1255
|
'',
|
|
1251
|
-
'- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`).
|
|
1256
|
+
'- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
|
|
1252
1257
|
'- The follow-camera and game loop already exist — extend them, do not rebuild.',
|
|
1253
1258
|
'',
|
|
1254
1259
|
].join('\n');
|
|
1255
1260
|
}
|
|
1256
1261
|
|
|
1262
|
+
// For a web game that started outside MyVillage and was brought in with
|
|
1263
|
+
// `myvillage init-game`. We don't know its files, so instead of a file map it
|
|
1264
|
+
// says how to connect any game to the host.
|
|
1265
|
+
function generateConvertedGameAgents(name, orientation) {
|
|
1266
|
+
const banner = orientation === 'portrait'
|
|
1267
|
+
? '9:16 (e.g. 1080x1920) for this portrait game'
|
|
1268
|
+
: '16:9 (e.g. 1920x1080) for this landscape game';
|
|
1269
|
+
return [
|
|
1270
|
+
'# AGENTS.md — working on this MyVillage game with an AI agent',
|
|
1271
|
+
'',
|
|
1272
|
+
'**' + name + '** is a web game that was brought into MyVillage. It is built with `npm run build` and runs inside the MyVillage host (the muni mobile app or the web player) and standalone in a browser.',
|
|
1273
|
+
'',
|
|
1274
|
+
'When editing: make the requested change, keep the **MyVillage game contract** intact (see below), and keep `npm run build` working.',
|
|
1275
|
+
'',
|
|
1276
|
+
'## Platform files',
|
|
1277
|
+
'',
|
|
1278
|
+
'| File | Purpose |',
|
|
1279
|
+
'|------|---------|',
|
|
1280
|
+
'| `src/myvillage-game.js` | Host bridge — **do not edit.** Import it from the game\'s code: `import { MyVillageGame } from \'./myvillage-game.js\'` (path relative to the importing file). |',
|
|
1281
|
+
'| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
|
|
1282
|
+
'| `assets/` | Cover art shipped on deploy. |',
|
|
1283
|
+
'| `package.json` | The `myvillage` block holds metadata `myvillage deploy` reads. |',
|
|
1284
|
+
'',
|
|
1285
|
+
'Everything else is the game\'s own code. Read it before changing it, and keep its structure.',
|
|
1286
|
+
'',
|
|
1287
|
+
'## The MyVillage game contract — DO NOT BREAK',
|
|
1288
|
+
'',
|
|
1289
|
+
'- `MyVillageGame.ready()` — call once, when the game is interactive (after loading).',
|
|
1290
|
+
'- `MyVillageGame.complete({ score, won })` — **required.** Call it on **every** way the game can end (win, lose, out of time, last level done). It records a completion and awards the player\'s reward. A game with no ending (endless, sandbox) needs a real one that fits it: a goal, a last level, a time limit.',
|
|
1291
|
+
'- **Plays count automatically.** `src/myvillage-game.js` reports how long the player is actively playing; the platform counts a play after 30 seconds of it. Do not add timers, fake input, or early `complete()` calls to make plays count.',
|
|
1292
|
+
'- Optional: `MyVillageGame.progress({ score })` reports the running score during play. It is throttled, so calling it every frame is fine.',
|
|
1293
|
+
'',
|
|
1294
|
+
'Do **not** add an Exit button — the host always provides its own. During `npm run dev` there is no host, so host calls just log to the console (expected).',
|
|
1295
|
+
'',
|
|
1296
|
+
'## Screen layout (phones)',
|
|
1297
|
+
'',
|
|
1298
|
+
'- The game fills the whole screen edge to edge, including behind the status bar and home indicator. The viewport meta needs `viewport-fit=cover`. Never letterbox it or add bars.',
|
|
1299
|
+
'- Define the safe-area variables in `:root`: `--safe-top`, `--safe-bottom`, `--safe-left`, `--safe-right` as `max(env(safe-area-inset-*, 0px), var(--mv-safe-*, 0px))`, plus `--hud-top: calc(var(--safe-top) + 12px)` and `--exit-clear: 64px`. Keep HUD inside them: top row at `var(--hud-top)`, others offset by `var(--safe-bottom)` / `var(--safe-left)` / `var(--safe-right)`.',
|
|
1300
|
+
'- Leave the **top-left corner** (`var(--exit-clear)` wide, below `var(--safe-top)`) empty: the host app draws its Exit button there. Put Pause and other top buttons on the right.',
|
|
1301
|
+
'',
|
|
1302
|
+
'## Run / build / deploy',
|
|
1303
|
+
'',
|
|
1304
|
+
' npm install',
|
|
1305
|
+
' npm run dev # local dev server, live reload',
|
|
1306
|
+
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1307
|
+
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1308
|
+
'',
|
|
1309
|
+
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves from a sub-path, so absolute `/...` URLs break.',
|
|
1310
|
+
'',
|
|
1311
|
+
'## Cover art (`assets/`)',
|
|
1312
|
+
'',
|
|
1313
|
+
'- `thumbnail.png` — square 1:1 (e.g. 512x512). The game card; required.',
|
|
1314
|
+
'- `banner.png` — ' + banner + '. Featured hero; optional.',
|
|
1315
|
+
'',
|
|
1316
|
+
'## Conventions',
|
|
1317
|
+
'',
|
|
1318
|
+
'- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Both play in the muni mobile app (it turns the phone to match) and on the web.',
|
|
1319
|
+
'- Don\'t add npm dependencies unless the change really needs one.',
|
|
1320
|
+
'',
|
|
1321
|
+
].join('\n');
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* The AGENTS.md this CLI would write for a JS game, from its package.json
|
|
1326
|
+
* `myvillage` block. Lets `init-game` bring an older game's guide up to date.
|
|
1327
|
+
*/
|
|
1328
|
+
export function generateGameAgents(myvillage, { slug, description } = {}) {
|
|
1329
|
+
const name = myvillage.name || slug || 'My Game';
|
|
1330
|
+
const orientation = myvillage.orientation === 'portrait' ? 'portrait' : 'landscape';
|
|
1331
|
+
if (myvillage.converted) return generateConvertedGameAgents(name, orientation);
|
|
1332
|
+
const type = String(myvillage.gameType || 'custom');
|
|
1333
|
+
if (type === 'quiz' || type === 'narrative') return generate2DGameAgents(name, type, orientation);
|
|
1334
|
+
if (type === 'arena' || myvillage.multiplayer) {
|
|
1335
|
+
const gameSlug = slug || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
1336
|
+
return arenaTemplateFiller(name, gameSlug, description, orientation)('AGENTS.md');
|
|
1337
|
+
}
|
|
1338
|
+
return generate3DGameAgents(name, type, orientation);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** Vite config for a converted game that had none: just the relative base. */
|
|
1342
|
+
export function generateMinimalViteConfig() {
|
|
1343
|
+
return `import { defineConfig } from 'vite';
|
|
1344
|
+
|
|
1345
|
+
// base: './' keeps asset URLs relative. MyVillage serves each game from its own
|
|
1346
|
+
// folder (/api/v1/games/game-files/<slug>/), so absolute /assets/... URLs break.
|
|
1347
|
+
export default defineConfig({
|
|
1348
|
+
base: './',
|
|
1349
|
+
});
|
|
1350
|
+
`;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Bundled starter thumbnail (square) for a game that has no cover art yet. */
|
|
1354
|
+
export function starterThumbnailPath() {
|
|
1355
|
+
return join(GAME_ART_DIR, 'custom-thumbnail.png');
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1257
1358
|
function generateUnityAgents(name, className, orientation) {
|
|
1258
1359
|
const isLandscape = orientation === 'landscape';
|
|
1259
1360
|
const reach = isLandscape
|
|
@@ -1740,8 +1841,8 @@ function generatePackageJson(name, description, slug, type, ageGroup, orientatio
|
|
|
1740
1841
|
gameId: null,
|
|
1741
1842
|
gameType: type,
|
|
1742
1843
|
targetAge: ageGroup,
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1844
|
+
// Both orientations run in the muni mobile app (it rotates to match) and on
|
|
1845
|
+
// the web (mvp-video-games). Hosts read this to frame the game.
|
|
1745
1846
|
orientation,
|
|
1746
1847
|
aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
|
|
1747
1848
|
// Declared up front so `myvillage deploy` ships them with the submission.
|