@myvillage/cli 2.16.1 → 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/assets/game-toolkit/myvillage-game.js +99 -3
- package/src/assets/templates/arena/AGENTS.md +1 -0
- package/src/commands/create-game.js +2 -2
- package/src/commands/deploy.js +18 -0
- 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 +102 -0
- package/src/utils/templates.js +130 -21
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MyVillage Game Bridge — the "mvgame" protocol (
|
|
1
|
+
// MyVillage Game Bridge — the "mvgame" protocol (v3).
|
|
2
2
|
//
|
|
3
3
|
// ONE contract for every MyVillage game, on every surface:
|
|
4
4
|
// - muni mobile app (React Native WebView)
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
// One the host ALWAYS provides, but you can trigger too:
|
|
13
13
|
// MyVillageGame.exit() // leave the game
|
|
14
14
|
//
|
|
15
|
+
// Play time is automatic. This file measures how long the player is actually
|
|
16
|
+
// playing (screen visible, touched/clicked/pressed something recently) and
|
|
17
|
+
// reports it to the host, which is how the platform tells a real play from
|
|
18
|
+
// someone opening the game and closing it. Don't add timers or fake input for
|
|
19
|
+
// it. Optionally, report the running score as the game goes:
|
|
20
|
+
// MyVillageGame.progress({ score })
|
|
21
|
+
//
|
|
15
22
|
// Real-time multiplayer (optional) — see "Multiplayer" below:
|
|
16
23
|
// const mp = await MyVillageGame.multiplayer({ arena, game: 'my-game' });
|
|
17
24
|
// await mp.quickMatch(); // or mp.host() / mp.join(code)
|
|
@@ -25,7 +32,7 @@
|
|
|
25
32
|
|
|
26
33
|
const PROTOCOL = 'mvgame';
|
|
27
34
|
const HOST_PROTOCOL = 'mvgame-host';
|
|
28
|
-
const VERSION =
|
|
35
|
+
const VERSION = 3;
|
|
29
36
|
|
|
30
37
|
const hasWindow = typeof window !== 'undefined';
|
|
31
38
|
const inReactNative = hasWindow && !!window.ReactNativeWebView;
|
|
@@ -59,6 +66,73 @@ function post(message) {
|
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
// ── Play time ───────────────────────────────────────────────────────────────
|
|
70
|
+
// The platform counts a session as a play once the player has been actively
|
|
71
|
+
// playing for a while (the threshold is the platform's, not ours). Only the game
|
|
72
|
+
// page can tell a player who's playing from a game left open on a table, so the
|
|
73
|
+
// measuring happens here: time counts while the page is visible and the player
|
|
74
|
+
// has touched, clicked or pressed something in the last IDLE_AFTER_MS.
|
|
75
|
+
//
|
|
76
|
+
// The host forwards { type: 'progress', activeMs } to the platform. A message
|
|
77
|
+
// with version >= 3 also tells the host this game measures its own time, so it
|
|
78
|
+
// doesn't fall back to timing how long the game is on screen.
|
|
79
|
+
|
|
80
|
+
const IDLE_AFTER_MS = 20000;
|
|
81
|
+
const REPORT_EVERY_MS = 10000; // of active time
|
|
82
|
+
const TICK_MS = 1000;
|
|
83
|
+
|
|
84
|
+
let activeMs = 0;
|
|
85
|
+
let lastInputAt = -Infinity;
|
|
86
|
+
let lastTickAt = now();
|
|
87
|
+
let reportedMs = -1;
|
|
88
|
+
let lastReportAt = -Infinity;
|
|
89
|
+
let pendingUpdate = null; // from progress(), sent with the next report
|
|
90
|
+
let finished = false;
|
|
91
|
+
|
|
92
|
+
function pageVisible() {
|
|
93
|
+
return typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function playTick() {
|
|
97
|
+
const t = now();
|
|
98
|
+
// A throttled background tab can skip ticks; never credit more than a tick or
|
|
99
|
+
// two for one interval.
|
|
100
|
+
const dt = Math.min(t - lastTickAt, TICK_MS * 2);
|
|
101
|
+
lastTickAt = t;
|
|
102
|
+
if (pageVisible() && t - lastInputAt < IDLE_AFTER_MS) activeMs += dt;
|
|
103
|
+
if (!finished && activeMs - reportedMs >= REPORT_EVERY_MS) reportProgress();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reportProgress() {
|
|
107
|
+
if (finished) return;
|
|
108
|
+
reportedMs = activeMs;
|
|
109
|
+
lastReportAt = now();
|
|
110
|
+
const update = pendingUpdate;
|
|
111
|
+
pendingUpdate = null;
|
|
112
|
+
post({ type: 'progress', ...update, activeMs: Math.round(activeMs) });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (hasWindow) {
|
|
116
|
+
const onInput = () => {
|
|
117
|
+
lastInputAt = now();
|
|
118
|
+
};
|
|
119
|
+
for (const type of ['pointerdown', 'pointermove', 'touchstart', 'touchmove', 'keydown', 'wheel']) {
|
|
120
|
+
window.addEventListener(type, onInput, { capture: true, passive: true });
|
|
121
|
+
}
|
|
122
|
+
setInterval(playTick, TICK_MS);
|
|
123
|
+
// Leaving (app backgrounded, tab hidden, page closing): report what we have.
|
|
124
|
+
if (typeof document !== 'undefined') {
|
|
125
|
+
document.addEventListener('visibilitychange', () => {
|
|
126
|
+
if (!pageVisible() && activeMs > reportedMs) reportProgress();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
window.addEventListener('pagehide', () => {
|
|
130
|
+
if (activeMs > reportedMs) reportProgress();
|
|
131
|
+
});
|
|
132
|
+
// Announce right away so the host knows this game reports its own play time.
|
|
133
|
+
setTimeout(() => reportProgress(), 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
62
136
|
// ── Host context ────────────────────────────────────────────────────────────
|
|
63
137
|
// Hosts that support multiplayer hand the game a context:
|
|
64
138
|
// { apiBase, game, playToken, platform, player: { displayName } }
|
|
@@ -451,7 +525,29 @@ export const MyVillageGame = {
|
|
|
451
525
|
* `durationMs` is added automatically.
|
|
452
526
|
*/
|
|
453
527
|
complete(result = {}) {
|
|
454
|
-
|
|
528
|
+
if (finished) return;
|
|
529
|
+
playTick();
|
|
530
|
+
finished = true;
|
|
531
|
+
post({
|
|
532
|
+
type: 'complete',
|
|
533
|
+
durationMs: Math.round(now() - startedAt),
|
|
534
|
+
activeMs: Math.round(activeMs),
|
|
535
|
+
...result,
|
|
536
|
+
});
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Optional: report how the game is going, e.g. the running score. Play time is
|
|
541
|
+
* reported automatically — you never need to call this for a play to count.
|
|
542
|
+
* { score?: number, custom?: object }
|
|
543
|
+
*/
|
|
544
|
+
progress(update = {}) {
|
|
545
|
+
pendingUpdate = { ...pendingUpdate, ...update };
|
|
546
|
+
// Safe to call every frame: sent at most every couple of seconds.
|
|
547
|
+
if (now() - lastReportAt >= 2000) {
|
|
548
|
+
playTick();
|
|
549
|
+
reportProgress();
|
|
550
|
+
}
|
|
455
551
|
},
|
|
456
552
|
|
|
457
553
|
/** Request to leave the game. The host also always offers an Exit control. */
|
|
@@ -67,6 +67,7 @@ A match starts by itself once `minPlayers` are connected. Until then players can
|
|
|
67
67
|
- Keep `MyVillageGame.ready()` in `src/main.js`.
|
|
68
68
|
- **Never send positions, hits or scores yourself.** Only `net.controls`. Anything else is ignored by the server, and it's the server's results that award MVT.
|
|
69
69
|
- **Don't call `MyVillageGame.complete()` when a match ends.** The platform records multiplayer results from the match server. Hosts treat `complete()` as "close the game", which would stop players from playing again.
|
|
70
|
+
- Plays count automatically: `src/myvillage-game.js` reports active play time to the host, and the platform counts a play after 30 seconds of it. Don't add anything for it.
|
|
70
71
|
- Players are identified only by display name and an in-match id. Don't ask for, show or store anything more about them.
|
|
71
72
|
- **Players only play with their own village.** The platform enforces it: codes only admit village-mates of the host, and quick match stays inside the player's villages. Don't build anything that suggests playing with strangers.
|
|
72
73
|
- Don't add an Exit button; the host provides one (top-left). The in-game **Leave** button just returns to the lobby.
|
|
@@ -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
|
])
|
package/src/commands/deploy.js
CHANGED
|
@@ -9,6 +9,7 @@ import { villageSpinner, brand } from '../utils/brand.js';
|
|
|
9
9
|
import { isAuthenticated } from '../utils/auth.js';
|
|
10
10
|
import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview, getGameDetail, updateGameMetadata } from '../utils/api.js';
|
|
11
11
|
import { preflightBundles } from '../utils/preflight.js';
|
|
12
|
+
import { refreshGameToolkit, gameCallsComplete } from '../utils/game-toolkit.js';
|
|
12
13
|
import { selectGameVillages } from '../utils/village-resolver.js';
|
|
13
14
|
|
|
14
15
|
function readPackageJson(dir) {
|
|
@@ -269,6 +270,21 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
|
|
|
269
270
|
const villageIds = await gatherVillages(pkg.myvillage.villages || [], options);
|
|
270
271
|
if (villageIds === null) return; // could not resolve villages — abort
|
|
271
272
|
|
|
273
|
+
// Ship the current platform toolkit (play-time reporting, host protocol),
|
|
274
|
+
// whatever CLI version the game was created with.
|
|
275
|
+
const toolkitUpdated = refreshGameToolkit(projectDir);
|
|
276
|
+
if (toolkitUpdated.length && !json) {
|
|
277
|
+
console.log(brand.teal(` Updated the MyVillage game toolkit (${toolkitUpdated.join(', ')})`));
|
|
278
|
+
}
|
|
279
|
+
// Multiplayer games never call complete() — the match server records results.
|
|
280
|
+
const warnings = [];
|
|
281
|
+
if (!multiplayer && !gameCallsComplete(projectDir)) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
"This game never calls MyVillageGame.complete(). Plays still count, but finishing the game won't record a completion or pay the player's reward. Call it on every way the game can end (win, lose, game over).",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (!json) for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
|
|
287
|
+
|
|
272
288
|
const spinner = json ? null : villageSpinner('Building game...').start();
|
|
273
289
|
|
|
274
290
|
const failOut = (msg) => {
|
|
@@ -360,6 +376,8 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
|
|
|
360
376
|
status: finalStatus,
|
|
361
377
|
title: metadata.title,
|
|
362
378
|
villageIds,
|
|
379
|
+
toolkitUpdated,
|
|
380
|
+
warnings,
|
|
363
381
|
}),
|
|
364
382
|
);
|
|
365
383
|
return;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join, dirname, extname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
// The platform files every JS game ships (see src/assets/game-toolkit). They're
|
|
6
|
+
// copied into a game at scaffold time, so without a refresh a game keeps the
|
|
7
|
+
// toolkit it was created with forever — and platform behaviour that lives in
|
|
8
|
+
// the toolkit (like reporting play time) never reaches it. `deploy` refreshes
|
|
9
|
+
// them, so every deploy from the CLI or MVP Studio ships the current toolkit.
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TOOLKIT_FILES = [
|
|
13
|
+
{
|
|
14
|
+
target: 'src/myvillage-game.js',
|
|
15
|
+
source: join(HERE, '..', 'assets', 'game-toolkit', 'myvillage-game.js'),
|
|
16
|
+
// Only replace a file that is still ours; a hand-rolled bridge is left alone.
|
|
17
|
+
isOurs: (text) => text.startsWith('// MyVillage Game Bridge'),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
target: 'src/myvillage-realtime.js',
|
|
21
|
+
source: join(HERE, '..', '..', 'vendor', 'realtime', 'myvillage-realtime-client.js'),
|
|
22
|
+
isOurs: (text) => text.startsWith('// myvillage-realtime client'),
|
|
23
|
+
// The bridge imports it lazily, so it must exist next to the bridge for the
|
|
24
|
+
// build to resolve, even in games scaffolded before multiplayer existed.
|
|
25
|
+
createIfMissing: true,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bring a game's platform files up to the toolkit bundled with this CLI.
|
|
31
|
+
* Returns the paths it changed (or, with `dryRun`, would change). Does nothing
|
|
32
|
+
* for a game without our bridge.
|
|
33
|
+
*/
|
|
34
|
+
export function refreshGameToolkit(projectDir, { dryRun = false } = {}) {
|
|
35
|
+
const bridge = join(projectDir, 'src/myvillage-game.js');
|
|
36
|
+
if (!existsSync(bridge) || !TOOLKIT_FILES[0].isOurs(readFileSync(bridge, 'utf8'))) return [];
|
|
37
|
+
|
|
38
|
+
const changed = [];
|
|
39
|
+
for (const file of TOOLKIT_FILES) {
|
|
40
|
+
const target = join(projectDir, file.target);
|
|
41
|
+
const latest = readFileSync(file.source, 'utf8');
|
|
42
|
+
if (existsSync(target)) {
|
|
43
|
+
const current = readFileSync(target, 'utf8');
|
|
44
|
+
if (current === latest || !file.isOurs(current)) continue;
|
|
45
|
+
} else if (!file.createIfMissing) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!dryRun) writeFileSync(target, latest);
|
|
49
|
+
changed.push(file.target);
|
|
50
|
+
}
|
|
51
|
+
return changed;
|
|
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
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the game's own code (not the toolkit) ever calls complete(). A game
|
|
83
|
+
* that never does can't record a finished game or pay its reward.
|
|
84
|
+
*/
|
|
85
|
+
export function gameCallsComplete(projectDir) {
|
|
86
|
+
const src = join(projectDir, 'src');
|
|
87
|
+
if (!existsSync(src)) return true; // not a toolkit game; nothing to judge
|
|
88
|
+
const toolkit = new Set(TOOLKIT_FILES.map((f) => join(projectDir, f.target)));
|
|
89
|
+
const stack = [src];
|
|
90
|
+
while (stack.length) {
|
|
91
|
+
const dir = stack.pop();
|
|
92
|
+
for (const name of readdirSync(dir)) {
|
|
93
|
+
const path = join(dir, name);
|
|
94
|
+
if (statSync(path).isDirectory()) {
|
|
95
|
+
stack.push(path);
|
|
96
|
+
} else if (['.js', '.mjs', '.ts', '.jsx', '.tsx'].includes(extname(name)) && !toolkit.has(path)) {
|
|
97
|
+
if (/MyVillageGame\s*\.\s*complete\s*\(/.test(readFileSync(path, 'utf8'))) return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
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';
|
|
@@ -1129,7 +1134,8 @@ function generate2DGameAgents(name, gameType, orientation) {
|
|
|
1129
1134
|
'The game talks to its host through `src/myvillage-game.js`:',
|
|
1130
1135
|
'',
|
|
1131
1136
|
'- `MyVillageGame.ready()` — called on load. Leave it.',
|
|
1132
|
-
'- `MyVillageGame.complete({ score, won })` — **required.** Called when the game ends (in `' + endFn + '`). It
|
|
1137
|
+
'- `MyVillageGame.complete({ score, won })` — **required.** Called when the game ends (in `' + endFn + '`). It records a completion and awards the player\'s reward. If you change the end flow, keep it wired to **every** way the game can end (win, lose, out of time).',
|
|
1138
|
+
'- **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.',
|
|
1133
1139
|
'',
|
|
1134
1140
|
'Do **not** add an Exit button — the host always provides its own.',
|
|
1135
1141
|
'',
|
|
@@ -1153,7 +1159,7 @@ function generate2DGameAgents(name, gameType, orientation) {
|
|
|
1153
1159
|
'',
|
|
1154
1160
|
'## Conventions',
|
|
1155
1161
|
'',
|
|
1156
|
-
'- 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.',
|
|
1157
1163
|
'- Vanilla JS, no framework — keep it dependency-light.',
|
|
1158
1164
|
'',
|
|
1159
1165
|
].join('\n');
|
|
@@ -1224,7 +1230,8 @@ function generate3DGameAgents(name, type, orientation) {
|
|
|
1224
1230
|
'## The MyVillage game contract — DO NOT BREAK',
|
|
1225
1231
|
'',
|
|
1226
1232
|
'- `MyVillageGame.ready()` — called after assets load in `src/main.js`. Leave it.',
|
|
1227
|
-
'- `this.game.endGame({ won, score })` — call at
|
|
1233
|
+
'- `this.game.endGame({ won, score })` — call at **every** win/lose condition. It calls `MyVillageGame.complete(...)`, which is **required**: it records a completion and awards the player\'s reward. An exploration game needs a real end (everything found, the exit reached) that calls it.',
|
|
1234
|
+
'- **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.',
|
|
1228
1235
|
'',
|
|
1229
1236
|
'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).',
|
|
1230
1237
|
'',
|
|
@@ -1246,12 +1253,108 @@ function generate3DGameAgents(name, type, orientation) {
|
|
|
1246
1253
|
'',
|
|
1247
1254
|
'## Conventions',
|
|
1248
1255
|
'',
|
|
1249
|
-
'- 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.',
|
|
1250
1257
|
'- The follow-camera and game loop already exist — extend them, do not rebuild.',
|
|
1251
1258
|
'',
|
|
1252
1259
|
].join('\n');
|
|
1253
1260
|
}
|
|
1254
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
|
+
|
|
1255
1358
|
function generateUnityAgents(name, className, orientation) {
|
|
1256
1359
|
const isLandscape = orientation === 'landscape';
|
|
1257
1360
|
const reach = isLandscape
|
|
@@ -1290,6 +1393,7 @@ function generateUnityAgents(name, className, orientation) {
|
|
|
1290
1393
|
'',
|
|
1291
1394
|
'- The host drives your mission and runs `OnBegin()`. In the editor, `MockMissionHost` stands in and logs host calls to the Console.',
|
|
1292
1395
|
'- **`CompleteMission(finalScore: n)`** — call when the player finishes. This is the native equivalent of the web games\' `MyVillageGame.complete()`; the host records the session and awards the player\'s reward. **Required to end the game properly.**',
|
|
1396
|
+
'- Plays count automatically: the host times how long the mission is on screen and the platform counts a play after 30 seconds. Call `CompleteMission` on every way the mission can end so finished games are recorded too.',
|
|
1293
1397
|
'- `FailMission(reason)` — the lose path. `Host.LogEvent("...")` — telemetry.',
|
|
1294
1398
|
'- **Do not build an Exit button** — the host always shows its own and calls `OnEnd()` for cleanup when the player exits.',
|
|
1295
1399
|
'',
|
|
@@ -1737,8 +1841,8 @@ function generatePackageJson(name, description, slug, type, ageGroup, orientatio
|
|
|
1737
1841
|
gameId: null,
|
|
1738
1842
|
gameType: type,
|
|
1739
1843
|
targetAge: ageGroup,
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
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.
|
|
1742
1846
|
orientation,
|
|
1743
1847
|
aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
|
|
1744
1848
|
// Declared up front so `myvillage deploy` ships them with the submission.
|
|
@@ -1886,7 +1990,12 @@ consistent experience:
|
|
|
1886
1990
|
- **Done / finished** — call \`MyVillageGame.complete({ score })\` when the game is
|
|
1887
1991
|
over. The host records the session and awards the player this game's MVT reward
|
|
1888
1992
|
(set by an admin when the game is published). The scaffold already calls this
|
|
1889
|
-
from \`Game.endGame()\` in \`src/main.js\` — wire that to
|
|
1993
|
+
from \`Game.endGame()\` in \`src/main.js\` — wire that to **every** way the game
|
|
1994
|
+
can end (win, lose, out of time).
|
|
1995
|
+
- **Plays count automatically** — \`src/myvillage-game.js\` measures how long the
|
|
1996
|
+
player is actively playing and the platform counts a play after 30 seconds of
|
|
1997
|
+
it. You don't need to do anything for that. To pass along a running score, call
|
|
1998
|
+
\`MyVillageGame.progress({ score })\` (optional; safe to call every frame).
|
|
1890
1999
|
- **Exit any time** — the host (the muni mobile app or the web player) always
|
|
1891
2000
|
shows an Exit control, so a player can leave at any moment. You don't build an
|
|
1892
2001
|
exit button. You *may* trigger \`MyVillageGame.exit()\` yourself (e.g. a "Quit"
|