@myvillage/cli 2.7.0 → 2.8.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.
@@ -24,7 +24,7 @@ function normEngine(v) {
24
24
  function normType(v) {
25
25
  if (!v) return null;
26
26
  v = String(v).toLowerCase();
27
- return ['quiz', 'exploration', 'narrative', 'custom'].includes(v) ? v : null;
27
+ return ['quiz', 'exploration', 'narrative', 'custom', 'arena'].includes(v) ? v : null;
28
28
  }
29
29
  function normAge(v) {
30
30
  if (!v) return null;
@@ -127,6 +127,7 @@ export async function createGameCommand(options = {}) {
127
127
  { name: 'Exploration Game - Navigate and discover', value: 'exploration' },
128
128
  { name: 'Narrative Game - Story-driven experience', value: 'narrative' },
129
129
  { name: 'Custom - Start with base Three.js template', value: 'custom' },
130
+ { name: 'Arena (multiplayer) - Real-time first-person tag / blaster matches', value: 'arena' },
130
131
  ],
131
132
  });
132
133
  if (needName)
@@ -213,6 +214,10 @@ export async function createGameCommand(options = {}) {
213
214
  console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
214
215
  console.log();
215
216
  console.log(brand.teal(` Game type: ${game.type} | Orientation: ${orientation} | Age group: ${game.ageGroup}`));
217
+ if (game.type === 'arena') {
218
+ console.log(brand.teal(' Multiplayer: open the dev server in two browser tabs and press Quick match in both.'));
219
+ console.log(brand.teal(' Match rules + map: src/arena.json (sent with every deploy).'));
220
+ }
216
221
  console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
217
222
  console.log();
218
223
  } catch (err) {
@@ -22,6 +22,42 @@ function readPackageJson(dir) {
22
22
  }
23
23
  }
24
24
 
25
+ const MAX_MULTIPLAYER_BYTES = 64 * 1024;
26
+
27
+ /**
28
+ * The game's real-time multiplayer config for the deploy metadata, from
29
+ * `myvillage.multiplayer` in package.json: `{ arena: 'src/arena.json' }` (a
30
+ * path) or `{ arena: {...} }` (inline). Returns undefined when the game doesn't
31
+ * declare one (the platform keeps its current setting), null to turn it off.
32
+ * Throws a readable error for a missing or malformed config.
33
+ */
34
+ export function readMultiplayerConfig(dir, mv) {
35
+ const setting = mv?.multiplayer;
36
+ if (setting === undefined) return undefined;
37
+ if (setting === null || setting === false) return null;
38
+ if (typeof setting !== 'object' || Array.isArray(setting)) {
39
+ throw new Error('myvillage.multiplayer must be an object like { "arena": "src/arena.json" }');
40
+ }
41
+ let arena = setting.arena ?? {};
42
+ if (typeof arena === 'string') {
43
+ const file = join(dir, arena);
44
+ if (!existsSync(file)) throw new Error(`myvillage.multiplayer.arena points to ${arena}, which doesn't exist`);
45
+ try {
46
+ arena = JSON.parse(readFileSync(file, 'utf-8'));
47
+ } catch (err) {
48
+ throw new Error(`${arena} is not valid JSON: ${err.message}`);
49
+ }
50
+ }
51
+ if (typeof arena !== 'object' || arena === null || Array.isArray(arena)) {
52
+ throw new Error('myvillage.multiplayer.arena must be a JSON object (or a path to one)');
53
+ }
54
+ const config = { arena };
55
+ if (JSON.stringify(config).length > MAX_MULTIPLAYER_BYTES) {
56
+ throw new Error('Multiplayer config is larger than 64 KB — simplify the map');
57
+ }
58
+ return config;
59
+ }
60
+
25
61
  function readMyVillageJson(dir) {
26
62
  const configPath = join(dir, 'myvillage.json');
27
63
  if (!existsSync(configPath)) return null;
@@ -217,6 +253,17 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
217
253
  // If this game is already live, confirm + unpublish before we rebuild/upload.
218
254
  if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId, options))) return;
219
255
 
256
+ // Multiplayer rules + map ship with the deploy; a broken config should fail
257
+ // before anything is built or uploaded.
258
+ let multiplayer;
259
+ try {
260
+ multiplayer = readMultiplayerConfig(projectDir, pkg.myvillage);
261
+ } catch (err) {
262
+ if (json) console.log(JSON.stringify({ ok: false, error: err.message }));
263
+ else console.log(chalk.red(`\n ✗ ${err.message}`));
264
+ return;
265
+ }
266
+
220
267
  // Village visibility is part of the submission — gather it up front (before
221
268
  // the build) so it travels in the deploy metadata.
222
269
  const villageIds = await gatherVillages(pkg.myvillage.villages || [], options);
@@ -263,6 +310,7 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
263
310
  orientation: pkg.myvillage.orientation || 'landscape',
264
311
  entryFile: 'index.html',
265
312
  villageIds,
313
+ ...(multiplayer !== undefined && { multiplayer }),
266
314
  };
267
315
  formData.append('metadata', JSON.stringify(metadata));
268
316
 
package/src/index.js CHANGED
@@ -215,7 +215,7 @@ export function run() {
215
215
  .option('--description <text>', 'Game description')
216
216
  .option('--engine <engine>', 'Engine: threejs | unity')
217
217
  .option('--orientation <orientation>', 'portrait | landscape')
218
- .option('--type <type>', 'Three.js type: quiz | exploration | narrative | custom')
218
+ .option('--type <type>', 'Three.js type: quiz | exploration | narrative | custom | arena (multiplayer)')
219
219
  .option('--category <category>', 'Unity category: education | action | adventure | puzzle | other')
220
220
  .option('--target-age <age>', 'Target age: under-5 | 6-12 | 13-and-over')
221
221
  .option('--dir <path>', 'Parent directory to scaffold into (defaults to cwd)')
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { fileURLToPath } from 'url';
@@ -10,6 +10,13 @@ import * as tar from 'tar';
10
10
  // installed globally via npm.
11
11
  const GAME_ART_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'game-art');
12
12
 
13
+ // Real source files (not strings) for the game toolkit and the arena template,
14
+ // so they can be linted and tested: src/assets/game-toolkit, src/assets/templates.
15
+ const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets');
16
+ // Multiplayer netcode, built from the myvillage-realtime repo
17
+ // (scripts/sync-realtime-client.sh refreshes it).
18
+ const REALTIME_CLIENT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'vendor', 'realtime', 'myvillage-realtime-client.js');
19
+
13
20
  // GameKit SDK version that scaffolded Unity projects pin to.
14
21
  // Bump alongside CLI minor releases as the SDK contract evolves.
15
22
  const GAMEKIT_SDK_VERSION = '1.1.0-alpha.5';
@@ -109,6 +116,11 @@ export function createGameProject(targetDir, options) {
109
116
  createNarrativeProject(targetDir, options);
110
117
  return;
111
118
  }
119
+ // Real-time multiplayer arena: its own scaffold (lobby + live match).
120
+ if (type === 'arena') {
121
+ createArenaProject(targetDir, options);
122
+ return;
123
+ }
112
124
 
113
125
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
114
126
 
@@ -142,7 +154,7 @@ export function createGameProject(targetDir, options) {
142
154
  copyTemplateArt(targetDir, type, orientation);
143
155
 
144
156
  // Write base source files
145
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
157
+ writeGameToolkit(targetDir);
146
158
  writeFileSync(join(targetDir, 'src/main.js'), generateMainJs(type));
147
159
  writeFileSync(join(targetDir, 'src/scenes/MainScene.js'), generateMainScene(type));
148
160
  writeFileSync(join(targetDir, 'src/utils/InputManager.js'), generateInputManager());
@@ -188,7 +200,7 @@ function createQuizProject(targetDir, options) {
188
200
  writeFileSync(join(targetDir, 'src/main.js'), generateQuizApp());
189
201
  writeFileSync(join(targetDir, 'src/questions.js'), generateQuizQuestions(name));
190
202
  writeFileSync(join(targetDir, 'src/styles.css'), generateQuizStyles());
191
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
203
+ writeGameToolkit(targetDir);
192
204
 
193
205
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
194
206
  copyTemplateArt(targetDir, 'quiz', orientation);
@@ -673,12 +685,61 @@ function createNarrativeProject(targetDir, options) {
673
685
  writeFileSync(join(targetDir, 'src/main.js'), generateNarrativeApp());
674
686
  writeFileSync(join(targetDir, 'src/story.js'), generateNarrativeStory(name));
675
687
  writeFileSync(join(targetDir, 'src/styles.css'), generateNarrativeStyles());
676
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
688
+ writeGameToolkit(targetDir);
677
689
 
678
690
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
679
691
  copyTemplateArt(targetDir, 'narrative', orientation);
680
692
  }
681
693
 
694
+ // ── Arena Project (real-time multiplayer) ──────────────────────────
695
+ // A first-person arena that plays live matches on MyVillage's match servers.
696
+ // The game owns its lobby (src/lobby.js); the platform plumbing lives in the
697
+ // toolkit. src/arena.json holds the match rules + map, which the game renders
698
+ // and `myvillage deploy` sends to the platform (myvillage.multiplayer.arena).
699
+ // Source files live in src/assets/templates/arena with {{PLACEHOLDERS}}.
700
+ function createArenaProject(targetDir, options) {
701
+ const { name, description, ageGroup, orientation = 'landscape' } = options;
702
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
703
+ const src = join(ASSETS_DIR, 'templates', 'arena');
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));
717
+
718
+ for (const dir of ['', 'src', 'assets', 'public']) {
719
+ mkdirSync(join(targetDir, dir), { recursive: true });
720
+ }
721
+
722
+ writeFileSync(join(targetDir, 'package.json'), generateArenaPackageJson(name, description, slug, ageGroup, orientation));
723
+ writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
724
+ writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
725
+ for (const file of ['index.html', 'README.md', 'AGENTS.md', 'src/main.js', 'src/lobby.js', 'src/arena-game.js', 'src/arena.json']) {
726
+ writeFileSync(join(targetDir, file), fill(file));
727
+ }
728
+ writeGameToolkit(targetDir);
729
+
730
+ writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
731
+ copyTemplateArt(targetDir, 'arena', orientation);
732
+ }
733
+
734
+ function generateArenaPackageJson(name, description, slug, ageGroup, orientation) {
735
+ const pkg = JSON.parse(generatePackageJson(name, description || 'A real-time multiplayer arena game.', slug, 'arena', ageGroup, orientation));
736
+ pkg.myvillage.category = 'action';
737
+ // Real-time multiplayer: the match rules + map `myvillage deploy` sends to
738
+ // the platform. A path to a JSON file, or the config inline.
739
+ pkg.myvillage.multiplayer = { arena: 'src/arena.json' };
740
+ return JSON.stringify(pkg, null, 2) + '\n';
741
+ }
742
+
682
743
  function generateNarrativeApp() {
683
744
  return `// Narrative Game — an interactive, choice-driven story.
684
745
  //
@@ -1059,6 +1120,7 @@ function generate2DGameAgents(name, gameType, orientation) {
1059
1120
  '| `' + contentFile + '` | **The content** — edit this first for content changes. |',
1060
1121
  '| `src/styles.css` | Styling (CSS variables in `:root`). |',
1061
1122
  '| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
1123
+ '| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
1062
1124
  '| `assets/` | Cover art shipped on deploy. |',
1063
1125
  '| `package.json` | The `myvillage` block holds metadata `myvillage deploy` reads. |',
1064
1126
  '',
@@ -1137,6 +1199,7 @@ function generate3DGameAgents(name, type, orientation) {
1137
1199
  '| `src/utils/InputManager.js` | Keyboard / mouse / touch + mobile joystick. |',
1138
1200
  '| `src/utils/AudioManager.js` | Sound effects + music (Web Audio). |',
1139
1201
  '| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
1202
+ '| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
1140
1203
  '| `assets/` | Cover art shipped on deploy. |',
1141
1204
  '| `public/assets/` | Drop 3D models / textures / audio here. |',
1142
1205
  '',
@@ -2100,87 +2163,16 @@ window.game = game;
2100
2163
  // The universal MyVillage game contract (the "mvgame" protocol). Every
2101
2164
  // scaffolded game ships this verbatim so completion + exit behave identically
2102
2165
  // across the muni mobile app, the web (mvp-video-games), and standalone dev.
2166
+ // The game toolkit every JS game gets: src/myvillage-game.js (the mvgame host
2167
+ // bridge + multiplayer plumbing) and src/myvillage-realtime.js (match netcode,
2168
+ // only loaded by games that call MyVillageGame.multiplayer()).
2103
2169
  function generateMyVillageGameSdk() {
2104
- return `// MyVillage Game Bridge — the "mvgame" protocol (v1).
2105
- //
2106
- // ONE contract for every MyVillage game, on every surface:
2107
- // - muni mobile app (React Native WebView)
2108
- // - the web (mvp-video-games, in an iframe)
2109
- // - standalone dev (logs to the console)
2110
- //
2111
- // Two calls every game makes:
2112
- // MyVillageGame.ready() // once, when your game is interactive
2113
- // MyVillageGame.complete({ score }) // when the game is finished / won / over
2114
- //
2115
- // One the host ALWAYS provides, but you can trigger too:
2116
- // MyVillageGame.exit() // leave the game
2117
- //
2118
- // The host always renders an Exit control, so a player can leave ANY game at
2119
- // any time even if the game never calls exit() itself. Mirrors the native Unity
2120
- // contract (CompleteMission / abort) so there is a single mental model.
2121
-
2122
- const PROTOCOL = 'mvgame';
2123
- const VERSION = 1;
2124
-
2125
- function now() {
2126
- return typeof performance !== 'undefined' ? performance.now() : Date.now();
2127
- }
2128
-
2129
- function post(message) {
2130
- const payload = { source: PROTOCOL, version: VERSION, ...message };
2131
- const json = JSON.stringify(payload);
2132
- try {
2133
- // React Native WebView (muni mobile app)
2134
- if (typeof window !== 'undefined' && window.ReactNativeWebView) {
2135
- window.ReactNativeWebView.postMessage(json);
2136
- }
2137
- // Iframe parent (mvp-video-games web host) — only if actually embedded
2138
- if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
2139
- window.parent.postMessage(payload, '*');
2140
- }
2141
- // Standalone dev: nothing is listening, so surface it for debugging.
2142
- if (typeof window !== 'undefined' && !window.ReactNativeWebView && window.parent === window) {
2143
- console.info('[mvgame] (no host attached) ' + json);
2144
- }
2145
- } catch (e) {
2146
- console.warn('[mvgame] postMessage failed', e);
2147
- }
2148
- }
2149
-
2150
- let readySent = false;
2151
- let startedAt = now();
2152
-
2153
- export const MyVillageGame = {
2154
- /** Call once when the game has loaded and is interactive. */
2155
- ready() {
2156
- if (readySent) return;
2157
- readySent = true;
2158
- startedAt = now();
2159
- post({ type: 'ready' });
2160
- },
2161
-
2162
- /**
2163
- * Call when the game is finished. \`result\` is optional:
2164
- * { score?: number, rewards?: number, won?: boolean, custom?: object }
2165
- * \`durationMs\` is added automatically.
2166
- */
2167
- complete(result = {}) {
2168
- post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
2169
- },
2170
-
2171
- /** Request to leave the game. The host also always offers an Exit control. */
2172
- exit() {
2173
- post({ type: 'exit' });
2174
- },
2175
- };
2176
-
2177
- // Expose globally too, for non-module games and quick debugging.
2178
- if (typeof window !== 'undefined') {
2179
- window.MyVillageGame = MyVillageGame;
2170
+ return readFileSync(join(ASSETS_DIR, 'game-toolkit', 'myvillage-game.js'), 'utf8');
2180
2171
  }
2181
2172
 
2182
- export default MyVillageGame;
2183
- `;
2173
+ function writeGameToolkit(targetDir) {
2174
+ writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
2175
+ writeFileSync(join(targetDir, 'src/myvillage-realtime.js'), readFileSync(REALTIME_CLIENT_PATH, 'utf8'));
2184
2176
  }
2185
2177
 
2186
2178
  function generateMainScene(type) {