@myvillage/cli 1.61.0 → 1.62.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/package.json +1 -1
- package/src/commands/create-game.js +37 -18
- package/src/commands/deploy.js +147 -40
- package/src/utils/templates.js +267 -21
- package/src/utils/village-resolver.js +65 -0
package/package.json
CHANGED
|
@@ -15,6 +15,21 @@ export async function createGameCommand(options = {}) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Orientation first — it decides where the game can play.
|
|
19
|
+
// Portrait → muni mobile app + web (mvp-video-games)
|
|
20
|
+
// Landscape → web only (mvp-video-games)
|
|
21
|
+
const { orientation } = await inquirer.prompt([
|
|
22
|
+
{
|
|
23
|
+
type: 'list',
|
|
24
|
+
name: 'orientation',
|
|
25
|
+
message: 'Where will this game play?',
|
|
26
|
+
choices: [
|
|
27
|
+
{ name: 'Portrait — muni mobile app + web (plays everywhere)', value: 'portrait' },
|
|
28
|
+
{ name: 'Landscape — web only (mvp-video-games)', value: 'landscape' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
]);
|
|
32
|
+
|
|
18
33
|
// Engine choice
|
|
19
34
|
const { engine } = await inquirer.prompt([
|
|
20
35
|
{
|
|
@@ -22,14 +37,19 @@ export async function createGameCommand(options = {}) {
|
|
|
22
37
|
name: 'engine',
|
|
23
38
|
message: 'Which engine are you using?',
|
|
24
39
|
choices: [
|
|
25
|
-
{ name: 'Three.js —
|
|
26
|
-
{
|
|
40
|
+
{ name: 'JS / Three.js — web build (runs in a browser / WebView)', value: 'threejs' },
|
|
41
|
+
{
|
|
42
|
+
name: orientation === 'landscape'
|
|
43
|
+
? 'Unity — WebGL build (runs in browser)'
|
|
44
|
+
: 'Unity — native build for muni (can also build WebGL for web)',
|
|
45
|
+
value: 'unity',
|
|
46
|
+
},
|
|
27
47
|
],
|
|
28
48
|
},
|
|
29
49
|
]);
|
|
30
50
|
|
|
31
51
|
if (engine === 'unity') {
|
|
32
|
-
return createUnityGame(options);
|
|
52
|
+
return createUnityGame({ ...options, orientation });
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
// ── Three.js path (existing flow) ──
|
|
@@ -91,6 +111,7 @@ export async function createGameCommand(options = {}) {
|
|
|
91
111
|
description: answers.description,
|
|
92
112
|
type: answers.type,
|
|
93
113
|
ageGroup: answers.ageGroup,
|
|
114
|
+
orientation,
|
|
94
115
|
});
|
|
95
116
|
|
|
96
117
|
spinner.text = 'Installing dependencies...';
|
|
@@ -112,7 +133,8 @@ export async function createGameCommand(options = {}) {
|
|
|
112
133
|
console.log(` ${brand.gold('npm run build')} ${brand.teal('- Build for production')}`);
|
|
113
134
|
console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
|
|
114
135
|
console.log();
|
|
115
|
-
console.log(brand.teal(` Game type: ${answers.type} | Age group: ${answers.ageGroup}`));
|
|
136
|
+
console.log(brand.teal(` Game type: ${answers.type} | Orientation: ${orientation} | Age group: ${answers.ageGroup}`));
|
|
137
|
+
console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
|
|
116
138
|
console.log();
|
|
117
139
|
} catch (err) {
|
|
118
140
|
spinner.fail('Failed to create game project.');
|
|
@@ -123,6 +145,9 @@ export async function createGameCommand(options = {}) {
|
|
|
123
145
|
// ── Unity path ──
|
|
124
146
|
|
|
125
147
|
async function createUnityGame(options = {}) {
|
|
148
|
+
// orientation is decided in the top-level wizard; default portrait for Unity.
|
|
149
|
+
const orientation = options.orientation || 'portrait';
|
|
150
|
+
|
|
126
151
|
const answers = await inquirer.prompt([
|
|
127
152
|
{
|
|
128
153
|
type: 'input',
|
|
@@ -152,15 +177,6 @@ async function createUnityGame(options = {}) {
|
|
|
152
177
|
{ name: 'Other', value: 'other' },
|
|
153
178
|
],
|
|
154
179
|
},
|
|
155
|
-
{
|
|
156
|
-
type: 'list',
|
|
157
|
-
name: 'platform',
|
|
158
|
-
message: 'Target platform:',
|
|
159
|
-
choices: [
|
|
160
|
-
{ name: 'Mobile (iOS / Android) — AssetBundle loaded by Izi Graham', value: 'mobile' },
|
|
161
|
-
{ name: 'WebGL — Runs in browser on M-UNI', value: 'webgl' },
|
|
162
|
-
],
|
|
163
|
-
},
|
|
164
180
|
{
|
|
165
181
|
type: 'list',
|
|
166
182
|
name: 'ageGroup',
|
|
@@ -190,7 +206,7 @@ async function createUnityGame(options = {}) {
|
|
|
190
206
|
description: answers.description,
|
|
191
207
|
type: answers.category,
|
|
192
208
|
ageGroup: answers.ageGroup,
|
|
193
|
-
|
|
209
|
+
orientation,
|
|
194
210
|
unityVersion: options.unityVersion,
|
|
195
211
|
});
|
|
196
212
|
|
|
@@ -200,6 +216,7 @@ async function createUnityGame(options = {}) {
|
|
|
200
216
|
console.log(brand.green(` \u2713 Unity game "${answers.name}" created!`));
|
|
201
217
|
console.log(brand.teal(` Unity: ${scaffold.unityVersion}`));
|
|
202
218
|
console.log(brand.teal(` GameKit SDK: ${scaffold.sdkVersion}`));
|
|
219
|
+
console.log(brand.teal(` Orientation: ${orientation} (${orientation === 'landscape' ? 'web only' : 'muni mobile + web'})`));
|
|
203
220
|
console.log();
|
|
204
221
|
console.log(brand.teal(' Next steps:'));
|
|
205
222
|
console.log();
|
|
@@ -209,14 +226,16 @@ async function createUnityGame(options = {}) {
|
|
|
209
226
|
console.log(` 4. ${brand.teal('Edit')} ${brand.gold(`Assets/Scripts/${answers.name.replace(/[^a-zA-Z0-9]/g, '')}.cs`)} ${brand.teal('and iterate (hit Play again)')}`);
|
|
210
227
|
console.log();
|
|
211
228
|
console.log(brand.teal(' Ready to ship:'));
|
|
212
|
-
if (
|
|
213
|
-
console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')}`);
|
|
214
|
-
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform ios')} ${brand.teal('(or android)')}`);
|
|
215
|
-
} else {
|
|
229
|
+
if (orientation === 'landscape') {
|
|
216
230
|
console.log(` 5. ${brand.teal('Build WebGL via')} ${brand.gold('File \u2192 Build Settings \u2192 WebGL \u2192 Build')} ${brand.teal('to')} ${brand.gold('builds/webgl/')}`);
|
|
217
231
|
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy --platform webgl')}`);
|
|
232
|
+
} else {
|
|
233
|
+
console.log(` 5. ${brand.teal('Build AssetBundle:')} ${brand.gold('MyVillage \u2192 Build Mission Bundle')} ${brand.teal('(into builds/ios or builds/android)')}`);
|
|
234
|
+
console.log(` 6. ${brand.gold('cd ' + slug + ' && myvillage deploy')} ${brand.teal('\u2014 ships every platform found in builds/')}`);
|
|
235
|
+
console.log(` ${brand.teal('Optional: also build WebGL into builds/webgl/ to offer it on the web too.')}`);
|
|
218
236
|
}
|
|
219
237
|
console.log();
|
|
238
|
+
console.log(brand.teal(` Cover art: drop thumbnail.png / banner.png in ${slug}/assets/ before deploy.`));
|
|
220
239
|
console.log(brand.teal(` See ${slug}/README.md for the full developer guide.`));
|
|
221
240
|
console.log();
|
|
222
241
|
} catch (err) {
|
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 } from '../utils/api.js';
|
|
11
11
|
import { preflightBundles } from '../utils/preflight.js';
|
|
12
|
+
import { selectGameVillages } from '../utils/village-resolver.js';
|
|
12
13
|
|
|
13
14
|
function readPackageJson(dir) {
|
|
14
15
|
const pkgPath = join(dir, 'package.json');
|
|
@@ -48,6 +49,34 @@ function saveGameIdToMyVillageJson(dir, gameId, slug) {
|
|
|
48
49
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// Persist the village selection so re-deploys pre-check the same villages.
|
|
53
|
+
function saveVillages(dir, isUnity, villageIds) {
|
|
54
|
+
if (!Array.isArray(villageIds)) return;
|
|
55
|
+
if (isUnity) {
|
|
56
|
+
const configPath = join(dir, 'myvillage.json');
|
|
57
|
+
if (!existsSync(configPath)) return;
|
|
58
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
59
|
+
config.villages = villageIds;
|
|
60
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
61
|
+
} else {
|
|
62
|
+
const pkgPath = join(dir, 'package.json');
|
|
63
|
+
if (!existsSync(pkgPath)) return;
|
|
64
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
65
|
+
pkg.myvillage = { ...pkg.myvillage, villages: villageIds };
|
|
66
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Gather the submission's village visibility once per deploy. Returns an array
|
|
71
|
+
// of village ids (possibly empty), or null if the developer aborted/failed —
|
|
72
|
+
// the caller should stop the deploy in that case.
|
|
73
|
+
async function gatherVillages(existingIds = []) {
|
|
74
|
+
console.log();
|
|
75
|
+
console.log(brand.teal(' Village visibility — who can see & play this game:'));
|
|
76
|
+
const villageIds = await selectGameVillages(existingIds);
|
|
77
|
+
return villageIds;
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
// Recursively collect file paths from a directory
|
|
52
81
|
function collectFilePaths(dir) {
|
|
53
82
|
const paths = [];
|
|
@@ -122,6 +151,11 @@ async function autoSubmitForReview(gameId, deployResult) {
|
|
|
122
151
|
// ── Three.js deploy (existing flow) ──
|
|
123
152
|
|
|
124
153
|
async function deployThreeJsGame(projectDir, pkg) {
|
|
154
|
+
// Village visibility is part of the submission — gather it up front (before
|
|
155
|
+
// the build) so it travels in the deploy metadata.
|
|
156
|
+
const villageIds = await gatherVillages(pkg.myvillage.villages || []);
|
|
157
|
+
if (villageIds === null) return; // could not resolve villages — abort
|
|
158
|
+
|
|
125
159
|
const spinner = villageSpinner('Building game...').start();
|
|
126
160
|
|
|
127
161
|
try {
|
|
@@ -155,7 +189,9 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
155
189
|
category: (pkg.myvillage.gameType || 'OTHER').toUpperCase(),
|
|
156
190
|
gameType: 'THREE_JS',
|
|
157
191
|
targetAge: pkg.myvillage.targetAge || 'all',
|
|
192
|
+
orientation: pkg.myvillage.orientation || 'landscape',
|
|
158
193
|
entryFile: 'index.html',
|
|
194
|
+
villageIds,
|
|
159
195
|
};
|
|
160
196
|
formData.append('metadata', JSON.stringify(metadata));
|
|
161
197
|
|
|
@@ -191,9 +227,11 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
191
227
|
if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
|
|
192
228
|
if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
193
229
|
|
|
194
|
-
|
|
230
|
+
saveVillages(projectDir, false, villageIds);
|
|
231
|
+
|
|
232
|
+
// Cover art is part of the submission: ship declared assets, require a thumbnail.
|
|
195
233
|
if (finalGameId) {
|
|
196
|
-
await
|
|
234
|
+
await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
console.log();
|
|
@@ -260,6 +298,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
260
298
|
}
|
|
261
299
|
|
|
262
300
|
console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
|
|
301
|
+
|
|
302
|
+
// Village visibility is part of the submission — gather once for the whole
|
|
303
|
+
// multi-platform deploy and thread it into every platform's metadata.
|
|
304
|
+
const villageIds = await gatherVillages(mvConfig.villages || []);
|
|
305
|
+
if (villageIds === null) return; // could not resolve villages — abort
|
|
263
306
|
console.log();
|
|
264
307
|
|
|
265
308
|
// Track final outcome across all platforms.
|
|
@@ -269,7 +312,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
269
312
|
let finalTitle = null;
|
|
270
313
|
|
|
271
314
|
for (const platform of platformsToDeploy) {
|
|
272
|
-
const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId });
|
|
315
|
+
const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId, villageIds });
|
|
273
316
|
if (!outcome.ok) return; // bail on first platform failure; nothing else makes sense to ship
|
|
274
317
|
finalGameId = outcome.gameId || finalGameId;
|
|
275
318
|
finalSlug = outcome.slug || finalSlug;
|
|
@@ -289,8 +332,11 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
289
332
|
if (finalSlug) console.log(brand.teal(` Slug: ${finalSlug}`));
|
|
290
333
|
if (finalGameId) console.log(brand.teal(` Game ID: ${finalGameId}`));
|
|
291
334
|
|
|
335
|
+
saveVillages(projectDir, true, villageIds);
|
|
336
|
+
|
|
337
|
+
// Cover art is part of the submission: ship declared assets, require a thumbnail.
|
|
292
338
|
if (finalGameId) {
|
|
293
|
-
await
|
|
339
|
+
await submitCoverArt(finalGameId, projectDir, mvConfig);
|
|
294
340
|
}
|
|
295
341
|
|
|
296
342
|
console.log();
|
|
@@ -302,7 +348,7 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
302
348
|
* Deploy one specific platform. Returns { ok, gameId, slug, status, title } or
|
|
303
349
|
* { ok: false } and writes a user-readable error to the console.
|
|
304
350
|
*/
|
|
305
|
-
async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride }) {
|
|
351
|
+
async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride, villageIds }) {
|
|
306
352
|
const buildDir = join(projectDir, 'builds', platform);
|
|
307
353
|
const filePaths = collectFilePaths(buildDir);
|
|
308
354
|
|
|
@@ -352,10 +398,12 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
|
|
|
352
398
|
category: (mvConfig.category || 'OTHER').toUpperCase(),
|
|
353
399
|
gameType,
|
|
354
400
|
targetAge: mvConfig.targetAge || 'all',
|
|
401
|
+
orientation: mvConfig.orientation || (platform === 'webgl' ? 'landscape' : 'portrait'),
|
|
355
402
|
buildPlatform: platform,
|
|
356
403
|
};
|
|
357
404
|
if (mvConfig.sdkVersion) metadata.sdkVersion = mvConfig.sdkVersion;
|
|
358
405
|
if (platform === 'webgl') metadata.entryFile = 'index.html';
|
|
406
|
+
if (Array.isArray(villageIds)) metadata.villageIds = villageIds;
|
|
359
407
|
|
|
360
408
|
formData.append('metadata', JSON.stringify(metadata));
|
|
361
409
|
|
|
@@ -465,49 +513,108 @@ async function uploadImageAsset(gameId, filePath, assetType) {
|
|
|
465
513
|
}
|
|
466
514
|
}
|
|
467
515
|
|
|
468
|
-
|
|
516
|
+
// Read PNG pixel dimensions from the IHDR chunk. Returns {width,height} or null
|
|
517
|
+
// for non-PNG / unreadable files (we only sanity-check PNGs, the scaffold default).
|
|
518
|
+
function readPngSize(filePath) {
|
|
519
|
+
try {
|
|
520
|
+
const buf = readFileSync(filePath);
|
|
521
|
+
// PNG signature + IHDR: width is big-endian uint32 at offset 16, height at 20.
|
|
522
|
+
if (buf.length < 24 || buf[0] !== 0x89 || buf[1] !== 0x50) return null;
|
|
523
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Warn (don't block) if a declared image's shape doesn't match the orientation.
|
|
530
|
+
function warnOnAspectMismatch(filePath, assetType, orientation) {
|
|
531
|
+
const size = readPngSize(filePath);
|
|
532
|
+
if (!size || !size.height) return;
|
|
533
|
+
const ratio = size.width / size.height;
|
|
534
|
+
if (assetType === 'BANNER') {
|
|
535
|
+
if (orientation === 'portrait' && ratio > 1) {
|
|
536
|
+
console.log(chalk.yellow(` ! banner looks landscape (${size.width}×${size.height}); portrait games want a tall 9:16 hero.`));
|
|
537
|
+
} else if (orientation === 'landscape' && ratio < 1) {
|
|
538
|
+
console.log(chalk.yellow(` ! banner looks portrait (${size.width}×${size.height}); landscape games want a wide 16:9 hero.`));
|
|
539
|
+
}
|
|
540
|
+
} else if (assetType === 'THUMBNAIL' && Math.abs(ratio - 1) > 0.2) {
|
|
541
|
+
console.log(chalk.yellow(` ! thumbnail isn't square (${size.width}×${size.height}); cards look best at 1:1.`));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Cover art shipped with the submission. Uploads whatever's declared in
|
|
546
|
+
// myvillage.assets first; only prompts for what's missing. A thumbnail is
|
|
547
|
+
// required — without one the game card renders blank in muni and on the web.
|
|
548
|
+
async function submitCoverArt(gameId, projectDir, config) {
|
|
549
|
+
const orientation = config?.orientation || 'landscape';
|
|
550
|
+
const declared = config?.assets || {};
|
|
469
551
|
console.log();
|
|
470
|
-
const { addThumbnail } = await inquirer.prompt([{
|
|
471
|
-
type: 'confirm',
|
|
472
|
-
name: 'addThumbnail',
|
|
473
|
-
message: 'Upload a thumbnail image? (shown on game cards)',
|
|
474
|
-
default: false,
|
|
475
|
-
}]);
|
|
476
552
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
553
|
+
// 1. Thumbnail — declared, else prompt (required).
|
|
554
|
+
let thumbUploaded = false;
|
|
555
|
+
if (declared.thumbnail) {
|
|
556
|
+
const p = resolve(projectDir, declared.thumbnail);
|
|
557
|
+
if (existsSync(p)) {
|
|
558
|
+
warnOnAspectMismatch(p, 'THUMBNAIL', orientation);
|
|
559
|
+
thumbUploaded = await uploadImageAsset(gameId, p, 'THUMBNAIL');
|
|
560
|
+
} else {
|
|
561
|
+
console.log(chalk.yellow(` ! Declared thumbnail not found: ${declared.thumbnail}`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (!thumbUploaded) {
|
|
565
|
+
thumbUploaded = await promptForImage(gameId, 'THUMBNAIL', orientation, { required: true });
|
|
489
566
|
}
|
|
490
567
|
|
|
491
|
-
|
|
568
|
+
// 2. Banner — declared, else optional prompt.
|
|
569
|
+
let bannerHandled = false;
|
|
570
|
+
if (declared.banner) {
|
|
571
|
+
const p = resolve(projectDir, declared.banner);
|
|
572
|
+
if (existsSync(p)) {
|
|
573
|
+
warnOnAspectMismatch(p, 'BANNER', orientation);
|
|
574
|
+
await uploadImageAsset(gameId, p, 'BANNER');
|
|
575
|
+
bannerHandled = true;
|
|
576
|
+
} else {
|
|
577
|
+
console.log(chalk.yellow(` ! Declared banner not found: ${declared.banner}`));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!bannerHandled) {
|
|
581
|
+
await promptForImage(gameId, 'BANNER', orientation, { required: false });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Prompt for a single image. `required` thumbnails warn loudly if skipped.
|
|
586
|
+
async function promptForImage(gameId, assetType, orientation, { required }) {
|
|
587
|
+
const label = assetType.toLowerCase();
|
|
588
|
+
const hint = assetType === 'THUMBNAIL'
|
|
589
|
+
? 'square, shown on game cards'
|
|
590
|
+
: (orientation === 'portrait' ? '9:16 hero for muni' : '16:9 featured hero');
|
|
591
|
+
const { add } = await inquirer.prompt([{
|
|
492
592
|
type: 'confirm',
|
|
493
|
-
name: '
|
|
494
|
-
message:
|
|
495
|
-
default:
|
|
593
|
+
name: 'add',
|
|
594
|
+
message: `Upload a ${label} image? (${hint})`,
|
|
595
|
+
default: required,
|
|
496
596
|
}]);
|
|
497
597
|
|
|
498
|
-
if (
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
validate: (input) => {
|
|
504
|
-
if (!input.trim()) return 'Path is required.';
|
|
505
|
-
if (!existsSync(resolve(input.trim()))) return 'File not found.';
|
|
506
|
-
return true;
|
|
507
|
-
},
|
|
508
|
-
}]);
|
|
509
|
-
await uploadImageAsset(gameId, bannerPath.trim(), 'BANNER');
|
|
598
|
+
if (!add) {
|
|
599
|
+
if (required) {
|
|
600
|
+
console.log(chalk.yellow(` ! No thumbnail — your game card will render blank until you add one with "myvillage game upload-thumbnail <file>".`));
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
510
603
|
}
|
|
604
|
+
|
|
605
|
+
const { imgPath } = await inquirer.prompt([{
|
|
606
|
+
type: 'input',
|
|
607
|
+
name: 'imgPath',
|
|
608
|
+
message: `Path to ${label} image:`,
|
|
609
|
+
validate: (input) => {
|
|
610
|
+
if (!input.trim()) return 'Path is required.';
|
|
611
|
+
if (!existsSync(resolve(input.trim()))) return 'File not found.';
|
|
612
|
+
return true;
|
|
613
|
+
},
|
|
614
|
+
}]);
|
|
615
|
+
const p = resolve(imgPath.trim());
|
|
616
|
+
warnOnAspectMismatch(p, assetType, orientation);
|
|
617
|
+
return uploadImageAsset(gameId, p, assetType);
|
|
511
618
|
}
|
|
512
619
|
|
|
513
620
|
function formatStatus(status) {
|
package/src/utils/templates.js
CHANGED
|
@@ -48,7 +48,7 @@ const BRAND = {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
export function createGameProject(targetDir, options) {
|
|
51
|
-
const { name, description, type, ageGroup } = options;
|
|
51
|
+
const { name, description, type, ageGroup, orientation = 'landscape' } = options;
|
|
52
52
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
53
53
|
|
|
54
54
|
// Create directory structure
|
|
@@ -59,6 +59,7 @@ export function createGameProject(targetDir, options) {
|
|
|
59
59
|
'public/assets/models',
|
|
60
60
|
'public/assets/textures',
|
|
61
61
|
'public/assets/audio',
|
|
62
|
+
'assets',
|
|
62
63
|
'src',
|
|
63
64
|
'src/scenes',
|
|
64
65
|
'src/components',
|
|
@@ -70,13 +71,15 @@ export function createGameProject(targetDir, options) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
// Write common files
|
|
73
|
-
writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup));
|
|
74
|
+
writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup, orientation));
|
|
74
75
|
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
75
76
|
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
76
77
|
writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
|
|
77
|
-
writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name));
|
|
78
|
+
writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name, orientation));
|
|
79
|
+
writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
|
|
78
80
|
|
|
79
81
|
// Write base source files
|
|
82
|
+
writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
|
|
80
83
|
writeFileSync(join(targetDir, 'src/main.js'), generateMainJs(type));
|
|
81
84
|
writeFileSync(join(targetDir, 'src/scenes/MainScene.js'), generateMainScene(type));
|
|
82
85
|
writeFileSync(join(targetDir, 'src/utils/InputManager.js'), generateInputManager());
|
|
@@ -103,10 +106,14 @@ export function createGameProject(targetDir, options) {
|
|
|
103
106
|
// ── Unity Game Project ─────────────────────────────────────────────
|
|
104
107
|
|
|
105
108
|
export async function createUnityGameProject(targetDir, options) {
|
|
106
|
-
const { name, description, type, ageGroup,
|
|
109
|
+
const { name, description, type, ageGroup, orientation = 'portrait', unityVersion: explicitUnityVersion } = options;
|
|
107
110
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
108
111
|
const className = name.replace(/[^a-zA-Z0-9]/g, '');
|
|
109
112
|
const unityVersion = explicitUnityVersion || await resolveUnityVersion();
|
|
113
|
+
// Portrait Unity ships a native AssetBundle for the muni mobile app (and can
|
|
114
|
+
// also build WebGL for the web). Landscape Unity is WebGL-only (web).
|
|
115
|
+
const isLandscape = orientation === 'landscape';
|
|
116
|
+
const primaryGameType = isLandscape ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
|
|
110
117
|
|
|
111
118
|
// Scaffold an openable Unity project that pulls com.myvillage.gamekit via UPM.
|
|
112
119
|
// Layout matches what Unity Hub expects:
|
|
@@ -119,8 +126,10 @@ export async function createUnityGameProject(targetDir, options) {
|
|
|
119
126
|
'',
|
|
120
127
|
'Assets',
|
|
121
128
|
'Assets/Scripts',
|
|
129
|
+
'Assets/Editor',
|
|
122
130
|
'Packages',
|
|
123
131
|
'ProjectSettings',
|
|
132
|
+
'assets',
|
|
124
133
|
'builds',
|
|
125
134
|
'builds/ios',
|
|
126
135
|
'builds/android',
|
|
@@ -137,9 +146,19 @@ export async function createUnityGameProject(targetDir, options) {
|
|
|
137
146
|
slug,
|
|
138
147
|
description,
|
|
139
148
|
engine: 'unity',
|
|
140
|
-
gameType:
|
|
149
|
+
gameType: primaryGameType,
|
|
141
150
|
category: type.toUpperCase(),
|
|
142
151
|
targetAge: ageGroup,
|
|
152
|
+
// Portrait → muni mobile + web; landscape → web only. Hosts frame on this.
|
|
153
|
+
orientation,
|
|
154
|
+
aspectRatio: isLandscape ? '16:9' : '9:16',
|
|
155
|
+
// Cover art shipped with the submission. Drop files at these paths.
|
|
156
|
+
assets: {
|
|
157
|
+
thumbnail: 'assets/thumbnail.png',
|
|
158
|
+
banner: 'assets/banner.png',
|
|
159
|
+
},
|
|
160
|
+
// Villages allowed to see & play the game. Populated by `myvillage deploy`.
|
|
161
|
+
villages: [],
|
|
143
162
|
sdkVersion: GAMEKIT_SDK_VERSION,
|
|
144
163
|
unityVersion,
|
|
145
164
|
gameId: null,
|
|
@@ -154,10 +173,56 @@ export async function createUnityGameProject(targetDir, options) {
|
|
|
154
173
|
writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs`), generateMissionScript(className, name, unityVersion));
|
|
155
174
|
writeFileSync(join(targetDir, `Assets/Scripts/${className}.cs.meta`), generateUnityScriptMeta());
|
|
156
175
|
|
|
176
|
+
// Editor script that pins PlayerSettings orientation so the project starts
|
|
177
|
+
// correct (portrait for muni) instead of Unity's landscape default — no more
|
|
178
|
+
// hand-converting like the legacy izi-graham project had to.
|
|
179
|
+
writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs'), generateOrientationEditorScript(orientation));
|
|
180
|
+
writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs.meta'), generateUnityScriptMeta());
|
|
181
|
+
|
|
182
|
+
writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
|
|
157
183
|
writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
|
|
158
|
-
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className,
|
|
184
|
+
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
|
|
159
185
|
|
|
160
|
-
return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION };
|
|
186
|
+
return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION, gameType: primaryGameType };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Editor script: applies orientation to PlayerSettings on first load so a
|
|
190
|
+
// freshly scaffolded project opens in the right orientation. Runs once per
|
|
191
|
+
// session (guarded by SessionState) and is safe to delete once set.
|
|
192
|
+
function generateOrientationEditorScript(orientation) {
|
|
193
|
+
const isPortrait = orientation !== 'landscape';
|
|
194
|
+
const defaultOrientation = isPortrait ? 'UIOrientation.Portrait' : 'UIOrientation.LandscapeLeft';
|
|
195
|
+
return `using UnityEditor;
|
|
196
|
+
using UnityEngine;
|
|
197
|
+
|
|
198
|
+
// Auto-applies this game's orientation (${orientation}) to PlayerSettings.
|
|
199
|
+
// Generated by the MyVillage CLI. Edit MyVillage > Apply Orientation to re-run.
|
|
200
|
+
[InitializeOnLoad]
|
|
201
|
+
public static class MyVillageOrientation
|
|
202
|
+
{
|
|
203
|
+
const string AppliedKey = "MyVillage.OrientationApplied";
|
|
204
|
+
|
|
205
|
+
static MyVillageOrientation()
|
|
206
|
+
{
|
|
207
|
+
if (!SessionState.GetBool(AppliedKey, false))
|
|
208
|
+
{
|
|
209
|
+
Apply();
|
|
210
|
+
SessionState.SetBool(AppliedKey, true);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
[MenuItem("MyVillage/Apply Orientation (${orientation})")]
|
|
215
|
+
public static void Apply()
|
|
216
|
+
{
|
|
217
|
+
PlayerSettings.defaultInterfaceOrientation = ${defaultOrientation};
|
|
218
|
+
PlayerSettings.allowedAutorotateToPortrait = ${isPortrait ? 'true' : 'false'};
|
|
219
|
+
PlayerSettings.allowedAutorotateToPortraitUpsideDown = false;
|
|
220
|
+
PlayerSettings.allowedAutorotateToLandscapeLeft = ${isPortrait ? 'false' : 'true'};
|
|
221
|
+
PlayerSettings.allowedAutorotateToLandscapeRight = ${isPortrait ? 'false' : 'true'};
|
|
222
|
+
Debug.Log("[MyVillage] Applied ${orientation} orientation to PlayerSettings.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
161
226
|
}
|
|
162
227
|
|
|
163
228
|
// ── Unity (GameKit SDK) ─────────────────────────────────────────────
|
|
@@ -204,6 +269,14 @@ using UnityEngine;
|
|
|
204
269
|
///
|
|
205
270
|
/// EDIT THIS SCRIPT: replace OnBegin with your game logic and call
|
|
206
271
|
/// CompleteMission(score) when the player finishes.
|
|
272
|
+
///
|
|
273
|
+
/// THE GAME CONTRACT (consistent across every MyVillage game):
|
|
274
|
+
/// • DONE — call CompleteMission(score) (or FailMission(reason)) so the host
|
|
275
|
+
/// knows the game is over. This is the native equivalent of the web
|
|
276
|
+
/// games' MyVillageGame.complete().
|
|
277
|
+
/// • EXIT — the M-UNI host always shows an Exit control, so players can leave
|
|
278
|
+
/// at any time. You don't build an exit button; the host owns it and
|
|
279
|
+
/// calls OnEnd() for cleanup when the player exits.
|
|
207
280
|
/// </summary>
|
|
208
281
|
public sealed class ${className} : MissionBase
|
|
209
282
|
{
|
|
@@ -272,11 +345,15 @@ function generateUnityGitignore() {
|
|
|
272
345
|
].join('\n') + '\n';
|
|
273
346
|
}
|
|
274
347
|
|
|
275
|
-
function generateUnityReadme(name, slug, className,
|
|
276
|
-
const
|
|
277
|
-
const
|
|
348
|
+
function generateUnityReadme(name, slug, className, orientation, unityVersion) {
|
|
349
|
+
const isLandscape = orientation === 'landscape';
|
|
350
|
+
const gameType = isLandscape ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
|
|
351
|
+
const reach = isLandscape
|
|
352
|
+
? 'web only (mvp-video-games)'
|
|
353
|
+
: 'the muni mobile app **and** web (mvp-video-games)';
|
|
354
|
+
const buildHint = isLandscape
|
|
278
355
|
? `4. Build to \`builds/webgl/\` via **File -> Build Settings -> WebGL**, then \`myvillage deploy --platform webgl\``
|
|
279
|
-
: `4. Build AssetBundles to \`builds/ios/\` or \`builds/android/\` via **MyVillage -> Build Mission Bundle**, then \`myvillage deploy --platform ios\` (or \`android\`)
|
|
356
|
+
: `4. Build AssetBundles to \`builds/ios/\` or \`builds/android/\` via **MyVillage -> Build Mission Bundle**, then \`myvillage deploy --platform ios\` (or \`android\`). To also offer this portrait game on the web, build WebGL into \`builds/webgl/\` and it ships under the same game.`;
|
|
280
357
|
|
|
281
358
|
return `# ${name}
|
|
282
359
|
|
|
@@ -284,6 +361,7 @@ A MyVillage game scaffolded with the MyVillage CLI and the MyVillage GameKit SDK
|
|
|
284
361
|
|
|
285
362
|
- **Slug:** \`${slug}\`
|
|
286
363
|
- **Game type:** \`${gameType}\`
|
|
364
|
+
- **Orientation:** \`${orientation}\` — plays on ${reach}
|
|
287
365
|
- **SDK:** \`com.myvillage.gamekit\` (pinned in \`Packages/manifest.json\`)
|
|
288
366
|
|
|
289
367
|
## What's in this project
|
|
@@ -337,7 +415,7 @@ For configurable missions (quiz questions, level layouts, etc.), create a \`Miss
|
|
|
337
415
|
|
|
338
416
|
// ── Three.js Templates ─────────────────────────────────────────────
|
|
339
417
|
|
|
340
|
-
function generatePackageJson(name, description, slug, type, ageGroup) {
|
|
418
|
+
function generatePackageJson(name, description, slug, type, ageGroup, orientation = 'landscape') {
|
|
341
419
|
return JSON.stringify({
|
|
342
420
|
name: slug,
|
|
343
421
|
version: '1.0.0',
|
|
@@ -360,11 +438,43 @@ function generatePackageJson(name, description, slug, type, ageGroup) {
|
|
|
360
438
|
gameId: null,
|
|
361
439
|
gameType: type,
|
|
362
440
|
targetAge: ageGroup,
|
|
441
|
+
// Portrait games run in the muni mobile app AND on the web (mvp-video-games).
|
|
442
|
+
// Landscape games run on the web only. Hosts read this to frame the game.
|
|
443
|
+
orientation,
|
|
444
|
+
aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
|
|
445
|
+
// Declared up front so `myvillage deploy` ships them with the submission.
|
|
446
|
+
// Drop files at these paths (relative to the project root).
|
|
447
|
+
assets: {
|
|
448
|
+
thumbnail: 'assets/thumbnail.png',
|
|
449
|
+
banner: 'assets/banner.png',
|
|
450
|
+
},
|
|
451
|
+
// Villages allowed to see & play the game. Populated by `myvillage deploy`.
|
|
452
|
+
villages: [],
|
|
363
453
|
lastDeployed: null,
|
|
364
454
|
},
|
|
365
455
|
}, null, 2) + '\n';
|
|
366
456
|
}
|
|
367
457
|
|
|
458
|
+
// Drop-zone guidance for cover art that ships with the submission.
|
|
459
|
+
function generateAssetsReadme(orientation) {
|
|
460
|
+
const thumb = orientation === 'portrait'
|
|
461
|
+
? 'Square (1:1, e.g. 512×512) — shown on game cards in muni and on the web.'
|
|
462
|
+
: 'Square (1:1, e.g. 512×512) — shown on game cards.';
|
|
463
|
+
const banner = orientation === 'portrait'
|
|
464
|
+
? 'Portrait hero (9:16, e.g. 1080×1920) — the full-bleed muni catalog hero.'
|
|
465
|
+
: 'Landscape hero (16:9, e.g. 1920×1080) — featured banner on the web.';
|
|
466
|
+
return `# Submission art
|
|
467
|
+
|
|
468
|
+
These files ship with \`myvillage deploy\` (declared in the \`myvillage.assets\` block of package.json).
|
|
469
|
+
|
|
470
|
+
- **thumbnail.png** — ${thumb}
|
|
471
|
+
- **banner.png** — ${banner}
|
|
472
|
+
|
|
473
|
+
Replace the placeholders before deploying. The CLI checks aspect ratio against
|
|
474
|
+
this game's orientation (\`${orientation}\`) and warns on a mismatch.
|
|
475
|
+
`;
|
|
476
|
+
}
|
|
477
|
+
|
|
368
478
|
function generateViteConfig() {
|
|
369
479
|
// base: './' makes the built HTML use relative asset URLs (e.g. ./assets/main.js
|
|
370
480
|
// instead of /assets/main.js). The MyVillage host serves games at
|
|
@@ -427,6 +537,7 @@ npm run preview
|
|
|
427
537
|
\`\`\`
|
|
428
538
|
src/
|
|
429
539
|
main.js - Game entry point, sets up Three.js renderer
|
|
540
|
+
myvillage-game.js - MyVillage game bridge (completion + exit contract)
|
|
430
541
|
scenes/
|
|
431
542
|
MainScene.js - Main Three.js scene with lighting and camera
|
|
432
543
|
components/
|
|
@@ -435,20 +546,47 @@ src/
|
|
|
435
546
|
utils/
|
|
436
547
|
InputManager.js - Keyboard, mouse, and touch input handling
|
|
437
548
|
AudioManager.js - Sound effects and background music
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
assets/ - Static assets (models, textures, audio)
|
|
549
|
+
index.html - HTML entry point
|
|
550
|
+
assets/ - Cover art that ships with your submission (thumbnail/banner)
|
|
441
551
|
\`\`\`
|
|
442
552
|
|
|
553
|
+
## The game contract (works the same in every MyVillage game)
|
|
554
|
+
|
|
555
|
+
Every MyVillage game — web or Unity — follows one contract so players have a
|
|
556
|
+
consistent experience:
|
|
557
|
+
|
|
558
|
+
- **Done / finished** — call \`MyVillageGame.complete({ score })\` when the game is
|
|
559
|
+
over. The host records the session and awards the player this game's MVT reward
|
|
560
|
+
(set by an admin when the game is published). The scaffold already calls this
|
|
561
|
+
from \`Game.endGame()\` in \`src/main.js\` — wire that to your real end condition.
|
|
562
|
+
- **Exit any time** — the host (the muni mobile app or the web player) always
|
|
563
|
+
shows an Exit control, so a player can leave at any moment. You don't build an
|
|
564
|
+
exit button. You *may* trigger \`MyVillageGame.exit()\` yourself (e.g. a "Quit"
|
|
565
|
+
menu item).
|
|
566
|
+
- **Ready** — \`MyVillageGame.ready()\` is called once your game is interactive
|
|
567
|
+
(already wired after asset loading).
|
|
568
|
+
|
|
569
|
+
\`\`\`js
|
|
570
|
+
import { MyVillageGame } from './myvillage-game.js';
|
|
571
|
+
|
|
572
|
+
// when the player wins / the round ends:
|
|
573
|
+
MyVillageGame.complete({ score: 1200, won: true });
|
|
574
|
+
\`\`\`
|
|
575
|
+
|
|
576
|
+
During \`npm run dev\` there's no host attached, so these calls just log to the
|
|
577
|
+
console — that's expected. They take effect once the game runs inside muni or the
|
|
578
|
+
web player.
|
|
579
|
+
|
|
443
580
|
## Deploying to MyVillageOS
|
|
444
581
|
|
|
445
|
-
|
|
582
|
+
Drop your cover art in \`assets/\` (thumbnail.png + banner.png), then:
|
|
446
583
|
|
|
447
584
|
\`\`\`bash
|
|
448
585
|
myvillage deploy
|
|
449
586
|
\`\`\`
|
|
450
587
|
|
|
451
|
-
|
|
588
|
+
Deploy walks you through village visibility (who can see & play it) and ships your
|
|
589
|
+
cover art with the submission. An admin sets the game's MVT reward at review time.
|
|
452
590
|
|
|
453
591
|
## Learn More
|
|
454
592
|
|
|
@@ -458,18 +596,31 @@ You'll earn MVT tokens for successful deployments!
|
|
|
458
596
|
`;
|
|
459
597
|
}
|
|
460
598
|
|
|
461
|
-
function generateIndexHtml(name) {
|
|
599
|
+
function generateIndexHtml(name, orientation = 'landscape') {
|
|
600
|
+
const isPortrait = orientation === 'portrait';
|
|
601
|
+
// Portrait games are framed by a 9:16 stage so they look right both in the
|
|
602
|
+
// muni mobile shell (portrait WebView) and on the web. Landscape fills the
|
|
603
|
+
// viewport as before.
|
|
604
|
+
const containerStyle = isPortrait
|
|
605
|
+
? `#game-container {
|
|
606
|
+
position: relative;
|
|
607
|
+
width: min(100vw, calc(100vh * 9 / 16));
|
|
608
|
+
height: min(100vh, calc(100vw * 16 / 9));
|
|
609
|
+
margin: 0 auto;
|
|
610
|
+
}`
|
|
611
|
+
: `#game-container { width: 100%; height: 100%; position: relative; }`;
|
|
462
612
|
return `<!DOCTYPE html>
|
|
463
613
|
<html lang="en">
|
|
464
614
|
<head>
|
|
465
615
|
<meta charset="UTF-8" />
|
|
466
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
616
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
617
|
+
<!-- orientation: ${orientation} -->
|
|
467
618
|
<title>${name}</title>
|
|
468
619
|
<style>
|
|
469
620
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
470
621
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
471
|
-
body { background: ${BRAND.darkBrown}; }
|
|
472
|
-
|
|
622
|
+
body { background: ${BRAND.darkBrown}; display: flex; align-items: center; justify-content: center; }
|
|
623
|
+
${containerStyle}
|
|
473
624
|
canvas { display: block; }
|
|
474
625
|
|
|
475
626
|
/* Loading screen */
|
|
@@ -532,6 +683,7 @@ import { InputManager } from './utils/InputManager.js';
|
|
|
532
683
|
import { AudioManager } from './utils/AudioManager.js';
|
|
533
684
|
import { GameLogic } from './components/GameLogic.js';
|
|
534
685
|
import { UI } from './components/UI.js';
|
|
686
|
+
import { MyVillageGame } from './myvillage-game.js';
|
|
535
687
|
|
|
536
688
|
class Game {
|
|
537
689
|
constructor() {
|
|
@@ -567,6 +719,8 @@ class Game {
|
|
|
567
719
|
this.state = 'menu';
|
|
568
720
|
this.hideLoadingScreen();
|
|
569
721
|
this.ui.showMenu();
|
|
722
|
+
// Tell the MyVillage host the game is loaded and interactive.
|
|
723
|
+
MyVillageGame.ready();
|
|
570
724
|
});
|
|
571
725
|
|
|
572
726
|
// Start game loop
|
|
@@ -620,6 +774,12 @@ class Game {
|
|
|
620
774
|
endGame(result) {
|
|
621
775
|
this.state = 'gameover';
|
|
622
776
|
this.ui.showGameOver(result);
|
|
777
|
+
// REQUIRED: tell the MyVillage host the game is finished. The host records
|
|
778
|
+
// the session, awards MVT, and returns the player to the catalog.
|
|
779
|
+
MyVillageGame.complete({
|
|
780
|
+
score: result?.score,
|
|
781
|
+
won: result?.won,
|
|
782
|
+
});
|
|
623
783
|
}
|
|
624
784
|
|
|
625
785
|
animate() {
|
|
@@ -644,6 +804,92 @@ window.game = game;
|
|
|
644
804
|
`;
|
|
645
805
|
}
|
|
646
806
|
|
|
807
|
+
// The universal MyVillage game contract (the "mvgame" protocol). Every
|
|
808
|
+
// scaffolded game ships this verbatim so completion + exit behave identically
|
|
809
|
+
// across the muni mobile app, the web (mvp-video-games), and standalone dev.
|
|
810
|
+
function generateMyVillageGameSdk() {
|
|
811
|
+
return `// MyVillage Game Bridge — the "mvgame" protocol (v1).
|
|
812
|
+
//
|
|
813
|
+
// ONE contract for every MyVillage game, on every surface:
|
|
814
|
+
// - muni mobile app (React Native WebView)
|
|
815
|
+
// - the web (mvp-video-games, in an iframe)
|
|
816
|
+
// - standalone dev (logs to the console)
|
|
817
|
+
//
|
|
818
|
+
// Two calls every game makes:
|
|
819
|
+
// MyVillageGame.ready() // once, when your game is interactive
|
|
820
|
+
// MyVillageGame.complete({ score }) // when the game is finished / won / over
|
|
821
|
+
//
|
|
822
|
+
// One the host ALWAYS provides, but you can trigger too:
|
|
823
|
+
// MyVillageGame.exit() // leave the game
|
|
824
|
+
//
|
|
825
|
+
// The host always renders an Exit control, so a player can leave ANY game at
|
|
826
|
+
// any time even if the game never calls exit() itself. Mirrors the native Unity
|
|
827
|
+
// contract (CompleteMission / abort) so there is a single mental model.
|
|
828
|
+
|
|
829
|
+
const PROTOCOL = 'mvgame';
|
|
830
|
+
const VERSION = 1;
|
|
831
|
+
|
|
832
|
+
function now() {
|
|
833
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function post(message) {
|
|
837
|
+
const payload = { source: PROTOCOL, version: VERSION, ...message };
|
|
838
|
+
const json = JSON.stringify(payload);
|
|
839
|
+
try {
|
|
840
|
+
// React Native WebView (muni mobile app)
|
|
841
|
+
if (typeof window !== 'undefined' && window.ReactNativeWebView) {
|
|
842
|
+
window.ReactNativeWebView.postMessage(json);
|
|
843
|
+
}
|
|
844
|
+
// Iframe parent (mvp-video-games web host) — only if actually embedded
|
|
845
|
+
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
846
|
+
window.parent.postMessage(payload, '*');
|
|
847
|
+
}
|
|
848
|
+
// Standalone dev: nothing is listening, so surface it for debugging.
|
|
849
|
+
if (typeof window !== 'undefined' && !window.ReactNativeWebView && window.parent === window) {
|
|
850
|
+
console.info('[mvgame] (no host attached) ' + json);
|
|
851
|
+
}
|
|
852
|
+
} catch (e) {
|
|
853
|
+
console.warn('[mvgame] postMessage failed', e);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let readySent = false;
|
|
858
|
+
let startedAt = now();
|
|
859
|
+
|
|
860
|
+
export const MyVillageGame = {
|
|
861
|
+
/** Call once when the game has loaded and is interactive. */
|
|
862
|
+
ready() {
|
|
863
|
+
if (readySent) return;
|
|
864
|
+
readySent = true;
|
|
865
|
+
startedAt = now();
|
|
866
|
+
post({ type: 'ready' });
|
|
867
|
+
},
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Call when the game is finished. \`result\` is optional:
|
|
871
|
+
* { score?: number, rewards?: number, won?: boolean, custom?: object }
|
|
872
|
+
* \`durationMs\` is added automatically.
|
|
873
|
+
*/
|
|
874
|
+
complete(result = {}) {
|
|
875
|
+
post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
/** Request to leave the game. The host also always offers an Exit control. */
|
|
879
|
+
exit() {
|
|
880
|
+
post({ type: 'exit' });
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// Expose globally too, for non-module games and quick debugging.
|
|
885
|
+
if (typeof window !== 'undefined') {
|
|
886
|
+
window.MyVillageGame = MyVillageGame;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export default MyVillageGame;
|
|
890
|
+
`;
|
|
891
|
+
}
|
|
892
|
+
|
|
647
893
|
function generateMainScene(type) {
|
|
648
894
|
return `// Main Three.js Scene
|
|
649
895
|
// Sets up camera, lighting, and the 3D environment.
|
|
@@ -9,6 +9,71 @@ import {
|
|
|
9
9
|
joinCommunity,
|
|
10
10
|
} from './api.js';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the authenticated developer's villager UUID from stored credentials,
|
|
14
|
+
* falling back to a lookup by villager_id. Returns the UUID or null.
|
|
15
|
+
*/
|
|
16
|
+
async function resolveVillagerUuid(creds) {
|
|
17
|
+
let villagerUuid = creds.villager_uuid;
|
|
18
|
+
if (!villagerUuid && creds.villager_id) {
|
|
19
|
+
const result = await getVillagerByVillagerId(creds.villager_id);
|
|
20
|
+
villagerUuid = result?.data?.id || result?.id;
|
|
21
|
+
}
|
|
22
|
+
return villagerUuid || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Villages the authenticated developer has access to. Returns [] on any failure
|
|
27
|
+
* so callers can degrade gracefully.
|
|
28
|
+
*/
|
|
29
|
+
export async function getMyVillages() {
|
|
30
|
+
const creds = loadCredentials();
|
|
31
|
+
if (!creds) return [];
|
|
32
|
+
const villagerUuid = await resolveVillagerUuid(creds);
|
|
33
|
+
if (!villagerUuid) return [];
|
|
34
|
+
const villagesResult = await getVillagerVillages(villagerUuid);
|
|
35
|
+
return villagesResult?.data || [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prompt the developer to pick which of their villages can see & play a game.
|
|
40
|
+
* Multi-select. `existingIds` pre-checks the previous selection on re-deploy.
|
|
41
|
+
*
|
|
42
|
+
* Returns an array of village ids, or null if villages could not be loaded
|
|
43
|
+
* (caller decides whether to proceed). An empty array means "no villages" — the
|
|
44
|
+
* developer isn't in any, so the game ships without village scoping.
|
|
45
|
+
*/
|
|
46
|
+
export async function selectGameVillages(existingIds = []) {
|
|
47
|
+
const spinner = villageSpinner('Loading your villages...').start();
|
|
48
|
+
let villages;
|
|
49
|
+
try {
|
|
50
|
+
villages = await getMyVillages();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
spinner.fail(`Could not load your villages: ${err.message}`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
spinner.stop();
|
|
56
|
+
|
|
57
|
+
if (!villages.length) {
|
|
58
|
+
console.log(chalk.yellow(' You are not a member of any villages — the game will be submitted without village scoping.'));
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { selected } = await inquirer.prompt([{
|
|
63
|
+
type: 'checkbox',
|
|
64
|
+
name: 'selected',
|
|
65
|
+
message: 'Which villages can see & play this game? (space to toggle, enter to confirm)',
|
|
66
|
+
choices: villages.map((v) => ({
|
|
67
|
+
name: `${v.name}${v.city ? ` (${v.city}${v.state ? ', ' + v.state : ''})` : ''}`,
|
|
68
|
+
value: v.id,
|
|
69
|
+
checked: existingIds.includes(v.id),
|
|
70
|
+
})),
|
|
71
|
+
validate: (ans) => (ans.length > 0 ? true : 'Select at least one village (space to toggle).'),
|
|
72
|
+
}]);
|
|
73
|
+
|
|
74
|
+
return selected;
|
|
75
|
+
}
|
|
76
|
+
|
|
12
77
|
/**
|
|
13
78
|
* Resolves a villager's village context: which village they're acting on behalf of
|
|
14
79
|
* and the corresponding MAN community.
|