@myvillage/cli 1.62.1 → 1.64.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.62.1",
3
+ "version": "1.64.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,13 @@
1
- import { mkdirSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
3
4
  import { randomUUID } from 'crypto';
4
5
 
6
+ // Bundled starter cover art lives at src/assets/game-art/ (this file is at
7
+ // src/utils/). Resolved from the module path so it also works once the CLI is
8
+ // installed globally via npm.
9
+ const GAME_ART_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'game-art');
10
+
5
11
  // GameKit SDK version that scaffolded Unity projects pin to.
6
12
  // Bump alongside CLI minor releases as the SDK contract evolves.
7
13
  const GAMEKIT_SDK_VERSION = '1.1.0-alpha.4';
@@ -49,6 +55,15 @@ const BRAND = {
49
55
 
50
56
  export function createGameProject(targetDir, options) {
51
57
  const { name, description, type, ageGroup, orientation = 'landscape' } = options;
58
+
59
+ // A quiz is a 2D UI experience, not a 3D world — it gets a dedicated DOM/CSS
60
+ // scaffold (no Three.js, no renderer, no game loop) instead of the shared
61
+ // 3D shell the exploration/narrative/custom types use.
62
+ if (type === 'quiz') {
63
+ createQuizProject(targetDir, options);
64
+ return;
65
+ }
66
+
52
67
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
53
68
 
54
69
  // Create directory structure
@@ -77,6 +92,7 @@ export function createGameProject(targetDir, options) {
77
92
  writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
78
93
  writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name, orientation));
79
94
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
95
+ copyTemplateArt(targetDir, type, orientation);
80
96
 
81
97
  // Write base source files
82
98
  writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
@@ -103,6 +119,480 @@ export function createGameProject(targetDir, options) {
103
119
  }
104
120
  }
105
121
 
122
+ // ── Quiz Game Project (2D) ─────────────────────────────────────────
123
+ // A quiz is a UI experience, so this scaffold is plain DOM/CSS — no Three.js,
124
+ // no renderer, no game loop. Two files hold everything a developer edits:
125
+ // src/questions.js - the questions + quiz settings (content)
126
+ // src/styles.css - the look
127
+ // src/main.js runs the quiz lifecycle (start → questions → results) and reports
128
+ // the result through the shared MyVillage game contract.
129
+ function createQuizProject(targetDir, options) {
130
+ const { name, description, ageGroup, orientation = 'portrait' } = options;
131
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
132
+
133
+ const dirs = ['', 'src', 'assets', 'public'];
134
+ for (const dir of dirs) {
135
+ mkdirSync(join(targetDir, dir), { recursive: true });
136
+ }
137
+
138
+ writeFileSync(join(targetDir, 'package.json'), generateQuizPackageJson(name, description, slug, ageGroup, orientation));
139
+ writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
140
+ writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
141
+ writeFileSync(join(targetDir, 'index.html'), generateQuizIndexHtml(name, orientation));
142
+ writeFileSync(join(targetDir, 'README.md'), generateQuizReadme(name, description, ageGroup));
143
+
144
+ writeFileSync(join(targetDir, 'src/main.js'), generateQuizApp());
145
+ writeFileSync(join(targetDir, 'src/questions.js'), generateQuizQuestions(name));
146
+ writeFileSync(join(targetDir, 'src/styles.css'), generateQuizStyles());
147
+ writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
148
+
149
+ writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
150
+ copyTemplateArt(targetDir, 'quiz', orientation);
151
+ }
152
+
153
+ function generateQuizPackageJson(name, description, slug, ageGroup, orientation) {
154
+ return JSON.stringify({
155
+ name: slug,
156
+ version: '1.0.0',
157
+ description,
158
+ private: true,
159
+ type: 'module',
160
+ scripts: {
161
+ dev: 'vite',
162
+ build: 'vite build',
163
+ preview: 'vite preview',
164
+ },
165
+ // No Three.js — a quiz is pure DOM/CSS.
166
+ devDependencies: {
167
+ vite: '^5.0.0',
168
+ },
169
+ myvillage: {
170
+ name: name,
171
+ gameId: null,
172
+ gameType: 'quiz',
173
+ targetAge: ageGroup,
174
+ orientation,
175
+ aspectRatio: orientation === 'portrait' ? '9:16' : '16:9',
176
+ assets: {
177
+ thumbnail: 'assets/thumbnail.png',
178
+ banner: 'assets/banner.png',
179
+ },
180
+ villages: [],
181
+ lastDeployed: null,
182
+ },
183
+ }, null, 2) + '\n';
184
+ }
185
+
186
+ function generateQuizIndexHtml(name, orientation) {
187
+ return `<!DOCTYPE html>
188
+ <html lang="en">
189
+ <head>
190
+ <meta charset="UTF-8" />
191
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
192
+ <!-- orientation: ${orientation} -->
193
+ <title>${name}</title>
194
+ <link rel="stylesheet" href="/src/styles.css" />
195
+ </head>
196
+ <body>
197
+ <div id="app"></div>
198
+ <script type="module" src="/src/main.js"></script>
199
+ </body>
200
+ </html>
201
+ `;
202
+ }
203
+
204
+ function generateQuizQuestions(name) {
205
+ return `// Quiz content — edit this file to make the quiz your own.
206
+ //
207
+ // Each question has the prompt, a list of choices, and \`correct\`: the index of
208
+ // the right choice, starting at 0. Add or remove questions freely.
209
+
210
+ export const quizConfig = {
211
+ // Shown on the start screen.
212
+ title: ${JSON.stringify(name)},
213
+ intro: 'Answer the questions and see how many you get right!',
214
+ // Fraction of questions you must get right to "win" (0.6 = 60%).
215
+ passThreshold: 0.6,
216
+ // Briefly highlight the correct / incorrect answer after each pick.
217
+ showFeedback: true,
218
+ };
219
+
220
+ export const questions = [
221
+ {
222
+ question: 'What is 2 + 2?',
223
+ choices: ['3', '4', '5', '6'],
224
+ correct: 1,
225
+ },
226
+ {
227
+ question: 'What is the capital of France?',
228
+ choices: ['London', 'Berlin', 'Paris', 'Madrid'],
229
+ correct: 2,
230
+ },
231
+ {
232
+ question: 'Which planet is closest to the Sun?',
233
+ choices: ['Venus', 'Mercury', 'Earth', 'Mars'],
234
+ correct: 1,
235
+ },
236
+ ];
237
+ `;
238
+ }
239
+
240
+ function generateQuizApp() {
241
+ return `// Quiz Game — a 2D question/answer game.
242
+ //
243
+ // Edit your questions and settings in src/questions.js, and the look in
244
+ // src/styles.css. This file runs the quiz: it renders the start screen, each
245
+ // question (with a progress bar + score), instant right/wrong feedback, and a
246
+ // results screen — then reports the result to the MyVillage host.
247
+
248
+ import { MyVillageGame } from './myvillage-game.js';
249
+ import { quizConfig, questions } from './questions.js';
250
+
251
+ class QuizGame {
252
+ constructor(root) {
253
+ this.root = root;
254
+ this.index = 0;
255
+ this.score = 0;
256
+ this.answered = false;
257
+
258
+ this.renderStart();
259
+
260
+ // Tell the MyVillage host the game is loaded and interactive.
261
+ MyVillageGame.ready();
262
+ }
263
+
264
+ renderStart() {
265
+ this.root.innerHTML = \`
266
+ <main class="screen">
267
+ <div class="card start-card">
268
+ <h1 class="title">\${quizConfig.title}</h1>
269
+ <p class="intro">\${quizConfig.intro}</p>
270
+ <button class="btn primary" id="start-btn">Start Quiz</button>
271
+ </div>
272
+ </main>
273
+ \`;
274
+ this.root.querySelector('#start-btn').addEventListener('click', () => this.begin());
275
+ }
276
+
277
+ begin() {
278
+ this.index = 0;
279
+ this.score = 0;
280
+ this.renderQuestion();
281
+ }
282
+
283
+ renderQuestion() {
284
+ if (this.index >= questions.length) {
285
+ this.renderResults();
286
+ return;
287
+ }
288
+
289
+ this.answered = false;
290
+ const q = questions[this.index];
291
+ const total = questions.length;
292
+ const progress = (this.index / total) * 100;
293
+
294
+ this.root.innerHTML = \`
295
+ <main class="screen">
296
+ <header class="quiz-header">
297
+ <div class="progress"><div class="progress-fill" style="width: \${progress}%"></div></div>
298
+ <div class="meta">
299
+ <span>Question \${this.index + 1} of \${total}</span>
300
+ <span>Score: \${this.score}</span>
301
+ </div>
302
+ </header>
303
+ <div class="card question-card">
304
+ <p class="question">\${q.question}</p>
305
+ <div class="choices">
306
+ \${q.choices.map((choice, i) => \`<button class="btn choice" data-index="\${i}">\${choice}</button>\`).join('')}
307
+ </div>
308
+ </div>
309
+ </main>
310
+ \`;
311
+
312
+ this.root.querySelectorAll('.choice').forEach((btn) => {
313
+ btn.addEventListener('click', () => this.answer(Number(btn.dataset.index)));
314
+ });
315
+ }
316
+
317
+ answer(choiceIndex) {
318
+ if (this.answered) return; // ignore extra taps while showing feedback
319
+ this.answered = true;
320
+
321
+ const q = questions[this.index];
322
+ if (choiceIndex === q.correct) this.score++;
323
+
324
+ // Reveal the right answer (and the wrong pick, if any).
325
+ const buttons = this.root.querySelectorAll('.choice');
326
+ buttons.forEach((btn, i) => {
327
+ btn.disabled = true;
328
+ if (quizConfig.showFeedback) {
329
+ if (i === q.correct) btn.classList.add('correct');
330
+ else if (i === choiceIndex) btn.classList.add('incorrect');
331
+ }
332
+ });
333
+
334
+ const delay = quizConfig.showFeedback ? 900 : 150;
335
+ window.setTimeout(() => {
336
+ this.index++;
337
+ this.renderQuestion();
338
+ }, delay);
339
+ }
340
+
341
+ renderResults() {
342
+ const total = questions.length;
343
+ const won = total > 0 && this.score / total >= quizConfig.passThreshold;
344
+ const pct = total > 0 ? Math.round((this.score / total) * 100) : 0;
345
+
346
+ this.root.innerHTML = \`
347
+ <main class="screen">
348
+ <div class="card results-card">
349
+ <h2 class="title">\${won ? 'Great job!' : 'Quiz complete'}</h2>
350
+ <p class="result-score">\${this.score} / \${total}</p>
351
+ <p class="result-pct">\${pct}% correct</p>
352
+ <button class="btn primary" id="again-btn">Play Again</button>
353
+ </div>
354
+ </main>
355
+ \`;
356
+ this.root.querySelector('#again-btn').addEventListener('click', () => this.begin());
357
+
358
+ // REQUIRED: tell the MyVillage host the game is finished. The host records
359
+ // the session, awards this game's MVT reward, and returns the player to the
360
+ // catalog. During \`npm run dev\` there's no host, so this just logs.
361
+ MyVillageGame.complete({ score: this.score, won, custom: { total } });
362
+ }
363
+ }
364
+
365
+ new QuizGame(document.getElementById('app'));
366
+ `;
367
+ }
368
+
369
+ function generateQuizStyles() {
370
+ return `/* Quiz styling — tweak freely. Colors use the MyVillage palette. */
371
+
372
+ :root {
373
+ --gold: #FFD700;
374
+ --primary: #B07C00;
375
+ --cream: #E4DCCB;
376
+ --dark: #302017;
377
+ --deep-green: #043922;
378
+ --green: #228B22;
379
+ --teal: #799C9F;
380
+ --correct: #2e9e4f;
381
+ --incorrect: #c0392b;
382
+ }
383
+
384
+ * { margin: 0; padding: 0; box-sizing: border-box; }
385
+
386
+ html, body {
387
+ height: 100%;
388
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
389
+ }
390
+
391
+ body {
392
+ background: linear-gradient(160deg, var(--deep-green), var(--dark));
393
+ color: var(--cream);
394
+ display: flex;
395
+ -webkit-font-smoothing: antialiased;
396
+ }
397
+
398
+ #app { flex: 1; display: flex; }
399
+
400
+ .screen {
401
+ flex: 1;
402
+ display: flex;
403
+ flex-direction: column;
404
+ justify-content: center;
405
+ align-items: center;
406
+ gap: 16px;
407
+ padding: 24px;
408
+ width: 100%;
409
+ }
410
+
411
+ .card {
412
+ width: 100%;
413
+ max-width: 480px;
414
+ background: rgba(48, 32, 23, 0.92);
415
+ border: 2px solid var(--primary);
416
+ border-radius: 20px;
417
+ padding: 28px 24px;
418
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
419
+ }
420
+
421
+ .start-card, .results-card { text-align: center; }
422
+
423
+ .title {
424
+ color: var(--gold);
425
+ font-size: 30px;
426
+ margin-bottom: 12px;
427
+ }
428
+
429
+ .intro {
430
+ font-size: 16px;
431
+ line-height: 1.5;
432
+ margin-bottom: 24px;
433
+ color: var(--cream);
434
+ }
435
+
436
+ /* Header: progress bar + meta row */
437
+ .quiz-header { width: 100%; max-width: 480px; }
438
+
439
+ .progress {
440
+ height: 8px;
441
+ background: rgba(255, 255, 255, 0.15);
442
+ border-radius: 99px;
443
+ overflow: hidden;
444
+ margin-bottom: 10px;
445
+ }
446
+
447
+ .progress-fill {
448
+ height: 100%;
449
+ background: var(--gold);
450
+ border-radius: 99px;
451
+ transition: width 0.3s ease;
452
+ }
453
+
454
+ .meta {
455
+ display: flex;
456
+ justify-content: space-between;
457
+ font-size: 14px;
458
+ color: var(--teal);
459
+ font-weight: 600;
460
+ }
461
+
462
+ .question {
463
+ font-size: 20px;
464
+ font-weight: 700;
465
+ color: var(--gold);
466
+ margin-bottom: 20px;
467
+ text-align: center;
468
+ line-height: 1.4;
469
+ }
470
+
471
+ .choices { display: flex; flex-direction: column; gap: 12px; }
472
+
473
+ /* Buttons */
474
+ .btn {
475
+ font-family: inherit;
476
+ font-size: 16px;
477
+ font-weight: 600;
478
+ border-radius: 12px;
479
+ border: none;
480
+ cursor: pointer;
481
+ min-height: 52px;
482
+ padding: 14px 18px;
483
+ transition: transform 0.05s ease, background 0.2s ease, border-color 0.2s ease;
484
+ }
485
+
486
+ .btn:active { transform: scale(0.98); }
487
+
488
+ .btn.primary {
489
+ background: var(--primary);
490
+ color: #fff;
491
+ width: 100%;
492
+ font-size: 18px;
493
+ }
494
+
495
+ .btn.primary:hover { background: #c98f0a; }
496
+
497
+ .choice {
498
+ background: var(--deep-green);
499
+ color: var(--cream);
500
+ border: 2px solid var(--green);
501
+ text-align: left;
502
+ width: 100%;
503
+ }
504
+
505
+ .choice:hover:not(:disabled) { border-color: var(--gold); }
506
+ .choice:disabled { cursor: default; opacity: 0.95; }
507
+
508
+ .choice.correct {
509
+ background: var(--correct);
510
+ border-color: var(--correct);
511
+ color: #fff;
512
+ }
513
+
514
+ .choice.incorrect {
515
+ background: var(--incorrect);
516
+ border-color: var(--incorrect);
517
+ color: #fff;
518
+ }
519
+
520
+ .result-score {
521
+ font-size: 48px;
522
+ font-weight: 800;
523
+ color: var(--gold);
524
+ margin: 12px 0 4px;
525
+ }
526
+
527
+ .result-pct {
528
+ font-size: 18px;
529
+ color: var(--teal);
530
+ margin-bottom: 24px;
531
+ }
532
+ `;
533
+ }
534
+
535
+ function generateQuizReadme(name, description, ageGroup) {
536
+ return `# ${name}
537
+
538
+ ${description}
539
+
540
+ - **Game Type**: Quiz
541
+ - **Target Age Group**: ${ageGroup}
542
+ - **Built with**: MyVillageOS CLI
543
+
544
+ A 2D quiz game — no 3D engine. Edit two files to make it yours:
545
+
546
+ - **src/questions.js** — your questions, answers, and quiz settings (title, pass mark, feedback)
547
+ - **src/styles.css** — colors, fonts, and layout
548
+
549
+ ## Getting Started
550
+
551
+ \`\`\`bash
552
+ npm install
553
+ npm run dev # play it locally with live reload
554
+ npm run build # build for production (outputs to dist/)
555
+ \`\`\`
556
+
557
+ ## Editing questions
558
+
559
+ Open \`src/questions.js\`. Each question is an object with the prompt, the list of
560
+ \`choices\`, and \`correct\` — the index of the right choice (0 = the first):
561
+
562
+ \`\`\`js
563
+ {
564
+ question: 'Which planet is closest to the Sun?',
565
+ choices: ['Venus', 'Mercury', 'Earth', 'Mars'],
566
+ correct: 1, // 'Mercury'
567
+ }
568
+ \`\`\`
569
+
570
+ \`quizConfig\` at the top of the file controls the title, intro text, the pass
571
+ threshold (fraction correct needed to "win"), and whether to flash right/wrong
572
+ feedback after each answer.
573
+
574
+ ## The game contract
575
+
576
+ When the quiz finishes it calls \`MyVillageGame.complete({ score, won })\`, so the
577
+ MyVillage host records the session and awards the player this game's MVT reward.
578
+ \`MyVillageGame.ready()\` is called on load. During \`npm run dev\` there's no host
579
+ attached, so these just log to the console — that's expected.
580
+
581
+ ## Deploying
582
+
583
+ Starter cover art ships in \`assets/\` — swap in your own thumbnail.png + banner.png, then:
584
+
585
+ \`\`\`bash
586
+ myvillage deploy
587
+ \`\`\`
588
+
589
+ ## Learn More
590
+
591
+ - [Vite Documentation](https://vitejs.dev/)
592
+ - [MyVillageOS Developer Guide](https://portal.myvillageproject.ai)
593
+ `;
594
+ }
595
+
106
596
  // ── Unity Game Project ─────────────────────────────────────────────
107
597
 
108
598
  export async function createUnityGameProject(targetDir, options) {
@@ -180,6 +670,9 @@ export async function createUnityGameProject(targetDir, options) {
180
670
  writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs.meta'), generateUnityScriptMeta());
181
671
 
182
672
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
673
+ // Unity has no quiz/exploration/narrative type — categories fall back to the
674
+ // generic "custom" starter art inside copyTemplateArt.
675
+ copyTemplateArt(targetDir, type, orientation);
183
676
  writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
184
677
  writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
185
678
 
@@ -456,6 +949,30 @@ function generatePackageJson(name, description, slug, type, ageGroup, orientatio
456
949
  }
457
950
 
458
951
  // Drop-zone guidance for cover art that ships with the submission.
952
+ // Copy the bundled starter cover art for this template into the scaffold's
953
+ // assets/ folder as thumbnail.png + banner.png — the paths declared in the
954
+ // myvillage.assets block, so `myvillage deploy` ships them out of the box.
955
+ // Devs replace these with their own before deploying. The thumbnail is always
956
+ // square; the banner matches orientation (9:16 portrait / 16:9 landscape).
957
+ // Unknown types (e.g. Unity categories like "puzzle") fall back to the generic
958
+ // "custom" art. Missing source files are skipped silently so a packaging gap
959
+ // never breaks scaffolding.
960
+ function copyTemplateArt(targetDir, type, orientation) {
961
+ const KNOWN_TYPES = ['quiz', 'exploration', 'narrative', 'custom'];
962
+ const artType = KNOWN_TYPES.includes(type) ? type : 'custom';
963
+ const bannerVariant = orientation === 'portrait' ? 'portrait' : 'landscape';
964
+ const copies = [
965
+ [`${artType}-thumbnail.png`, 'thumbnail.png'],
966
+ [`${artType}-${bannerVariant}.png`, 'banner.png'],
967
+ ];
968
+ for (const [src, dest] of copies) {
969
+ const srcPath = join(GAME_ART_DIR, src);
970
+ if (existsSync(srcPath)) {
971
+ copyFileSync(srcPath, join(targetDir, 'assets', dest));
972
+ }
973
+ }
974
+ }
975
+
459
976
  function generateAssetsReadme(orientation) {
460
977
  const thumb = orientation === 'portrait'
461
978
  ? 'Square (1:1, e.g. 512×512) — shown on game cards in muni and on the web.'
@@ -465,13 +982,18 @@ function generateAssetsReadme(orientation) {
465
982
  : 'Landscape hero (16:9, e.g. 1920×1080) — featured banner on the web.';
466
983
  return `# Submission art
467
984
 
468
- These files ship with \`myvillage deploy\` (declared in the \`myvillage.assets\` block of package.json).
985
+ This template ships with starter **thumbnail.png** and **banner.png** so your
986
+ game has cover art the moment you deploy. They're declared in the
987
+ \`myvillage.assets\` block and uploaded by \`myvillage deploy\`. Swap them for your
988
+ own art before launch — keep the same filenames (or update the paths in the
989
+ config) and these dimensions:
469
990
 
470
991
  - **thumbnail.png** — ${thumb}
471
992
  - **banner.png** — ${banner}
472
993
 
473
- Replace the placeholders before deploying. The CLI checks aspect ratio against
474
- this game's orientation (\`${orientation}\`) and warns on a mismatch.
994
+ The CLI checks each image's aspect ratio against this game's orientation
995
+ (\`${orientation}\`) and warns on a mismatch — it never blocks, so the sizes above
996
+ are recommended targets, not hard limits.
475
997
  `;
476
998
  }
477
999
 
@@ -607,8 +1129,9 @@ function generateIndexHtml(name, orientation = 'landscape') {
607
1129
  width: min(100vw, calc(100vh * 9 / 16));
608
1130
  height: min(100vh, calc(100vw * 16 / 9));
609
1131
  margin: 0 auto;
1132
+ overflow: hidden;
610
1133
  }`
611
- : `#game-container { width: 100%; height: 100%; position: relative; }`;
1134
+ : `#game-container { width: 100%; height: 100%; position: relative; overflow: hidden; }`;
612
1135
  return `<!DOCTYPE html>
613
1136
  <html lang="en">
614
1137
  <head>
@@ -697,9 +1220,12 @@ class Game {
697
1220
  }
698
1221
 
699
1222
  init() {
700
- // Set up renderer with responsive canvas
1223
+ // Set up renderer with responsive canvas. Size to the #game-container, not
1224
+ // the window: portrait games are framed in a centered 9:16 box that is much
1225
+ // narrower than a landscape desktop window. Measuring the window would make
1226
+ // the canvas overflow the frame and render in the wrong aspect ratio.
701
1227
  this.renderer = new THREE.WebGLRenderer({ antialias: true });
702
- this.renderer.setSize(window.innerWidth, window.innerHeight);
1228
+ this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
703
1229
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
704
1230
  this.renderer.shadowMap.enabled = true;
705
1231
  this.container.appendChild(this.renderer.domElement);
@@ -711,8 +1237,10 @@ class Game {
711
1237
  this.gameLogic = new GameLogic(this);
712
1238
  this.ui = new UI(this);
713
1239
 
714
- // Handle window resize
1240
+ // Handle window resize, and sync the camera/renderer to the container size
1241
+ // once now (the container's final size is known after the canvas is added).
715
1242
  window.addEventListener('resize', () => this.onResize());
1243
+ this.onResize();
716
1244
 
717
1245
  // Simulate asset loading (replace with real asset loading in your game)
718
1246
  this.loadAssets().then(() => {
@@ -747,8 +1275,10 @@ class Game {
747
1275
  }
748
1276
 
749
1277
  onResize() {
750
- const width = window.innerWidth;
751
- const height = window.innerHeight;
1278
+ // Match the container (the 9:16 frame for portrait, full viewport for
1279
+ // landscape) rather than the raw window.
1280
+ const width = this.container.clientWidth;
1281
+ const height = this.container.clientHeight;
752
1282
 
753
1283
  this.scene.camera.aspect = width / height;
754
1284
  this.scene.camera.updateProjectionMatrix();
@@ -902,12 +1432,14 @@ export class MainScene {
902
1432
  this.scene = new THREE.Scene();
903
1433
  this.scene.background = new THREE.Color(0x87CEEB); // Sky blue
904
1434
 
905
- // Camera setup with perspective projection
1435
+ // Camera setup with perspective projection. The aspect is a placeholder —
1436
+ // Game.onResize() sets the real value from the #game-container the moment
1437
+ // the renderer is attached (and on every resize after).
906
1438
  this.camera = new THREE.PerspectiveCamera(
907
- 60, // FOV
908
- window.innerWidth / window.innerHeight, // Aspect ratio
909
- 0.1, // Near plane
910
- 1000 // Far plane
1439
+ 60, // FOV
1440
+ 1, // Aspect ratio (corrected by Game.onResize)
1441
+ 0.1, // Near plane
1442
+ 1000 // Far plane
911
1443
  );
912
1444
  this.camera.position.set(0, 5, 10);
913
1445
  this.camera.lookAt(0, 0, 0);
@@ -991,6 +1523,11 @@ export class InputManager {
991
1523
  this.touches = [];
992
1524
  this.isTouchDevice = 'ontouchstart' in window;
993
1525
 
1526
+ // Virtual joystick state for mobile movement. { x, z } in the range -1..1,
1527
+ // with active === true while a finger is on the stick. Stays null until a
1528
+ // movement-based game calls enableJoystick(), and on desktop.
1529
+ this.joystick = null;
1530
+
994
1531
  this.setupEventListeners();
995
1532
  }
996
1533
 
@@ -1007,11 +1544,23 @@ export class InputManager {
1007
1544
  this.keys[e.code] = false;
1008
1545
  });
1009
1546
 
1547
+ // Normalized device coordinates (-1 to +1), relative to the canvas rect.
1548
+ // The canvas may be a centered 9:16 frame inside a larger window (portrait
1549
+ // games), so pointer math must be canvas-relative, not window-relative, or
1550
+ // raycasting/picking will be offset.
1551
+ const toNDC = (clientX, clientY) => {
1552
+ const rect = this.canvas.getBoundingClientRect();
1553
+ return {
1554
+ x: ((clientX - rect.left) / rect.width) * 2 - 1,
1555
+ y: -((clientY - rect.top) / rect.height) * 2 + 1,
1556
+ };
1557
+ };
1558
+
1010
1559
  // Mouse events
1011
1560
  this.canvas.addEventListener('mousemove', (e) => {
1012
- // Normalized device coordinates (-1 to +1)
1013
- this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
1014
- this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
1561
+ const { x, y } = toNDC(e.clientX, e.clientY);
1562
+ this.mouse.x = x;
1563
+ this.mouse.y = y;
1015
1564
  });
1016
1565
 
1017
1566
  this.canvas.addEventListener('mousedown', () => {
@@ -1023,32 +1572,129 @@ export class InputManager {
1023
1572
  });
1024
1573
 
1025
1574
  // Touch events
1026
- this.canvas.addEventListener('touchstart', (e) => {
1027
- this.touches = Array.from(e.touches).map((t) => ({
1028
- x: (t.clientX / window.innerWidth) * 2 - 1,
1029
- y: -(t.clientY / window.innerHeight) * 2 + 1,
1575
+ const mapTouches = (touchList) =>
1576
+ Array.from(touchList).map((t) => ({
1577
+ ...toNDC(t.clientX, t.clientY),
1030
1578
  id: t.identifier,
1031
1579
  }));
1580
+
1581
+ this.canvas.addEventListener('touchstart', (e) => {
1582
+ this.touches = mapTouches(e.touches);
1032
1583
  });
1033
1584
 
1034
1585
  this.canvas.addEventListener('touchmove', (e) => {
1035
1586
  e.preventDefault();
1036
- this.touches = Array.from(e.touches).map((t) => ({
1037
- x: (t.clientX / window.innerWidth) * 2 - 1,
1038
- y: -(t.clientY / window.innerHeight) * 2 + 1,
1039
- id: t.identifier,
1040
- }));
1587
+ this.touches = mapTouches(e.touches);
1041
1588
  }, { passive: false });
1042
1589
 
1043
1590
  this.canvas.addEventListener('touchend', (e) => {
1044
- this.touches = Array.from(e.touches).map((t) => ({
1045
- x: (t.clientX / window.innerWidth) * 2 - 1,
1046
- y: -(t.clientY / window.innerHeight) * 2 + 1,
1047
- id: t.identifier,
1048
- }));
1591
+ this.touches = mapTouches(e.touches);
1049
1592
  });
1050
1593
  }
1051
1594
 
1595
+ // Add an on-screen thumbstick for movement, for games that use
1596
+ // getMovementVector(). Call this from your GameLogic.start() — the
1597
+ // exploration template already does. No-op on desktop and if already enabled.
1598
+ //
1599
+ // The stick is a DOM overlay (not drawn in the 3D scene), so it survives any
1600
+ // camera/render changes; it's anchored bottom-left inside #game-container,
1601
+ // clear of iOS safe-area insets. Pointer Events drive it, so it also works in
1602
+ // a desktop browser's mobile emulation for testing.
1603
+ enableJoystick() {
1604
+ if (!this.isTouchDevice || this.joystick) return;
1605
+
1606
+ const container = this.canvas.parentElement || document.body;
1607
+
1608
+ const base = document.createElement('div');
1609
+ Object.assign(base.style, {
1610
+ position: 'absolute',
1611
+ left: 'calc(24px + env(safe-area-inset-left, 0px))',
1612
+ bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
1613
+ width: '120px',
1614
+ height: '120px',
1615
+ borderRadius: '50%',
1616
+ background: 'rgba(255, 255, 255, 0.15)',
1617
+ border: '2px solid rgba(255, 255, 255, 0.4)',
1618
+ touchAction: 'none',
1619
+ pointerEvents: 'auto',
1620
+ zIndex: '50',
1621
+ userSelect: 'none',
1622
+ webkitUserSelect: 'none',
1623
+ });
1624
+
1625
+ const knob = document.createElement('div');
1626
+ Object.assign(knob.style, {
1627
+ position: 'absolute',
1628
+ left: '50%',
1629
+ top: '50%',
1630
+ width: '56px',
1631
+ height: '56px',
1632
+ marginLeft: '-28px',
1633
+ marginTop: '-28px',
1634
+ borderRadius: '50%',
1635
+ background: 'rgba(255, 215, 0, 0.85)',
1636
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4)',
1637
+ pointerEvents: 'none',
1638
+ });
1639
+ base.appendChild(knob);
1640
+ container.appendChild(base);
1641
+
1642
+ const state = { x: 0, z: 0, active: false };
1643
+ this.joystick = state;
1644
+
1645
+ const maxDist = 60; // how far (px) the knob travels from center
1646
+ let center = { x: 0, y: 0 };
1647
+ let pointerId = null;
1648
+
1649
+ const recenter = () => {
1650
+ const r = base.getBoundingClientRect();
1651
+ center = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1652
+ };
1653
+
1654
+ const setKnob = (dx, dy) => {
1655
+ knob.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
1656
+ };
1657
+
1658
+ base.addEventListener('pointerdown', (e) => {
1659
+ pointerId = e.pointerId;
1660
+ base.setPointerCapture(pointerId);
1661
+ recenter();
1662
+ state.active = true;
1663
+ e.preventDefault();
1664
+ });
1665
+
1666
+ base.addEventListener('pointermove', (e) => {
1667
+ if (!state.active || e.pointerId !== pointerId) return;
1668
+ let dx = e.clientX - center.x;
1669
+ let dy = e.clientY - center.y;
1670
+ const dist = Math.hypot(dx, dy);
1671
+ if (dist > maxDist) {
1672
+ dx = (dx / dist) * maxDist;
1673
+ dy = (dy / dist) * maxDist;
1674
+ }
1675
+ setKnob(dx, dy);
1676
+ // Screen up (dy < 0) maps to forward (-z) and screen right (dx > 0) to
1677
+ // +x — the same axes the keyboard uses, so movement feels identical.
1678
+ state.x = dx / maxDist;
1679
+ state.z = dy / maxDist;
1680
+ e.preventDefault();
1681
+ });
1682
+
1683
+ const release = (e) => {
1684
+ if (e.pointerId !== pointerId) return;
1685
+ state.active = false;
1686
+ state.x = 0;
1687
+ state.z = 0;
1688
+ pointerId = null;
1689
+ setKnob(0, 0);
1690
+ };
1691
+ base.addEventListener('pointerup', release);
1692
+ base.addEventListener('pointercancel', release);
1693
+
1694
+ // Keep the cached center correct if the frame is resized/rotated.
1695
+ window.addEventListener('resize', recenter);
1696
+ }
1697
+
1052
1698
  // Check if a key is currently held down
1053
1699
  isKeyDown(code) {
1054
1700
  return !!this.keys[code];
@@ -1059,8 +1705,15 @@ export class InputManager {
1059
1705
  return !!this.keysJustPressed[code];
1060
1706
  }
1061
1707
 
1062
- // Get WASD / arrow key movement vector
1708
+ // Movement vector for the game loop. Mobile virtual joystick takes priority
1709
+ // when engaged; otherwise falls back to WASD / arrow keys on desktop.
1063
1710
  getMovementVector() {
1711
+ // The joystick vector is already analog (magnitude <= 1), so return it as-is
1712
+ // for smooth, speed-proportional movement.
1713
+ if (this.joystick && this.joystick.active) {
1714
+ return { x: this.joystick.x, z: this.joystick.z };
1715
+ }
1716
+
1064
1717
  let x = 0;
1065
1718
  let z = 0;
1066
1719
 
@@ -1613,6 +2266,9 @@ export class GameLogic {
1613
2266
  this.collectedItems = [];
1614
2267
  this.setupPlayer();
1615
2268
  this.spawnCollectibles();
2269
+
2270
+ // Show the on-screen movement joystick on touch devices (no-op on desktop).
2271
+ this.game.input.enableJoystick();
1616
2272
  }
1617
2273
 
1618
2274
  reset() {