@myvillage/cli 1.64.1 → 1.64.5
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/deploy.js +93 -26
- package/src/commands/game.js +9 -7
- package/src/index.js +1 -1
- package/src/utils/templates.js +611 -300
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -7,7 +7,7 @@ import axios from 'axios';
|
|
|
7
7
|
import inquirer from 'inquirer';
|
|
8
8
|
import { villageSpinner, brand } from '../utils/brand.js';
|
|
9
9
|
import { isAuthenticated } from '../utils/auth.js';
|
|
10
|
-
import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview } from '../utils/api.js';
|
|
10
|
+
import { deployGame, getGameUploadUrl, confirmGameUpload, submitGameForReview, getGameDetail, updateGameMetadata } from '../utils/api.js';
|
|
11
11
|
import { preflightBundles } from '../utils/preflight.js';
|
|
12
12
|
import { selectGameVillages } from '../utils/village-resolver.js';
|
|
13
13
|
|
|
@@ -77,6 +77,52 @@ async function gatherVillages(existingIds = []) {
|
|
|
77
77
|
return villageIds;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// If the target game is currently PUBLISHED, a redeploy takes it offline and
|
|
81
|
+
// sends the new build back through admin review. Confirm with the developer and
|
|
82
|
+
// move it to DRAFT first. Returns true to proceed with the deploy, false to abort.
|
|
83
|
+
async function confirmRedeployIfPublished(gameId) {
|
|
84
|
+
if (!gameId) return true; // brand-new game — nothing live to disturb
|
|
85
|
+
|
|
86
|
+
let game;
|
|
87
|
+
try {
|
|
88
|
+
const result = await getGameDetail(gameId);
|
|
89
|
+
game = result.game;
|
|
90
|
+
} catch {
|
|
91
|
+
return true; // can't determine status — let the deploy proceed and surface real errors
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!game || game.status !== 'PUBLISHED') return true;
|
|
95
|
+
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(chalk.yellow(` ! "${game.title}" is currently PUBLISHED (live).`));
|
|
98
|
+
console.log(brand.teal(' Redeploying takes it offline and sends the new build back through'));
|
|
99
|
+
console.log(brand.teal(' admin review before it can go live again.'));
|
|
100
|
+
console.log();
|
|
101
|
+
|
|
102
|
+
const { confirm } = await inquirer.prompt([{
|
|
103
|
+
type: 'confirm',
|
|
104
|
+
name: 'confirm',
|
|
105
|
+
message: 'Take the live game offline and redeploy?',
|
|
106
|
+
default: false,
|
|
107
|
+
}]);
|
|
108
|
+
|
|
109
|
+
if (!confirm) {
|
|
110
|
+
console.log(brand.teal('\n Deploy cancelled — game is still live.\n'));
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const spinner = villageSpinner('Taking game offline (returning to draft)...').start();
|
|
115
|
+
try {
|
|
116
|
+
await updateGameMetadata(gameId, { status: 'DRAFT' });
|
|
117
|
+
spinner.succeed('Game returned to draft.');
|
|
118
|
+
return true;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const message = err.response?.data?.error || err.message;
|
|
121
|
+
spinner.fail(`Could not unpublish: ${message}`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
// Recursively collect file paths from a directory
|
|
81
127
|
function collectFilePaths(dir) {
|
|
82
128
|
const paths = [];
|
|
@@ -151,6 +197,9 @@ async function autoSubmitForReview(gameId, deployResult) {
|
|
|
151
197
|
// ── Three.js deploy (existing flow) ──
|
|
152
198
|
|
|
153
199
|
async function deployThreeJsGame(projectDir, pkg) {
|
|
200
|
+
// If this game is already live, confirm + unpublish before we rebuild/upload.
|
|
201
|
+
if (!(await confirmRedeployIfPublished(pkg.myvillage.gameId))) return;
|
|
202
|
+
|
|
154
203
|
// Village visibility is part of the submission — gather it up front (before
|
|
155
204
|
// the build) so it travels in the deploy metadata.
|
|
156
205
|
const villageIds = await gatherVillages(pkg.myvillage.villages || []);
|
|
@@ -213,10 +262,22 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
213
262
|
|
|
214
263
|
const finalGameId = result.gameId || pkg.myvillage.gameId;
|
|
215
264
|
|
|
216
|
-
spinner.
|
|
265
|
+
spinner.succeed('Upload complete!');
|
|
266
|
+
|
|
267
|
+
saveVillages(projectDir, false, villageIds);
|
|
268
|
+
|
|
269
|
+
// Attach cover art before the final submit step. The deploy already moved the
|
|
270
|
+
// game to SUBMITTED; the platform lets the owner upload assets while a game is
|
|
271
|
+
// DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it), so this works
|
|
272
|
+
// for non-admin developers too — not just admins.
|
|
273
|
+
if (finalGameId) {
|
|
274
|
+
await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Safety net: promote to SUBMITTED if the deploy left the game in DRAFT.
|
|
278
|
+
// (A normal deploy already submits, so this is usually a no-op.)
|
|
217
279
|
const finalStatus = await autoSubmitForReview(finalGameId, result);
|
|
218
280
|
|
|
219
|
-
spinner.succeed('Deployment complete!');
|
|
220
281
|
console.log();
|
|
221
282
|
if (finalStatus === 'SUBMITTED') {
|
|
222
283
|
console.log(brand.green(` \u2713 Game "${metadata.title}" submitted for review`));
|
|
@@ -227,13 +288,6 @@ async function deployThreeJsGame(projectDir, pkg) {
|
|
|
227
288
|
if (result.slug) console.log(brand.teal(` Slug: ${result.slug}`));
|
|
228
289
|
if (result.gameId) console.log(brand.teal(` Game ID: ${result.gameId}`));
|
|
229
290
|
|
|
230
|
-
saveVillages(projectDir, false, villageIds);
|
|
231
|
-
|
|
232
|
-
// Cover art is part of the submission: ship declared assets, require a thumbnail.
|
|
233
|
-
if (finalGameId) {
|
|
234
|
-
await submitCoverArt(finalGameId, projectDir, pkg.myvillage);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
291
|
console.log();
|
|
238
292
|
console.log(brand.teal(' Run "myvillage status" to check the review status.'));
|
|
239
293
|
console.log();
|
|
@@ -299,6 +353,9 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
299
353
|
|
|
300
354
|
console.log(brand.teal(` Deploying ${platformsToDeploy.length} platform(s): ${platformsToDeploy.join(', ')}`));
|
|
301
355
|
|
|
356
|
+
// If this game is already live, confirm + unpublish before we upload new builds.
|
|
357
|
+
if (!(await confirmRedeployIfPublished(mvConfig.gameId))) return;
|
|
358
|
+
|
|
302
359
|
// Village visibility is part of the submission — gather once for the whole
|
|
303
360
|
// multi-platform deploy and thread it into every platform's metadata.
|
|
304
361
|
const villageIds = await gatherVillages(mvConfig.villages || []);
|
|
@@ -308,23 +365,39 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
308
365
|
// Track final outcome across all platforms.
|
|
309
366
|
let finalGameId = mvConfig.gameId || null;
|
|
310
367
|
let finalSlug = null;
|
|
311
|
-
let finalStatus = null;
|
|
312
368
|
let finalTitle = null;
|
|
369
|
+
let deployResultForSubmit = null;
|
|
313
370
|
|
|
314
371
|
for (const platform of platformsToDeploy) {
|
|
315
372
|
const outcome = await deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride: finalGameId, villageIds });
|
|
316
373
|
if (!outcome.ok) return; // bail on first platform failure; nothing else makes sense to ship
|
|
317
374
|
finalGameId = outcome.gameId || finalGameId;
|
|
318
375
|
finalSlug = outcome.slug || finalSlug;
|
|
319
|
-
finalStatus = outcome.status || finalStatus;
|
|
320
376
|
finalTitle = outcome.title || finalTitle;
|
|
377
|
+
deployResultForSubmit = outcome.deployResult || deployResultForSubmit;
|
|
321
378
|
// Reload mvConfig in case the first deploy stamped a new gameId into myvillage.json
|
|
322
379
|
if (outcome.gameId && !mvConfig.gameId) {
|
|
323
380
|
mvConfig.gameId = outcome.gameId;
|
|
324
381
|
}
|
|
325
382
|
}
|
|
326
383
|
|
|
327
|
-
|
|
384
|
+
saveVillages(projectDir, true, villageIds);
|
|
385
|
+
|
|
386
|
+
// Attach cover art before the final submit step. The deploy already moved the
|
|
387
|
+
// game to SUBMITTED; the platform lets the owner upload assets while a game is
|
|
388
|
+
// DRAFT/REJECTED/SUBMITTED (only APPROVED/PUBLISHED lock it). Submit is kept
|
|
389
|
+
// out of the per-platform loop so it runs once, after every platform is up.
|
|
390
|
+
if (finalGameId) {
|
|
391
|
+
await submitCoverArt(finalGameId, projectDir, mvConfig);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Safety net: promote to SUBMITTED if a platform deploy left it in DRAFT.
|
|
395
|
+
const finalStatus = await autoSubmitForReview(
|
|
396
|
+
finalGameId,
|
|
397
|
+
deployResultForSubmit || { status: 'DRAFT' },
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Single combined summary for the whole multi-platform deploy.
|
|
328
401
|
console.log();
|
|
329
402
|
console.log(brand.green(` \u2713 Game "${finalTitle}" deployed across ${platformsToDeploy.length} platform(s)`));
|
|
330
403
|
console.log(` Status: ${formatStatus(finalStatus)}`);
|
|
@@ -332,21 +405,15 @@ async function deployUnityGame(projectDir, mvConfig, options) {
|
|
|
332
405
|
if (finalSlug) console.log(brand.teal(` Slug: ${finalSlug}`));
|
|
333
406
|
if (finalGameId) console.log(brand.teal(` Game ID: ${finalGameId}`));
|
|
334
407
|
|
|
335
|
-
saveVillages(projectDir, true, villageIds);
|
|
336
|
-
|
|
337
|
-
// Cover art is part of the submission: ship declared assets, require a thumbnail.
|
|
338
|
-
if (finalGameId) {
|
|
339
|
-
await submitCoverArt(finalGameId, projectDir, mvConfig);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
408
|
console.log();
|
|
343
409
|
console.log(brand.teal(' Run "myvillage status" to check the review status.'));
|
|
344
410
|
console.log();
|
|
345
411
|
}
|
|
346
412
|
|
|
347
413
|
/**
|
|
348
|
-
* Deploy one specific platform. Returns { ok, gameId, slug,
|
|
349
|
-
* { ok: false } and writes a user-readable error to the console.
|
|
414
|
+
* Deploy one specific platform. Returns { ok, gameId, slug, deployResult, title }
|
|
415
|
+
* or { ok: false } and writes a user-readable error to the console. Submit-for-
|
|
416
|
+
* review is intentionally NOT done here — see deployUnityGame.
|
|
350
417
|
*/
|
|
351
418
|
async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverride, villageIds }) {
|
|
352
419
|
const buildDir = join(projectDir, 'builds', platform);
|
|
@@ -425,16 +492,16 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
|
|
|
425
492
|
|
|
426
493
|
const finalGameId = result.gameId || gameIdOverride || mvConfig.gameId;
|
|
427
494
|
|
|
428
|
-
spinner.text = `[${platform}] Submitting for review...`;
|
|
429
|
-
const finalStatus = await autoSubmitForReview(finalGameId, result);
|
|
430
|
-
|
|
431
495
|
spinner.succeed(`[${platform}] deployed (${gameType})`);
|
|
432
496
|
|
|
497
|
+
// Submit-for-review is deferred to deployUnityGame, AFTER cover art is
|
|
498
|
+
// uploaded — non-admin developers can't attach assets once a game is
|
|
499
|
+
// SUBMITTED, and re-submitting per platform would also be redundant.
|
|
433
500
|
return {
|
|
434
501
|
ok: true,
|
|
435
502
|
gameId: finalGameId,
|
|
436
503
|
slug: result.slug,
|
|
437
|
-
|
|
504
|
+
deployResult: result,
|
|
438
505
|
title: metadata.title,
|
|
439
506
|
};
|
|
440
507
|
} catch (err) {
|
package/src/commands/game.js
CHANGED
|
@@ -693,22 +693,24 @@ export async function gameDraftCommand() {
|
|
|
693
693
|
return;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
-
|
|
697
|
-
console.log(chalk.red(`\n ✗ "${game.title}" is PUBLISHED and cannot be returned to draft.\n`));
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
696
|
+
const isPublished = game.status === 'PUBLISHED';
|
|
700
697
|
|
|
701
698
|
console.log();
|
|
702
699
|
console.log(brand.gold(` Return "${game.title}" to DRAFT?`));
|
|
703
700
|
console.log(brand.teal(` Current status: ${game.status}`));
|
|
704
|
-
|
|
701
|
+
if (isPublished) {
|
|
702
|
+
console.log(chalk.yellow(' ! This game is LIVE. Unpublishing takes it offline immediately, and'));
|
|
703
|
+
console.log(chalk.yellow(' it must be re-reviewed by an admin before it can be published again.'));
|
|
704
|
+
} else {
|
|
705
|
+
console.log(brand.teal(' This will withdraw the game from review so you can make changes.'));
|
|
706
|
+
}
|
|
705
707
|
console.log();
|
|
706
708
|
|
|
707
709
|
const { confirm } = await inquirer.prompt([{
|
|
708
710
|
type: 'confirm',
|
|
709
711
|
name: 'confirm',
|
|
710
|
-
message: 'Return to draft?',
|
|
711
|
-
default:
|
|
712
|
+
message: isPublished ? 'Take the live game offline?' : 'Return to draft?',
|
|
713
|
+
default: !isPublished,
|
|
712
714
|
}]);
|
|
713
715
|
|
|
714
716
|
if (!confirm) {
|
package/src/index.js
CHANGED
|
@@ -225,7 +225,7 @@ export function run() {
|
|
|
225
225
|
|
|
226
226
|
gameCmd
|
|
227
227
|
.command('draft')
|
|
228
|
-
.description('Return a game to DRAFT
|
|
228
|
+
.description('Return a game to DRAFT (withdraw from review, or unpublish a live game)')
|
|
229
229
|
.action(gameDraftCommand);
|
|
230
230
|
|
|
231
231
|
// Game missions subcommands
|
package/src/utils/templates.js
CHANGED
|
@@ -56,13 +56,17 @@ const BRAND = {
|
|
|
56
56
|
export function createGameProject(targetDir, options) {
|
|
57
57
|
const { name, description, type, ageGroup, orientation = 'landscape' } = options;
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// 3D shell the exploration/
|
|
59
|
+
// Quiz and narrative are 2D UI experiences, not 3D worlds — they get dedicated
|
|
60
|
+
// DOM/CSS scaffolds (no Three.js, no renderer, no game loop) instead of the
|
|
61
|
+
// shared 3D shell the exploration/custom types use.
|
|
62
62
|
if (type === 'quiz') {
|
|
63
63
|
createQuizProject(targetDir, options);
|
|
64
64
|
return;
|
|
65
65
|
}
|
|
66
|
+
if (type === 'narrative') {
|
|
67
|
+
createNarrativeProject(targetDir, options);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
66
70
|
|
|
67
71
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
68
72
|
|
|
@@ -90,6 +94,7 @@ export function createGameProject(targetDir, options) {
|
|
|
90
94
|
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
91
95
|
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
92
96
|
writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
|
|
97
|
+
writeFileSync(join(targetDir, 'AGENTS.md'), generate3DGameAgents(name, type, orientation));
|
|
93
98
|
writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name, orientation));
|
|
94
99
|
writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
|
|
95
100
|
copyTemplateArt(targetDir, type, orientation);
|
|
@@ -102,17 +107,13 @@ export function createGameProject(targetDir, options) {
|
|
|
102
107
|
writeFileSync(join(targetDir, 'src/utils/AudioManager.js'), generateAudioManager());
|
|
103
108
|
writeFileSync(join(targetDir, 'src/components/UI.js'), generateUI(type));
|
|
104
109
|
|
|
105
|
-
// Write game-type-specific
|
|
110
|
+
// Write the game-type-specific game logic. Quiz and narrative are handled
|
|
111
|
+
// earlier by their own 2D scaffolds, so only exploration and custom (default)
|
|
112
|
+
// reach this 3D path.
|
|
106
113
|
switch (type) {
|
|
107
|
-
case 'quiz':
|
|
108
|
-
writeFileSync(join(targetDir, 'src/components/GameLogic.js'), generateQuizGameLogic());
|
|
109
|
-
break;
|
|
110
114
|
case 'exploration':
|
|
111
115
|
writeFileSync(join(targetDir, 'src/components/GameLogic.js'), generateExplorationGameLogic());
|
|
112
116
|
break;
|
|
113
|
-
case 'narrative':
|
|
114
|
-
writeFileSync(join(targetDir, 'src/components/GameLogic.js'), generateNarrativeGameLogic());
|
|
115
|
-
break;
|
|
116
117
|
default:
|
|
117
118
|
writeFileSync(join(targetDir, 'src/components/GameLogic.js'), generateBaseGameLogic());
|
|
118
119
|
break;
|
|
@@ -135,11 +136,12 @@ function createQuizProject(targetDir, options) {
|
|
|
135
136
|
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
writeFileSync(join(targetDir, 'package.json'),
|
|
139
|
+
writeFileSync(join(targetDir, 'package.json'), generate2DGamePackageJson(name, description, slug, ageGroup, orientation, 'quiz'));
|
|
139
140
|
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
140
141
|
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
141
|
-
writeFileSync(join(targetDir, 'index.html'),
|
|
142
|
+
writeFileSync(join(targetDir, 'index.html'), generate2DGameIndexHtml(name, orientation));
|
|
142
143
|
writeFileSync(join(targetDir, 'README.md'), generateQuizReadme(name, description, ageGroup));
|
|
144
|
+
writeFileSync(join(targetDir, 'AGENTS.md'), generate2DGameAgents(name, 'quiz', orientation));
|
|
143
145
|
|
|
144
146
|
writeFileSync(join(targetDir, 'src/main.js'), generateQuizApp());
|
|
145
147
|
writeFileSync(join(targetDir, 'src/questions.js'), generateQuizQuestions(name));
|
|
@@ -150,7 +152,9 @@ function createQuizProject(targetDir, options) {
|
|
|
150
152
|
copyTemplateArt(targetDir, 'quiz', orientation);
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
// Shared by the 2D game types (quiz, narrative) — a plain Vite project with no
|
|
156
|
+
// Three.js. `gameType` goes into the myvillage block the deploy command reads.
|
|
157
|
+
function generate2DGamePackageJson(name, description, slug, ageGroup, orientation, gameType) {
|
|
154
158
|
return JSON.stringify({
|
|
155
159
|
name: slug,
|
|
156
160
|
version: '1.0.0',
|
|
@@ -162,14 +166,14 @@ function generateQuizPackageJson(name, description, slug, ageGroup, orientation)
|
|
|
162
166
|
build: 'vite build',
|
|
163
167
|
preview: 'vite preview',
|
|
164
168
|
},
|
|
165
|
-
// No Three.js —
|
|
169
|
+
// No Three.js — these are pure DOM/CSS games.
|
|
166
170
|
devDependencies: {
|
|
167
171
|
vite: '^5.0.0',
|
|
168
172
|
},
|
|
169
173
|
myvillage: {
|
|
170
174
|
name: name,
|
|
171
175
|
gameId: null,
|
|
172
|
-
gameType
|
|
176
|
+
gameType,
|
|
173
177
|
targetAge: ageGroup,
|
|
174
178
|
orientation,
|
|
175
179
|
aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
|
|
@@ -183,7 +187,8 @@ function generateQuizPackageJson(name, description, slug, ageGroup, orientation)
|
|
|
183
187
|
}, null, 2) + '\n';
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
|
|
190
|
+
// Shared by the 2D game types — links styles.css + main.js into a #app root.
|
|
191
|
+
function generate2DGameIndexHtml(name, orientation) {
|
|
187
192
|
return `<!DOCTYPE html>
|
|
188
193
|
<html lang="en">
|
|
189
194
|
<head>
|
|
@@ -593,6 +598,595 @@ myvillage deploy
|
|
|
593
598
|
`;
|
|
594
599
|
}
|
|
595
600
|
|
|
601
|
+
// ── Narrative Game Project (2D) ────────────────────────────────────
|
|
602
|
+
// A story game is text + choices, so this scaffold is plain DOM/CSS — no
|
|
603
|
+
// Three.js. Devs edit src/story.js (the branching scenes) and src/styles.css
|
|
604
|
+
// (the look). src/main.js runs the story and reports the result through the
|
|
605
|
+
// shared MyVillage game contract.
|
|
606
|
+
function createNarrativeProject(targetDir, options) {
|
|
607
|
+
const { name, description, ageGroup, orientation = 'portrait' } = options;
|
|
608
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
609
|
+
|
|
610
|
+
const dirs = ['', 'src', 'assets', 'public'];
|
|
611
|
+
for (const dir of dirs) {
|
|
612
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
writeFileSync(join(targetDir, 'package.json'), generate2DGamePackageJson(name, description, slug, ageGroup, orientation, 'narrative'));
|
|
616
|
+
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
617
|
+
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
618
|
+
writeFileSync(join(targetDir, 'index.html'), generate2DGameIndexHtml(name, orientation));
|
|
619
|
+
writeFileSync(join(targetDir, 'README.md'), generateNarrativeReadme(name, description, ageGroup));
|
|
620
|
+
writeFileSync(join(targetDir, 'AGENTS.md'), generate2DGameAgents(name, 'narrative', orientation));
|
|
621
|
+
|
|
622
|
+
writeFileSync(join(targetDir, 'src/main.js'), generateNarrativeApp());
|
|
623
|
+
writeFileSync(join(targetDir, 'src/story.js'), generateNarrativeStory(name));
|
|
624
|
+
writeFileSync(join(targetDir, 'src/styles.css'), generateNarrativeStyles());
|
|
625
|
+
writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
|
|
626
|
+
|
|
627
|
+
writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
|
|
628
|
+
copyTemplateArt(targetDir, 'narrative', orientation);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function generateNarrativeApp() {
|
|
632
|
+
return `// Narrative Game — an interactive, choice-driven story.
|
|
633
|
+
//
|
|
634
|
+
// Edit your story in src/story.js (scenes, speakers, choices) and the look in
|
|
635
|
+
// src/styles.css. This file runs the story: it shows the start screen, each
|
|
636
|
+
// scene with its choices, branches on what the player picks, and an ending —
|
|
637
|
+
// then reports the result to the MyVillage host.
|
|
638
|
+
|
|
639
|
+
import { MyVillageGame } from './myvillage-game.js';
|
|
640
|
+
import { storyConfig, scenes } from './story.js';
|
|
641
|
+
|
|
642
|
+
class NarrativeGame {
|
|
643
|
+
constructor(root) {
|
|
644
|
+
this.root = root;
|
|
645
|
+
this.index = 0;
|
|
646
|
+
this.choicesMade = 0;
|
|
647
|
+
|
|
648
|
+
this.renderStart();
|
|
649
|
+
|
|
650
|
+
// Tell the MyVillage host the game is loaded and interactive.
|
|
651
|
+
MyVillageGame.ready();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
renderStart() {
|
|
655
|
+
this.root.innerHTML = \`
|
|
656
|
+
<main class="screen">
|
|
657
|
+
<div class="card start-card">
|
|
658
|
+
<h1 class="title">\${storyConfig.title}</h1>
|
|
659
|
+
<p class="intro">\${storyConfig.intro}</p>
|
|
660
|
+
<button class="btn primary" id="begin-btn">Begin</button>
|
|
661
|
+
</div>
|
|
662
|
+
</main>
|
|
663
|
+
\`;
|
|
664
|
+
this.root.querySelector('#begin-btn').addEventListener('click', () => this.begin());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
begin() {
|
|
668
|
+
this.index = storyConfig.startScene || 0;
|
|
669
|
+
this.choicesMade = 0;
|
|
670
|
+
this.renderScene();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
renderScene() {
|
|
674
|
+
const scene = scenes[this.index];
|
|
675
|
+
if (!scene) {
|
|
676
|
+
this.renderEnding();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const hasChoices = Array.isArray(scene.choices) && scene.choices.length > 0;
|
|
681
|
+
const buttons = hasChoices
|
|
682
|
+
? scene.choices.map((c, i) => \`<button class="btn choice" data-index="\${i}">\${c.text}</button>\`).join('')
|
|
683
|
+
: \`<button class="btn primary" id="end-btn">The End</button>\`;
|
|
684
|
+
|
|
685
|
+
this.root.innerHTML = \`
|
|
686
|
+
<main class="screen">
|
|
687
|
+
<div class="card scene-card">
|
|
688
|
+
\${scene.speaker ? \`<p class="speaker">\${scene.speaker}</p>\` : ''}
|
|
689
|
+
<p class="scene-text">\${scene.text}</p>
|
|
690
|
+
<div class="choices">\${buttons}</div>
|
|
691
|
+
</div>
|
|
692
|
+
</main>
|
|
693
|
+
\`;
|
|
694
|
+
|
|
695
|
+
if (hasChoices) {
|
|
696
|
+
this.root.querySelectorAll('.choice').forEach((btn) => {
|
|
697
|
+
btn.addEventListener('click', () => this.choose(Number(btn.dataset.index)));
|
|
698
|
+
});
|
|
699
|
+
} else {
|
|
700
|
+
this.root.querySelector('#end-btn').addEventListener('click', () => this.renderEnding());
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
choose(choiceIndex) {
|
|
705
|
+
const scene = scenes[this.index];
|
|
706
|
+
const choice = scene.choices[choiceIndex];
|
|
707
|
+
this.choicesMade++;
|
|
708
|
+
// Jump to the linked scene. An undefined / out-of-range next ends the story.
|
|
709
|
+
this.index = choice && choice.next !== undefined ? choice.next : -1;
|
|
710
|
+
this.renderScene();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
renderEnding() {
|
|
714
|
+
this.root.innerHTML = \`
|
|
715
|
+
<main class="screen">
|
|
716
|
+
<div class="card results-card">
|
|
717
|
+
<h2 class="title">The End</h2>
|
|
718
|
+
<p class="intro">You made \${this.choicesMade} choice\${this.choicesMade === 1 ? '' : 's'} along the way.</p>
|
|
719
|
+
<button class="btn primary" id="again-btn">Play Again</button>
|
|
720
|
+
</div>
|
|
721
|
+
</main>
|
|
722
|
+
\`;
|
|
723
|
+
this.root.querySelector('#again-btn').addEventListener('click', () => this.begin());
|
|
724
|
+
|
|
725
|
+
// REQUIRED: tell the MyVillage host the story is finished. The host records
|
|
726
|
+
// the session, awards this game's MVT reward, and returns the player to the
|
|
727
|
+
// catalog. During \`npm run dev\` there's no host, so this just logs.
|
|
728
|
+
MyVillageGame.complete({ won: true, score: this.choicesMade, custom: { choices: this.choicesMade } });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
new NarrativeGame(document.getElementById('app'));
|
|
733
|
+
`;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function generateNarrativeStory(name) {
|
|
737
|
+
return `// Story content — edit this file to write your own branching narrative.
|
|
738
|
+
//
|
|
739
|
+
// Each scene has an optional \`speaker\`, the \`text\` to show, and \`choices\`.
|
|
740
|
+
// Each choice has \`text\` and \`next\`: the index of the scene to jump to.
|
|
741
|
+
// Set \`choices: null\` (or omit it) to mark a scene as the end of the story.
|
|
742
|
+
|
|
743
|
+
export const storyConfig = {
|
|
744
|
+
// Shown on the start screen.
|
|
745
|
+
title: ${JSON.stringify(name)},
|
|
746
|
+
intro: 'An interactive story — your choices shape what happens next.',
|
|
747
|
+
// Index of the scene the story opens on.
|
|
748
|
+
startScene: 0,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
export const scenes = [
|
|
752
|
+
{
|
|
753
|
+
speaker: 'Narrator',
|
|
754
|
+
text: 'You find yourself at the entrance to a mysterious village. The golden gates shine in the sunlight.',
|
|
755
|
+
choices: [
|
|
756
|
+
{ text: 'Enter the village', next: 1 },
|
|
757
|
+
{ text: 'Look around first', next: 2 },
|
|
758
|
+
],
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
speaker: 'Village Elder',
|
|
762
|
+
text: 'Welcome, young traveler! We have been expecting you. Our village needs your help.',
|
|
763
|
+
choices: [
|
|
764
|
+
{ text: 'How can I help?', next: 3 },
|
|
765
|
+
{ text: 'What happened here?', next: 3 },
|
|
766
|
+
],
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
speaker: 'Narrator',
|
|
770
|
+
text: 'You notice a weathered signpost and colorful flowers lining a cobblestone path. The village looks peaceful, but there is an air of anticipation.',
|
|
771
|
+
choices: [
|
|
772
|
+
{ text: 'Follow the path inside', next: 1 },
|
|
773
|
+
],
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
speaker: 'Village Elder',
|
|
777
|
+
text: 'A great challenge lies ahead. Only someone with courage and wisdom can solve it. Will you accept the quest?',
|
|
778
|
+
choices: [
|
|
779
|
+
{ text: 'I accept!', next: 4 },
|
|
780
|
+
{ text: 'Tell me more first', next: 4 },
|
|
781
|
+
],
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
speaker: 'Narrator',
|
|
785
|
+
text: 'And so your adventure begins. This is where your story continues — edit src/story.js to write your own!',
|
|
786
|
+
choices: null, // null (or omitted) = the end of the story
|
|
787
|
+
},
|
|
788
|
+
];
|
|
789
|
+
`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function generateNarrativeStyles() {
|
|
793
|
+
return `/* Narrative styling — tweak freely. Colors use the MyVillage palette. */
|
|
794
|
+
|
|
795
|
+
:root {
|
|
796
|
+
--gold: #FFD700;
|
|
797
|
+
--primary: #B07C00;
|
|
798
|
+
--cream: #E4DCCB;
|
|
799
|
+
--dark: #302017;
|
|
800
|
+
--deep-green: #043922;
|
|
801
|
+
--green: #228B22;
|
|
802
|
+
--teal: #799C9F;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
806
|
+
|
|
807
|
+
html, body {
|
|
808
|
+
height: 100%;
|
|
809
|
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
body {
|
|
813
|
+
background: linear-gradient(160deg, var(--deep-green), var(--dark));
|
|
814
|
+
color: var(--cream);
|
|
815
|
+
display: flex;
|
|
816
|
+
-webkit-font-smoothing: antialiased;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#app { flex: 1; display: flex; }
|
|
820
|
+
|
|
821
|
+
.screen {
|
|
822
|
+
flex: 1;
|
|
823
|
+
display: flex;
|
|
824
|
+
flex-direction: column;
|
|
825
|
+
justify-content: center;
|
|
826
|
+
align-items: center;
|
|
827
|
+
padding: 24px;
|
|
828
|
+
width: 100%;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.card {
|
|
832
|
+
width: 100%;
|
|
833
|
+
max-width: 540px;
|
|
834
|
+
background: rgba(48, 32, 23, 0.92);
|
|
835
|
+
border: 2px solid var(--primary);
|
|
836
|
+
border-radius: 20px;
|
|
837
|
+
padding: 28px 24px;
|
|
838
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.start-card, .results-card { text-align: center; }
|
|
842
|
+
.scene-card { text-align: left; }
|
|
843
|
+
|
|
844
|
+
.title { color: var(--gold); font-size: 30px; margin-bottom: 12px; }
|
|
845
|
+
.intro { font-size: 16px; line-height: 1.5; margin-bottom: 24px; }
|
|
846
|
+
|
|
847
|
+
.speaker {
|
|
848
|
+
color: var(--gold);
|
|
849
|
+
font-weight: 700;
|
|
850
|
+
font-size: 13px;
|
|
851
|
+
letter-spacing: 0.08em;
|
|
852
|
+
text-transform: uppercase;
|
|
853
|
+
margin-bottom: 10px;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.scene-text {
|
|
857
|
+
font-size: 18px;
|
|
858
|
+
line-height: 1.6;
|
|
859
|
+
margin-bottom: 24px;
|
|
860
|
+
color: var(--cream);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
.choices { display: flex; flex-direction: column; gap: 12px; }
|
|
864
|
+
|
|
865
|
+
.btn {
|
|
866
|
+
font-family: inherit;
|
|
867
|
+
font-size: 16px;
|
|
868
|
+
font-weight: 600;
|
|
869
|
+
border-radius: 12px;
|
|
870
|
+
border: none;
|
|
871
|
+
cursor: pointer;
|
|
872
|
+
min-height: 52px;
|
|
873
|
+
padding: 14px 18px;
|
|
874
|
+
transition: transform 0.05s ease, background 0.2s ease, border-color 0.2s ease;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
.btn:active { transform: scale(0.98); }
|
|
878
|
+
|
|
879
|
+
.btn.primary { background: var(--primary); color: #fff; width: 100%; font-size: 18px; }
|
|
880
|
+
.btn.primary:hover { background: #c98f0a; }
|
|
881
|
+
|
|
882
|
+
.choice {
|
|
883
|
+
background: var(--deep-green);
|
|
884
|
+
color: var(--cream);
|
|
885
|
+
border: 2px solid var(--green);
|
|
886
|
+
text-align: left;
|
|
887
|
+
width: 100%;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.choice:hover { border-color: var(--gold); }
|
|
891
|
+
`;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function generateNarrativeReadme(name, description, ageGroup) {
|
|
895
|
+
return `# ${name}
|
|
896
|
+
|
|
897
|
+
${description}
|
|
898
|
+
|
|
899
|
+
- **Game Type**: Narrative
|
|
900
|
+
- **Target Age Group**: ${ageGroup}
|
|
901
|
+
- **Built with**: MyVillageOS CLI
|
|
902
|
+
|
|
903
|
+
An interactive, choice-driven story — no 3D engine. Edit two files to make it yours:
|
|
904
|
+
|
|
905
|
+
- **src/story.js** — your scenes, speakers, and branching choices
|
|
906
|
+
- **src/styles.css** — colors, fonts, and layout
|
|
907
|
+
|
|
908
|
+
## Getting Started
|
|
909
|
+
|
|
910
|
+
\`\`\`bash
|
|
911
|
+
npm install
|
|
912
|
+
npm run dev # play it locally with live reload
|
|
913
|
+
npm run build # build for production (outputs to dist/)
|
|
914
|
+
\`\`\`
|
|
915
|
+
|
|
916
|
+
## Writing your story
|
|
917
|
+
|
|
918
|
+
Open \`src/story.js\`. Each scene has an optional \`speaker\`, the \`text\` to show,
|
|
919
|
+
and \`choices\`. Each choice has \`text\` and \`next\` — the index of the scene to
|
|
920
|
+
jump to. Branch the story by pointing choices at different scenes:
|
|
921
|
+
|
|
922
|
+
\`\`\`js
|
|
923
|
+
{
|
|
924
|
+
speaker: 'Village Elder',
|
|
925
|
+
text: 'Will you accept the quest?',
|
|
926
|
+
choices: [
|
|
927
|
+
{ text: 'I accept!', next: 4 },
|
|
928
|
+
{ text: 'Not yet', next: 2 },
|
|
929
|
+
],
|
|
930
|
+
}
|
|
931
|
+
\`\`\`
|
|
932
|
+
|
|
933
|
+
Set \`choices: null\` (or omit it) to make a scene the end of the story.
|
|
934
|
+
|
|
935
|
+
## The game contract
|
|
936
|
+
|
|
937
|
+
When the story ends it calls \`MyVillageGame.complete({ won, score })\`, so the
|
|
938
|
+
MyVillage host records the session and awards the player this game's MVT reward.
|
|
939
|
+
\`MyVillageGame.ready()\` is called on load. During \`npm run dev\` there's no host
|
|
940
|
+
attached, so these just log to the console — that's expected.
|
|
941
|
+
|
|
942
|
+
## Deploying
|
|
943
|
+
|
|
944
|
+
Starter cover art ships in \`assets/\` — swap in your own thumbnail.png + banner.png, then:
|
|
945
|
+
|
|
946
|
+
\`\`\`bash
|
|
947
|
+
myvillage deploy
|
|
948
|
+
\`\`\`
|
|
949
|
+
|
|
950
|
+
## Learn More
|
|
951
|
+
|
|
952
|
+
- [Vite Documentation](https://vitejs.dev/)
|
|
953
|
+
- [MyVillageOS Developer Guide](https://portal.myvillageproject.ai)
|
|
954
|
+
`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── AGENTS.md generators ───────────────────────────────────────────
|
|
958
|
+
// Each scaffold ships an AGENTS.md — a guide written for AI coding agents
|
|
959
|
+
// (Claude Code, Cursor, Codex, etc. read it automatically). It points the agent
|
|
960
|
+
// at the right files, documents the available APIs + the MyVillage game
|
|
961
|
+
// contract, and flags what must not break. Built as string arrays (not template
|
|
962
|
+
// literals) so the heavy inline-code backticks need no escaping.
|
|
963
|
+
|
|
964
|
+
function generate2DGameAgents(name, gameType, orientation) {
|
|
965
|
+
const isQuiz = gameType === 'quiz';
|
|
966
|
+
const kind = isQuiz ? 'quiz' : 'narrative';
|
|
967
|
+
const engine = isQuiz ? 'QuizGame' : 'NarrativeGame';
|
|
968
|
+
const contentFile = isQuiz ? 'src/questions.js' : 'src/story.js';
|
|
969
|
+
const endFn = isQuiz ? 'renderResults()' : 'renderEnding()';
|
|
970
|
+
const banner = orientation === 'portrait'
|
|
971
|
+
? '9:16 (e.g. 1080x1920) for this portrait game'
|
|
972
|
+
: '16:9 (e.g. 1920x1080) for this landscape game';
|
|
973
|
+
const contentLine = isQuiz
|
|
974
|
+
? '**Content — edit `src/questions.js` first.** It exports `quizConfig` (title, intro, `passThreshold`, `showFeedback`) and a `questions` array. Each question is `{ question, choices, correct }`, where `correct` is the 0-based index of the right choice.'
|
|
975
|
+
: '**Content — edit `src/story.js` first.** It exports `storyConfig` (title, intro, `startScene`) and a `scenes` array. Each scene is `{ speaker, text, choices }`; each choice is `{ text, next }`, where `next` is the index of the next scene. Set `choices: null` to end the story. Branch by pointing choices at different scenes.';
|
|
976
|
+
const flowLine = isQuiz
|
|
977
|
+
? '**Flow & rules — `src/main.js`.** The `QuizGame` class renders each screen (start -> question -> results) and wires the buttons. Edit here for scoring, timing, or new screens.'
|
|
978
|
+
: '**Flow — `src/main.js`.** The `NarrativeGame` class renders each screen (start -> scene -> ending), follows each choice\'s `next`, and wires the buttons.';
|
|
979
|
+
|
|
980
|
+
return [
|
|
981
|
+
'# AGENTS.md — working on this MyVillage game with an AI agent',
|
|
982
|
+
'',
|
|
983
|
+
'**' + name + '** is a MyVillage ' + kind + ' game — a 2D web game (plain DOM + CSS, no 3D engine, no Three.js) built with Vite. It runs inside the MyVillage host (the muni mobile app or the web player) and standalone in a browser.',
|
|
984
|
+
'',
|
|
985
|
+
'When editing: make the requested change, keep the **MyVillage game contract** intact (see below), and keep `npm run build` working.',
|
|
986
|
+
'',
|
|
987
|
+
'## Where common changes go',
|
|
988
|
+
'',
|
|
989
|
+
'- ' + contentLine,
|
|
990
|
+
'- **Look & feel — `src/styles.css`.** Colors are CSS variables in `:root` (the MyVillage palette). Cards, buttons, layout, fonts.',
|
|
991
|
+
'- ' + flowLine,
|
|
992
|
+
'',
|
|
993
|
+
'## File map',
|
|
994
|
+
'',
|
|
995
|
+
'| File | Purpose |',
|
|
996
|
+
'|------|---------|',
|
|
997
|
+
'| `index.html` | Entry point; mounts `#app`, loads `main.js` + `styles.css`. |',
|
|
998
|
+
'| `src/main.js` | The `' + engine + '` engine — lifecycle + rendering. |',
|
|
999
|
+
'| `' + contentFile + '` | **The content** — edit this first for content changes. |',
|
|
1000
|
+
'| `src/styles.css` | Styling (CSS variables in `:root`). |',
|
|
1001
|
+
'| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
|
|
1002
|
+
'| `assets/` | Cover art shipped on deploy. |',
|
|
1003
|
+
'| `package.json` | The `myvillage` block holds metadata `myvillage deploy` reads. |',
|
|
1004
|
+
'',
|
|
1005
|
+
'## The MyVillage game contract — DO NOT BREAK',
|
|
1006
|
+
'',
|
|
1007
|
+
'The game talks to its host through `src/myvillage-game.js`:',
|
|
1008
|
+
'',
|
|
1009
|
+
'- `MyVillageGame.ready()` — called on load. Leave it.',
|
|
1010
|
+
'- `MyVillageGame.complete({ score, won })` — **required.** Called when the game ends (in `' + endFn + '`). It is how the host records the session and awards the player\'s reward. If you change the end flow, keep this call wired to the real end condition.',
|
|
1011
|
+
'',
|
|
1012
|
+
'Do **not** add an Exit button — the host always provides its own.',
|
|
1013
|
+
'',
|
|
1014
|
+
'During `npm run dev` there is no host attached, so `ready()`/`complete()` just log to the console. That is expected — do not try to "fix" it.',
|
|
1015
|
+
'',
|
|
1016
|
+
'## Run / build / deploy',
|
|
1017
|
+
'',
|
|
1018
|
+
' npm install',
|
|
1019
|
+
' npm run dev # local dev server, live reload',
|
|
1020
|
+
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1021
|
+
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1022
|
+
'',
|
|
1023
|
+
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves the game from a sub-path, so absolute `/...` URLs will break.',
|
|
1024
|
+
'',
|
|
1025
|
+
'## Cover art (`assets/`)',
|
|
1026
|
+
'',
|
|
1027
|
+
'Starter art ships here — replace with your own, keeping the same filenames and sizes:',
|
|
1028
|
+
'',
|
|
1029
|
+
'- `thumbnail.png` — square 1:1 (e.g. 512x512). The game card; required.',
|
|
1030
|
+
'- `banner.png` — ' + banner + '. Featured hero; optional.',
|
|
1031
|
+
'',
|
|
1032
|
+
'## Conventions',
|
|
1033
|
+
'',
|
|
1034
|
+
'- Orientation is **' + orientation + '** (set in `package.json` -> `myvillage.orientation`). Portrait runs on muni mobile **and** web; landscape is web-only.',
|
|
1035
|
+
'- Vanilla JS, no framework — keep it dependency-light.',
|
|
1036
|
+
'',
|
|
1037
|
+
].join('\n');
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function generate3DGameAgents(name, type, orientation) {
|
|
1041
|
+
const isExploration = type === 'exploration';
|
|
1042
|
+
const banner = orientation === 'portrait'
|
|
1043
|
+
? '9:16 (e.g. 1080x1920) for this portrait game'
|
|
1044
|
+
: '16:9 (e.g. 1920x1080) for this landscape game';
|
|
1045
|
+
const logicLine = isExploration
|
|
1046
|
+
? 'Holds the gameplay — player movement (via `input.getMovementVector()`), collectible spawning + collision, the follow-camera, score, and win condition. It calls `input.enableJoystick()` in `start()` so mobile gets on-screen movement.'
|
|
1047
|
+
: 'A blank starter — `update(delta)` is empty with hints. Build your mechanics here; the 3D scene, input, audio, UI, and game loop are already wired.';
|
|
1048
|
+
const mobileLine = isExploration
|
|
1049
|
+
? 'This game already calls `this.game.input.enableJoystick()` in `GameLogic.start()`, so touch devices get an on-screen movement thumbstick (no-op on desktop). Read movement with `this.game.input.getMovementVector()`.'
|
|
1050
|
+
: 'If your game uses movement, call `this.game.input.enableJoystick()` in `GameLogic.start()` to show an on-screen thumbstick on touch devices (no-op on desktop), then read it with `this.game.input.getMovementVector()`.';
|
|
1051
|
+
const uiExtra = isExploration
|
|
1052
|
+
? ' This game also adds `showMinimap(pos, items)` and `showInventory(items)`.'
|
|
1053
|
+
: '';
|
|
1054
|
+
|
|
1055
|
+
return [
|
|
1056
|
+
'# AGENTS.md — working on this MyVillage game with an AI agent',
|
|
1057
|
+
'',
|
|
1058
|
+
'**' + name + '** is a MyVillage ' + (isExploration ? 'exploration' : 'custom') + ' game — a 3D web game built with **Three.js** and Vite. It runs inside the MyVillage host (the muni mobile app or the web player) and standalone in a browser.',
|
|
1059
|
+
'',
|
|
1060
|
+
'When editing: make the requested change, keep the **MyVillage game contract** intact (see below), and keep `npm run build` working.',
|
|
1061
|
+
'',
|
|
1062
|
+
'## Where common changes go',
|
|
1063
|
+
'',
|
|
1064
|
+
'- **Gameplay — `src/components/GameLogic.js`.** ' + logicLine,
|
|
1065
|
+
'- **3D scene — `src/scenes/MainScene.js`.** Camera, lighting, ground, objects. The starter has a sky, a green ground plane, and a sample rotating cube (`this.sampleObject`). Add your meshes here.',
|
|
1066
|
+
'- **On-screen UI — `src/components/UI.js`.** HTML overlay (start menu, HUD, pause menu, game-over) rendered over the 3D canvas.',
|
|
1067
|
+
'- **Page/overlay styles are inline in `index.html`.**',
|
|
1068
|
+
'',
|
|
1069
|
+
'## File map',
|
|
1070
|
+
'',
|
|
1071
|
+
'| File | Purpose |',
|
|
1072
|
+
'|------|---------|',
|
|
1073
|
+
'| `src/main.js` | The `Game` class — Three.js renderer, state machine (`loading`/`menu`/`playing`/`paused`/`gameover`), the `animate()` loop, host wiring. |',
|
|
1074
|
+
'| `src/scenes/MainScene.js` | The 3D scene (camera, lights, ground, objects). |',
|
|
1075
|
+
'| `src/components/GameLogic.js` | **Your gameplay** — `start()`, `update(delta)`, `reset()`. |',
|
|
1076
|
+
'| `src/components/UI.js` | HTML overlay (menus, HUD). |',
|
|
1077
|
+
'| `src/utils/InputManager.js` | Keyboard / mouse / touch + mobile joystick. |',
|
|
1078
|
+
'| `src/utils/AudioManager.js` | Sound effects + music (Web Audio). |',
|
|
1079
|
+
'| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
|
|
1080
|
+
'| `assets/` | Cover art shipped on deploy. |',
|
|
1081
|
+
'| `public/assets/` | Drop 3D models / textures / audio here. |',
|
|
1082
|
+
'',
|
|
1083
|
+
'## Engine API (reach it as `this.game` inside GameLogic)',
|
|
1084
|
+
'',
|
|
1085
|
+
'- `this.game.scene` — `.scene` (THREE.Scene), `.camera` (THREE.PerspectiveCamera), `.sampleObject`, `.update(delta)`.',
|
|
1086
|
+
'- `this.game.input` — `getMovementVector()` (returns `{ x, z }` from WASD/arrows or the joystick), `isKeyDown(code)`, `isKeyJustPressed(code)`, `enableJoystick()`, `clearFrame()`, `mouse`, `touches`.',
|
|
1087
|
+
'- `this.game.audio` — `playSFX(name)`, `playMusic(name)`, `stopMusic()`, `setMusicVolume(v)`, `setSFXVolume(v)`.',
|
|
1088
|
+
'- `this.game.ui` — `showMenu()`, `showHUD()`, `showPauseMenu()`, `showGameOver(result)`, `clearOverlay()`, `createElement(tag, styles)`.' + uiExtra,
|
|
1089
|
+
'- `this.game` — `startGame()`, `pauseGame()`, `resumeGame()`, `endGame(result)`, `state`.',
|
|
1090
|
+
'',
|
|
1091
|
+
'## Mobile controls',
|
|
1092
|
+
'',
|
|
1093
|
+
mobileLine,
|
|
1094
|
+
'',
|
|
1095
|
+
'## The MyVillage game contract — DO NOT BREAK',
|
|
1096
|
+
'',
|
|
1097
|
+
'- `MyVillageGame.ready()` — called after assets load in `src/main.js`. Leave it.',
|
|
1098
|
+
'- `this.game.endGame({ won, score })` — call at your win/lose condition. It calls `MyVillageGame.complete(...)`, which is **required**: the host records the session and awards the player\'s reward.',
|
|
1099
|
+
'',
|
|
1100
|
+
'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).',
|
|
1101
|
+
'',
|
|
1102
|
+
'## Run / build / deploy',
|
|
1103
|
+
'',
|
|
1104
|
+
' npm install',
|
|
1105
|
+
' npm run dev # local dev server, live reload',
|
|
1106
|
+
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1107
|
+
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1108
|
+
'',
|
|
1109
|
+
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves from a sub-path, so absolute `/...` URLs break.',
|
|
1110
|
+
'',
|
|
1111
|
+
'## Cover art (`assets/`)',
|
|
1112
|
+
'',
|
|
1113
|
+
'Starter art ships here — replace with your own, same filenames and sizes:',
|
|
1114
|
+
'',
|
|
1115
|
+
'- `thumbnail.png` — square 1:1 (e.g. 512x512). The game card; required.',
|
|
1116
|
+
'- `banner.png` — ' + banner + '. Featured hero; optional.',
|
|
1117
|
+
'',
|
|
1118
|
+
'## Conventions',
|
|
1119
|
+
'',
|
|
1120
|
+
'- Orientation is **' + orientation + '** (`package.json` -> `myvillage.orientation`). Portrait runs on muni mobile **and** web; landscape is web-only.',
|
|
1121
|
+
'- The follow-camera and game loop already exist — extend them, do not rebuild.',
|
|
1122
|
+
'',
|
|
1123
|
+
].join('\n');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function generateUnityAgents(name, className, orientation) {
|
|
1127
|
+
const isLandscape = orientation === 'landscape';
|
|
1128
|
+
const reach = isLandscape
|
|
1129
|
+
? 'web (WebGL) only'
|
|
1130
|
+
: 'the muni mobile app and the web';
|
|
1131
|
+
const banner = isLandscape
|
|
1132
|
+
? '16:9 (e.g. 1920x1080) for this landscape game'
|
|
1133
|
+
: '9:16 (e.g. 1080x1920) for this portrait game';
|
|
1134
|
+
|
|
1135
|
+
return [
|
|
1136
|
+
'# AGENTS.md — working on this MyVillage Unity game with an AI agent',
|
|
1137
|
+
'',
|
|
1138
|
+
'**' + name + '** is a MyVillage Unity game built on the **MyVillage GameKit SDK** (a UPM package pinned in `Packages/manifest.json`). The mission is C#; it builds to AssetBundles the M-UNI host loads.',
|
|
1139
|
+
'',
|
|
1140
|
+
'When editing: change the mission logic, keep the **game contract** intact (`CompleteMission` / `FailMission`), and do not break the GameKit `MissionBase` lifecycle.',
|
|
1141
|
+
'',
|
|
1142
|
+
'## Where common changes go',
|
|
1143
|
+
'',
|
|
1144
|
+
'- **Mission logic — `Assets/Scripts/' + className + '.cs`.** Your `MissionBase` subclass. Override `OnBegin()` with your gameplay and call `CompleteMission(finalScore: n)` (or `FailMission(reason)`) when the player finishes. `OnEnd()` is for cleanup.',
|
|
1145
|
+
'- **Scene & objects — the Unity scene** (create via the editor menu `MyVillage -> Create Starter Mission Scene`). Add GameObjects, art, and audio in the Unity editor.',
|
|
1146
|
+
'- **Orientation — `Assets/Editor/MyVillageOrientation.cs`** pins it on first load.',
|
|
1147
|
+
'',
|
|
1148
|
+
'## File map',
|
|
1149
|
+
'',
|
|
1150
|
+
'| Path | Purpose |',
|
|
1151
|
+
'|------|---------|',
|
|
1152
|
+
'| `Assets/Scripts/' + className + '.cs` | **Your mission** — the `MissionBase` subclass. Edit here. |',
|
|
1153
|
+
'| `Assets/Editor/MyVillageOrientation.cs` | Pins player orientation on first load. |',
|
|
1154
|
+
'| `Packages/manifest.json` | Depends on the GameKit SDK (pinned version). Do not hand-edit the SDK. |',
|
|
1155
|
+
'| `myvillage.json` | Game metadata the CLI/deploy reads (slug, gameType, orientation, assets). |',
|
|
1156
|
+
'| `assets/` | Cover art shipped on deploy (thumbnail/banner). |',
|
|
1157
|
+
'| `builds/{ios,android,webgl}/` | Drop-zone for built AssetBundles the CLI deploys. |',
|
|
1158
|
+
'',
|
|
1159
|
+
'## The game contract (GameKit `MissionBase`) — DO NOT BREAK',
|
|
1160
|
+
'',
|
|
1161
|
+
'- The host drives your mission and runs `OnBegin()`. In the editor, `MockMissionHost` stands in and logs host calls to the Console.',
|
|
1162
|
+
'- **`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.**',
|
|
1163
|
+
'- `FailMission(reason)` — the lose path. `Host.LogEvent("...")` — telemetry.',
|
|
1164
|
+
'- **Do not build an Exit button** — the host always shows its own and calls `OnEnd()` for cleanup when the player exits.',
|
|
1165
|
+
'',
|
|
1166
|
+
'The same script runs unchanged in the real M-UNI host (a `MissionHostAdapter` replaces `MockMissionHost`). Do not add platform-specific host code.',
|
|
1167
|
+
'',
|
|
1168
|
+
'## Build & deploy',
|
|
1169
|
+
'',
|
|
1170
|
+
'1. In Unity: **`MyVillage -> Build All Platforms`** (ships in GameKit). Builds iOS, Android, and WebGL into `builds/`.',
|
|
1171
|
+
'2. From the terminal: `myvillage deploy` (scans `builds/`, uploads, submits).',
|
|
1172
|
+
'',
|
|
1173
|
+
'This game\'s orientation is **' + orientation + '** — it runs on ' + reach + '.',
|
|
1174
|
+
'',
|
|
1175
|
+
'## Cover art (`assets/`)',
|
|
1176
|
+
'',
|
|
1177
|
+
'Starter art ships here — replace with your own, same filenames and sizes:',
|
|
1178
|
+
'',
|
|
1179
|
+
'- `thumbnail.png` — square 1:1 (e.g. 512x512). The game card; required.',
|
|
1180
|
+
'- `banner.png` — ' + banner + '. Featured hero; optional.',
|
|
1181
|
+
'',
|
|
1182
|
+
'## Gotchas',
|
|
1183
|
+
'',
|
|
1184
|
+
'- Do not edit the GameKit SDK package — it is a pinned dependency. Use its `MissionBase` API.',
|
|
1185
|
+
'- Visual Scripting reflection nodes (`InvokeMember`/`GetMember`/`SetMember`) are rejected by the deploy preflight. Write C# instead.',
|
|
1186
|
+
'',
|
|
1187
|
+
].join('\n');
|
|
1188
|
+
}
|
|
1189
|
+
|
|
596
1190
|
// ── Unity Game Project ─────────────────────────────────────────────
|
|
597
1191
|
|
|
598
1192
|
export async function createUnityGameProject(targetDir, options) {
|
|
@@ -675,6 +1269,7 @@ export async function createUnityGameProject(targetDir, options) {
|
|
|
675
1269
|
copyTemplateArt(targetDir, type, orientation);
|
|
676
1270
|
writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
|
|
677
1271
|
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
|
|
1272
|
+
writeFileSync(join(targetDir, 'AGENTS.md'), generateUnityAgents(name, className, orientation));
|
|
678
1273
|
|
|
679
1274
|
return { unityVersion, sdkVersion: GAMEKIT_SDK_VERSION, gameType: primaryGameType };
|
|
680
1275
|
}
|
|
@@ -1833,52 +2428,6 @@ function generateUI(type) {
|
|
|
1833
2428
|
let typeSpecificUI = '';
|
|
1834
2429
|
|
|
1835
2430
|
switch (type) {
|
|
1836
|
-
case 'quiz':
|
|
1837
|
-
typeSpecificUI = `
|
|
1838
|
-
showQuestion(question, choices, onAnswer) {
|
|
1839
|
-
this.clearOverlay();
|
|
1840
|
-
|
|
1841
|
-
const panel = this.createElement('div', {
|
|
1842
|
-
position: 'absolute', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
|
|
1843
|
-
width: '90%', maxWidth: '600px', background: 'rgba(48,32,23,0.95)',
|
|
1844
|
-
borderRadius: '16px', padding: '24px', color: '#E4DCCB',
|
|
1845
|
-
});
|
|
1846
|
-
|
|
1847
|
-
const qText = this.createElement('p', {
|
|
1848
|
-
fontSize: '18px', fontWeight: 'bold', marginBottom: '16px',
|
|
1849
|
-
color: '#FFD700', textAlign: 'center',
|
|
1850
|
-
});
|
|
1851
|
-
qText.textContent = question;
|
|
1852
|
-
panel.appendChild(qText);
|
|
1853
|
-
|
|
1854
|
-
choices.forEach((choice, index) => {
|
|
1855
|
-
const btn = this.createElement('button', {
|
|
1856
|
-
display: 'block', width: '100%', padding: '12px',
|
|
1857
|
-
margin: '8px 0', background: '#043922', color: '#E4DCCB',
|
|
1858
|
-
border: '2px solid #228B22', borderRadius: '8px',
|
|
1859
|
-
fontSize: '16px', cursor: 'pointer', textAlign: 'left',
|
|
1860
|
-
});
|
|
1861
|
-
btn.textContent = choice;
|
|
1862
|
-
btn.addEventListener('click', () => onAnswer(index));
|
|
1863
|
-
panel.appendChild(btn);
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
this.overlay.appendChild(panel);
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
showScore(score, total) {
|
|
1870
|
-
this.clearOverlay();
|
|
1871
|
-
|
|
1872
|
-
const panel = this.createElement('div', {
|
|
1873
|
-
position: 'absolute', top: '10px', right: '10px',
|
|
1874
|
-
background: 'rgba(48,32,23,0.85)', borderRadius: '12px',
|
|
1875
|
-
padding: '10px 18px', color: '#FFD700', fontSize: '18px', fontWeight: 'bold',
|
|
1876
|
-
});
|
|
1877
|
-
panel.textContent = \`Score: \${score} / \${total}\`;
|
|
1878
|
-
this.overlay.appendChild(panel);
|
|
1879
|
-
}`;
|
|
1880
|
-
break;
|
|
1881
|
-
|
|
1882
2431
|
case 'exploration':
|
|
1883
2432
|
typeSpecificUI = `
|
|
1884
2433
|
showMinimap(playerPos, items) {
|
|
@@ -1941,57 +2490,6 @@ function generateUI(type) {
|
|
|
1941
2490
|
}`;
|
|
1942
2491
|
break;
|
|
1943
2492
|
|
|
1944
|
-
case 'narrative':
|
|
1945
|
-
typeSpecificUI = `
|
|
1946
|
-
showDialogue(speaker, text, choices, onChoice) {
|
|
1947
|
-
this.clearOverlay();
|
|
1948
|
-
|
|
1949
|
-
const panel = this.createElement('div', {
|
|
1950
|
-
position: 'absolute', bottom: '0', left: '0', width: '100%',
|
|
1951
|
-
background: 'rgba(48,32,23,0.95)', padding: '20px 24px',
|
|
1952
|
-
borderTop: '3px solid #B07C00', color: '#E4DCCB',
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
if (speaker) {
|
|
1956
|
-
const nameTag = this.createElement('span', {
|
|
1957
|
-
color: '#FFD700', fontWeight: 'bold', fontSize: '16px',
|
|
1958
|
-
marginBottom: '8px', display: 'block',
|
|
1959
|
-
});
|
|
1960
|
-
nameTag.textContent = speaker;
|
|
1961
|
-
panel.appendChild(nameTag);
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
const textEl = this.createElement('p', {
|
|
1965
|
-
fontSize: '15px', lineHeight: '1.5', marginBottom: choices ? '16px' : '0',
|
|
1966
|
-
});
|
|
1967
|
-
textEl.textContent = text;
|
|
1968
|
-
panel.appendChild(textEl);
|
|
1969
|
-
|
|
1970
|
-
if (choices && choices.length > 0) {
|
|
1971
|
-
choices.forEach((choice, index) => {
|
|
1972
|
-
const btn = this.createElement('button', {
|
|
1973
|
-
display: 'block', width: '100%', padding: '10px',
|
|
1974
|
-
margin: '6px 0', background: 'transparent', color: '#FFD700',
|
|
1975
|
-
border: '1px solid #B07C00', borderRadius: '8px',
|
|
1976
|
-
fontSize: '14px', cursor: 'pointer', textAlign: 'left',
|
|
1977
|
-
});
|
|
1978
|
-
btn.textContent = \`> \${choice}\`;
|
|
1979
|
-
btn.addEventListener('click', () => onChoice(index));
|
|
1980
|
-
panel.appendChild(btn);
|
|
1981
|
-
});
|
|
1982
|
-
} else {
|
|
1983
|
-
const hint = this.createElement('p', {
|
|
1984
|
-
fontSize: '12px', color: '#799C9F', marginTop: '8px', textAlign: 'center',
|
|
1985
|
-
});
|
|
1986
|
-
hint.textContent = 'Click to continue...';
|
|
1987
|
-
panel.appendChild(hint);
|
|
1988
|
-
panel.addEventListener('click', () => onChoice(-1));
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
this.overlay.appendChild(panel);
|
|
1992
|
-
}`;
|
|
1993
|
-
break;
|
|
1994
|
-
|
|
1995
2493
|
default:
|
|
1996
2494
|
typeSpecificUI = '';
|
|
1997
2495
|
}
|
|
@@ -2162,89 +2660,6 @@ export class GameLogic {
|
|
|
2162
2660
|
`;
|
|
2163
2661
|
}
|
|
2164
2662
|
|
|
2165
|
-
function generateQuizGameLogic() {
|
|
2166
|
-
return `// Quiz Game Logic
|
|
2167
|
-
// Manages questions, answers, scoring, and progress tracking.
|
|
2168
|
-
|
|
2169
|
-
export class GameLogic {
|
|
2170
|
-
constructor(game) {
|
|
2171
|
-
this.game = game;
|
|
2172
|
-
this.score = 0;
|
|
2173
|
-
this.currentQuestion = 0;
|
|
2174
|
-
|
|
2175
|
-
// Define your quiz questions here
|
|
2176
|
-
this.questions = [
|
|
2177
|
-
{
|
|
2178
|
-
question: 'What is 2 + 2?',
|
|
2179
|
-
choices: ['3', '4', '5', '6'],
|
|
2180
|
-
correct: 1,
|
|
2181
|
-
},
|
|
2182
|
-
{
|
|
2183
|
-
question: 'What is the capital of France?',
|
|
2184
|
-
choices: ['London', 'Berlin', 'Paris', 'Madrid'],
|
|
2185
|
-
correct: 2,
|
|
2186
|
-
},
|
|
2187
|
-
{
|
|
2188
|
-
question: 'Which planet is closest to the Sun?',
|
|
2189
|
-
choices: ['Venus', 'Mercury', 'Earth', 'Mars'],
|
|
2190
|
-
correct: 1,
|
|
2191
|
-
},
|
|
2192
|
-
];
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
start() {
|
|
2196
|
-
this.score = 0;
|
|
2197
|
-
this.currentQuestion = 0;
|
|
2198
|
-
this.showCurrentQuestion();
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
reset() {
|
|
2202
|
-
this.score = 0;
|
|
2203
|
-
this.currentQuestion = 0;
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
showCurrentQuestion() {
|
|
2207
|
-
if (this.currentQuestion >= this.questions.length) {
|
|
2208
|
-
// Quiz complete
|
|
2209
|
-
this.game.endGame({
|
|
2210
|
-
won: this.score > this.questions.length / 2,
|
|
2211
|
-
score: this.score,
|
|
2212
|
-
});
|
|
2213
|
-
return;
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
|
-
const q = this.questions[this.currentQuestion];
|
|
2217
|
-
this.game.ui.showQuestion(q.question, q.choices, (answerIndex) => {
|
|
2218
|
-
this.handleAnswer(answerIndex);
|
|
2219
|
-
});
|
|
2220
|
-
this.game.ui.showScore(this.score, this.questions.length);
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
handleAnswer(answerIndex) {
|
|
2224
|
-
const q = this.questions[this.currentQuestion];
|
|
2225
|
-
|
|
2226
|
-
if (answerIndex === q.correct) {
|
|
2227
|
-
this.score++;
|
|
2228
|
-
// Play celebration animation - rotate the sample object faster
|
|
2229
|
-
if (this.game.scene.sampleObject) {
|
|
2230
|
-
this.game.scene.sampleObject.scale.set(1.5, 1.5, 1.5);
|
|
2231
|
-
setTimeout(() => {
|
|
2232
|
-
this.game.scene.sampleObject.scale.set(1, 1, 1);
|
|
2233
|
-
}, 500);
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
this.currentQuestion++;
|
|
2238
|
-
this.showCurrentQuestion();
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
update(delta) {
|
|
2242
|
-
// Quiz games are event-driven, so update is minimal
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
`;
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
2663
|
function generateExplorationGameLogic() {
|
|
2249
2664
|
return `// Exploration Game Logic
|
|
2250
2665
|
// Manages character movement, collision detection, and collectible items.
|
|
@@ -2375,107 +2790,3 @@ export class GameLogic {
|
|
|
2375
2790
|
}
|
|
2376
2791
|
`;
|
|
2377
2792
|
}
|
|
2378
|
-
|
|
2379
|
-
function generateNarrativeGameLogic() {
|
|
2380
|
-
return `// Narrative Game Logic
|
|
2381
|
-
// Manages dialogue, choices, scene transitions, and story branching.
|
|
2382
|
-
|
|
2383
|
-
export class GameLogic {
|
|
2384
|
-
constructor(game) {
|
|
2385
|
-
this.game = game;
|
|
2386
|
-
this.currentSceneIndex = 0;
|
|
2387
|
-
this.score = 0;
|
|
2388
|
-
|
|
2389
|
-
// Define your story scenes here.
|
|
2390
|
-
// Each scene has speaker, text, and optional choices.
|
|
2391
|
-
// Choices have 'text' and 'next' (index of the next scene to go to).
|
|
2392
|
-
this.storyScenes = [
|
|
2393
|
-
{
|
|
2394
|
-
speaker: 'Narrator',
|
|
2395
|
-
text: 'You find yourself at the entrance to a mysterious village. The golden gates shine in the sunlight.',
|
|
2396
|
-
choices: [
|
|
2397
|
-
{ text: 'Enter the village', next: 1 },
|
|
2398
|
-
{ text: 'Look around first', next: 2 },
|
|
2399
|
-
],
|
|
2400
|
-
},
|
|
2401
|
-
{
|
|
2402
|
-
speaker: 'Village Elder',
|
|
2403
|
-
text: 'Welcome, young traveler! We have been expecting you. Our village needs your help.',
|
|
2404
|
-
choices: [
|
|
2405
|
-
{ text: 'How can I help?', next: 3 },
|
|
2406
|
-
{ text: 'What happened here?', next: 3 },
|
|
2407
|
-
],
|
|
2408
|
-
},
|
|
2409
|
-
{
|
|
2410
|
-
speaker: 'Narrator',
|
|
2411
|
-
text: 'You notice a weathered signpost and colorful flowers lining a cobblestone path. The village looks peaceful but there is an air of anticipation.',
|
|
2412
|
-
choices: [
|
|
2413
|
-
{ text: 'Follow the path inside', next: 1 },
|
|
2414
|
-
],
|
|
2415
|
-
},
|
|
2416
|
-
{
|
|
2417
|
-
speaker: 'Village Elder',
|
|
2418
|
-
text: 'A great challenge lies ahead. Only someone with courage and wisdom can solve it. Will you accept the quest?',
|
|
2419
|
-
choices: [
|
|
2420
|
-
{ text: 'I accept!', next: 4 },
|
|
2421
|
-
{ text: 'Tell me more about the quest', next: 4 },
|
|
2422
|
-
],
|
|
2423
|
-
},
|
|
2424
|
-
{
|
|
2425
|
-
speaker: 'Narrator',
|
|
2426
|
-
text: 'And so your adventure begins... This is where your story continues! Edit the storyScenes array in GameLogic.js to create your own narrative.',
|
|
2427
|
-
choices: null, // null choices = end of story
|
|
2428
|
-
},
|
|
2429
|
-
];
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
start() {
|
|
2433
|
-
this.currentSceneIndex = 0;
|
|
2434
|
-
this.score = 0;
|
|
2435
|
-
this.showCurrentScene();
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
reset() {
|
|
2439
|
-
this.currentSceneIndex = 0;
|
|
2440
|
-
this.score = 0;
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
showCurrentScene() {
|
|
2444
|
-
const scene = this.storyScenes[this.currentSceneIndex];
|
|
2445
|
-
if (!scene) {
|
|
2446
|
-
this.game.endGame({ won: true, score: this.score });
|
|
2447
|
-
return;
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
const choiceTexts = scene.choices ? scene.choices.map((c) => c.text) : null;
|
|
2451
|
-
|
|
2452
|
-
this.game.ui.showDialogue(scene.speaker, scene.text, choiceTexts, (choiceIndex) => {
|
|
2453
|
-
this.handleChoice(choiceIndex);
|
|
2454
|
-
});
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
handleChoice(choiceIndex) {
|
|
2458
|
-
const scene = this.storyScenes[this.currentSceneIndex];
|
|
2459
|
-
|
|
2460
|
-
if (!scene.choices || choiceIndex === -1) {
|
|
2461
|
-
// End of story
|
|
2462
|
-
this.game.endGame({ won: true, score: this.score });
|
|
2463
|
-
return;
|
|
2464
|
-
}
|
|
2465
|
-
|
|
2466
|
-
const choice = scene.choices[choiceIndex];
|
|
2467
|
-
if (choice && choice.next !== undefined) {
|
|
2468
|
-
this.currentSceneIndex = choice.next;
|
|
2469
|
-
this.score++;
|
|
2470
|
-
this.showCurrentScene();
|
|
2471
|
-
} else {
|
|
2472
|
-
this.game.endGame({ won: true, score: this.score });
|
|
2473
|
-
}
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
update(delta) {
|
|
2477
|
-
// Narrative games are event-driven via UI choices
|
|
2478
|
-
}
|
|
2479
|
-
}
|
|
2480
|
-
`;
|
|
2481
|
-
}
|