@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 +1 -1
- package/src/assets/game-art/custom-landscape.png +0 -0
- package/src/assets/game-art/custom-portrait.png +0 -0
- package/src/assets/game-art/custom-thumbnail.png +0 -0
- package/src/assets/game-art/exploration-landscape.png +0 -0
- package/src/assets/game-art/exploration-portrait.png +0 -0
- package/src/assets/game-art/exploration-thumbnail.png +0 -0
- package/src/assets/game-art/narrative-landscape.png +0 -0
- package/src/assets/game-art/narrative-portrait.png +0 -0
- package/src/assets/game-art/narrative-thumbnail.png +0 -0
- package/src/assets/game-art/quiz-landscape.png +0 -0
- package/src/assets/game-art/quiz-portrait.png +0 -0
- package/src/assets/game-art/quiz-thumbnail.png +0 -0
- package/src/utils/templates.js +690 -34
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/utils/templates.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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(
|
|
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
|
-
|
|
751
|
-
|
|
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,
|
|
908
|
-
|
|
909
|
-
0.1,
|
|
910
|
-
1000
|
|
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
|
-
|
|
1013
|
-
this.mouse.x =
|
|
1014
|
-
this.mouse.y =
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
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() {
|