@myvillage/cli 1.62.0 → 1.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.62.0",
3
+ "version": "1.63.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,10 +87,9 @@ export async function createGameCommand(options = {}) {
87
87
  name: 'ageGroup',
88
88
  message: 'Target age group:',
89
89
  choices: [
90
- { name: '5-8 years', value: '5-8' },
91
- { name: '9-12 years', value: '9-12' },
92
- { name: '13-15 years', value: '13-15' },
93
- { name: '16-18 years', value: '16-18' },
90
+ { name: 'Under 5', value: 'under-5' },
91
+ { name: '6-12', value: '6-12' },
92
+ { name: '13 and Over', value: '13-and-over' },
94
93
  ],
95
94
  },
96
95
  ]);
@@ -129,6 +128,7 @@ export async function createGameCommand(options = {}) {
129
128
  console.log(brand.teal(' Next steps:'));
130
129
  console.log();
131
130
  console.log(` ${brand.gold('cd')} ${slug}`);
131
+ console.log(` ${brand.gold('npm install')} ${brand.teal('- Install dependencies (already run for you; re-run after cloning)')}`);
132
132
  console.log(` ${brand.gold('npm run dev')} ${brand.teal('- Start development server')}`);
133
133
  console.log(` ${brand.gold('npm run build')} ${brand.teal('- Build for production')}`);
134
134
  console.log(` ${brand.gold('myvillage deploy')} ${brand.teal('- Deploy to MyVillageOS')}`);
@@ -182,10 +182,9 @@ async function createUnityGame(options = {}) {
182
182
  name: 'ageGroup',
183
183
  message: 'Target age group:',
184
184
  choices: [
185
- { name: '5-8 years', value: '5-8' },
186
- { name: '9-12 years', value: '9-12' },
187
- { name: '13-15 years', value: '13-15' },
188
- { name: '16-18 years', value: '16-18' },
185
+ { name: 'Under 5', value: 'under-5' },
186
+ { name: '6-12', value: '6-12' },
187
+ { name: '13 and Over', value: '13-and-over' },
189
188
  ],
190
189
  },
191
190
  ]);
@@ -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';
@@ -77,6 +83,7 @@ export function createGameProject(targetDir, options) {
77
83
  writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
78
84
  writeFileSync(join(targetDir, 'index.html'), generateIndexHtml(name, orientation));
79
85
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
86
+ copyTemplateArt(targetDir, type, orientation);
80
87
 
81
88
  // Write base source files
82
89
  writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
@@ -180,6 +187,9 @@ export async function createUnityGameProject(targetDir, options) {
180
187
  writeFileSync(join(targetDir, 'Assets/Editor/MyVillageOrientation.cs.meta'), generateUnityScriptMeta());
181
188
 
182
189
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
190
+ // Unity has no quiz/exploration/narrative type — categories fall back to the
191
+ // generic "custom" starter art inside copyTemplateArt.
192
+ copyTemplateArt(targetDir, type, orientation);
183
193
  writeFileSync(join(targetDir, '.gitignore'), generateUnityGitignore());
184
194
  writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, orientation, unityVersion));
185
195
 
@@ -456,6 +466,30 @@ function generatePackageJson(name, description, slug, type, ageGroup, orientatio
456
466
  }
457
467
 
458
468
  // Drop-zone guidance for cover art that ships with the submission.
469
+ // Copy the bundled starter cover art for this template into the scaffold's
470
+ // assets/ folder as thumbnail.png + banner.png — the paths declared in the
471
+ // myvillage.assets block, so `myvillage deploy` ships them out of the box.
472
+ // Devs replace these with their own before deploying. The thumbnail is always
473
+ // square; the banner matches orientation (9:16 portrait / 16:9 landscape).
474
+ // Unknown types (e.g. Unity categories like "puzzle") fall back to the generic
475
+ // "custom" art. Missing source files are skipped silently so a packaging gap
476
+ // never breaks scaffolding.
477
+ function copyTemplateArt(targetDir, type, orientation) {
478
+ const KNOWN_TYPES = ['quiz', 'exploration', 'narrative', 'custom'];
479
+ const artType = KNOWN_TYPES.includes(type) ? type : 'custom';
480
+ const bannerVariant = orientation === 'portrait' ? 'portrait' : 'landscape';
481
+ const copies = [
482
+ [`${artType}-thumbnail.png`, 'thumbnail.png'],
483
+ [`${artType}-${bannerVariant}.png`, 'banner.png'],
484
+ ];
485
+ for (const [src, dest] of copies) {
486
+ const srcPath = join(GAME_ART_DIR, src);
487
+ if (existsSync(srcPath)) {
488
+ copyFileSync(srcPath, join(targetDir, 'assets', dest));
489
+ }
490
+ }
491
+ }
492
+
459
493
  function generateAssetsReadme(orientation) {
460
494
  const thumb = orientation === 'portrait'
461
495
  ? 'Square (1:1, e.g. 512×512) — shown on game cards in muni and on the web.'
@@ -465,13 +499,18 @@ function generateAssetsReadme(orientation) {
465
499
  : 'Landscape hero (16:9, e.g. 1920×1080) — featured banner on the web.';
466
500
  return `# Submission art
467
501
 
468
- These files ship with \`myvillage deploy\` (declared in the \`myvillage.assets\` block of package.json).
502
+ This template ships with starter **thumbnail.png** and **banner.png** so your
503
+ game has cover art the moment you deploy. They're declared in the
504
+ \`myvillage.assets\` block and uploaded by \`myvillage deploy\`. Swap them for your
505
+ own art before launch — keep the same filenames (or update the paths in the
506
+ config) and these dimensions:
469
507
 
470
508
  - **thumbnail.png** — ${thumb}
471
509
  - **banner.png** — ${banner}
472
510
 
473
- Replace the placeholders before deploying. The CLI checks aspect ratio against
474
- this game's orientation (\`${orientation}\`) and warns on a mismatch.
511
+ The CLI checks each image's aspect ratio against this game's orientation
512
+ (\`${orientation}\`) and warns on a mismatch — it never blocks, so the sizes above
513
+ are recommended targets, not hard limits.
475
514
  `;
476
515
  }
477
516
 
@@ -607,8 +646,9 @@ function generateIndexHtml(name, orientation = 'landscape') {
607
646
  width: min(100vw, calc(100vh * 9 / 16));
608
647
  height: min(100vh, calc(100vw * 16 / 9));
609
648
  margin: 0 auto;
649
+ overflow: hidden;
610
650
  }`
611
- : `#game-container { width: 100%; height: 100%; position: relative; }`;
651
+ : `#game-container { width: 100%; height: 100%; position: relative; overflow: hidden; }`;
612
652
  return `<!DOCTYPE html>
613
653
  <html lang="en">
614
654
  <head>
@@ -697,9 +737,12 @@ class Game {
697
737
  }
698
738
 
699
739
  init() {
700
- // Set up renderer with responsive canvas
740
+ // Set up renderer with responsive canvas. Size to the #game-container, not
741
+ // the window: portrait games are framed in a centered 9:16 box that is much
742
+ // narrower than a landscape desktop window. Measuring the window would make
743
+ // the canvas overflow the frame and render in the wrong aspect ratio.
701
744
  this.renderer = new THREE.WebGLRenderer({ antialias: true });
702
- this.renderer.setSize(window.innerWidth, window.innerHeight);
745
+ this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
703
746
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
704
747
  this.renderer.shadowMap.enabled = true;
705
748
  this.container.appendChild(this.renderer.domElement);
@@ -711,8 +754,10 @@ class Game {
711
754
  this.gameLogic = new GameLogic(this);
712
755
  this.ui = new UI(this);
713
756
 
714
- // Handle window resize
757
+ // Handle window resize, and sync the camera/renderer to the container size
758
+ // once now (the container's final size is known after the canvas is added).
715
759
  window.addEventListener('resize', () => this.onResize());
760
+ this.onResize();
716
761
 
717
762
  // Simulate asset loading (replace with real asset loading in your game)
718
763
  this.loadAssets().then(() => {
@@ -747,8 +792,10 @@ class Game {
747
792
  }
748
793
 
749
794
  onResize() {
750
- const width = window.innerWidth;
751
- const height = window.innerHeight;
795
+ // Match the container (the 9:16 frame for portrait, full viewport for
796
+ // landscape) rather than the raw window.
797
+ const width = this.container.clientWidth;
798
+ const height = this.container.clientHeight;
752
799
 
753
800
  this.scene.camera.aspect = width / height;
754
801
  this.scene.camera.updateProjectionMatrix();
@@ -902,12 +949,14 @@ export class MainScene {
902
949
  this.scene = new THREE.Scene();
903
950
  this.scene.background = new THREE.Color(0x87CEEB); // Sky blue
904
951
 
905
- // Camera setup with perspective projection
952
+ // Camera setup with perspective projection. The aspect is a placeholder —
953
+ // Game.onResize() sets the real value from the #game-container the moment
954
+ // the renderer is attached (and on every resize after).
906
955
  this.camera = new THREE.PerspectiveCamera(
907
- 60, // FOV
908
- window.innerWidth / window.innerHeight, // Aspect ratio
909
- 0.1, // Near plane
910
- 1000 // Far plane
956
+ 60, // FOV
957
+ 1, // Aspect ratio (corrected by Game.onResize)
958
+ 0.1, // Near plane
959
+ 1000 // Far plane
911
960
  );
912
961
  this.camera.position.set(0, 5, 10);
913
962
  this.camera.lookAt(0, 0, 0);
@@ -991,6 +1040,11 @@ export class InputManager {
991
1040
  this.touches = [];
992
1041
  this.isTouchDevice = 'ontouchstart' in window;
993
1042
 
1043
+ // Virtual joystick state for mobile movement. { x, z } in the range -1..1,
1044
+ // with active === true while a finger is on the stick. Stays null until a
1045
+ // movement-based game calls enableJoystick(), and on desktop.
1046
+ this.joystick = null;
1047
+
994
1048
  this.setupEventListeners();
995
1049
  }
996
1050
 
@@ -1007,11 +1061,23 @@ export class InputManager {
1007
1061
  this.keys[e.code] = false;
1008
1062
  });
1009
1063
 
1064
+ // Normalized device coordinates (-1 to +1), relative to the canvas rect.
1065
+ // The canvas may be a centered 9:16 frame inside a larger window (portrait
1066
+ // games), so pointer math must be canvas-relative, not window-relative, or
1067
+ // raycasting/picking will be offset.
1068
+ const toNDC = (clientX, clientY) => {
1069
+ const rect = this.canvas.getBoundingClientRect();
1070
+ return {
1071
+ x: ((clientX - rect.left) / rect.width) * 2 - 1,
1072
+ y: -((clientY - rect.top) / rect.height) * 2 + 1,
1073
+ };
1074
+ };
1075
+
1010
1076
  // Mouse events
1011
1077
  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;
1078
+ const { x, y } = toNDC(e.clientX, e.clientY);
1079
+ this.mouse.x = x;
1080
+ this.mouse.y = y;
1015
1081
  });
1016
1082
 
1017
1083
  this.canvas.addEventListener('mousedown', () => {
@@ -1023,32 +1089,129 @@ export class InputManager {
1023
1089
  });
1024
1090
 
1025
1091
  // 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,
1092
+ const mapTouches = (touchList) =>
1093
+ Array.from(touchList).map((t) => ({
1094
+ ...toNDC(t.clientX, t.clientY),
1030
1095
  id: t.identifier,
1031
1096
  }));
1097
+
1098
+ this.canvas.addEventListener('touchstart', (e) => {
1099
+ this.touches = mapTouches(e.touches);
1032
1100
  });
1033
1101
 
1034
1102
  this.canvas.addEventListener('touchmove', (e) => {
1035
1103
  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
- }));
1104
+ this.touches = mapTouches(e.touches);
1041
1105
  }, { passive: false });
1042
1106
 
1043
1107
  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
- }));
1108
+ this.touches = mapTouches(e.touches);
1049
1109
  });
1050
1110
  }
1051
1111
 
1112
+ // Add an on-screen thumbstick for movement, for games that use
1113
+ // getMovementVector(). Call this from your GameLogic.start() — the
1114
+ // exploration template already does. No-op on desktop and if already enabled.
1115
+ //
1116
+ // The stick is a DOM overlay (not drawn in the 3D scene), so it survives any
1117
+ // camera/render changes; it's anchored bottom-left inside #game-container,
1118
+ // clear of iOS safe-area insets. Pointer Events drive it, so it also works in
1119
+ // a desktop browser's mobile emulation for testing.
1120
+ enableJoystick() {
1121
+ if (!this.isTouchDevice || this.joystick) return;
1122
+
1123
+ const container = this.canvas.parentElement || document.body;
1124
+
1125
+ const base = document.createElement('div');
1126
+ Object.assign(base.style, {
1127
+ position: 'absolute',
1128
+ left: 'calc(24px + env(safe-area-inset-left, 0px))',
1129
+ bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
1130
+ width: '120px',
1131
+ height: '120px',
1132
+ borderRadius: '50%',
1133
+ background: 'rgba(255, 255, 255, 0.15)',
1134
+ border: '2px solid rgba(255, 255, 255, 0.4)',
1135
+ touchAction: 'none',
1136
+ pointerEvents: 'auto',
1137
+ zIndex: '50',
1138
+ userSelect: 'none',
1139
+ webkitUserSelect: 'none',
1140
+ });
1141
+
1142
+ const knob = document.createElement('div');
1143
+ Object.assign(knob.style, {
1144
+ position: 'absolute',
1145
+ left: '50%',
1146
+ top: '50%',
1147
+ width: '56px',
1148
+ height: '56px',
1149
+ marginLeft: '-28px',
1150
+ marginTop: '-28px',
1151
+ borderRadius: '50%',
1152
+ background: 'rgba(255, 215, 0, 0.85)',
1153
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4)',
1154
+ pointerEvents: 'none',
1155
+ });
1156
+ base.appendChild(knob);
1157
+ container.appendChild(base);
1158
+
1159
+ const state = { x: 0, z: 0, active: false };
1160
+ this.joystick = state;
1161
+
1162
+ const maxDist = 60; // how far (px) the knob travels from center
1163
+ let center = { x: 0, y: 0 };
1164
+ let pointerId = null;
1165
+
1166
+ const recenter = () => {
1167
+ const r = base.getBoundingClientRect();
1168
+ center = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1169
+ };
1170
+
1171
+ const setKnob = (dx, dy) => {
1172
+ knob.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
1173
+ };
1174
+
1175
+ base.addEventListener('pointerdown', (e) => {
1176
+ pointerId = e.pointerId;
1177
+ base.setPointerCapture(pointerId);
1178
+ recenter();
1179
+ state.active = true;
1180
+ e.preventDefault();
1181
+ });
1182
+
1183
+ base.addEventListener('pointermove', (e) => {
1184
+ if (!state.active || e.pointerId !== pointerId) return;
1185
+ let dx = e.clientX - center.x;
1186
+ let dy = e.clientY - center.y;
1187
+ const dist = Math.hypot(dx, dy);
1188
+ if (dist > maxDist) {
1189
+ dx = (dx / dist) * maxDist;
1190
+ dy = (dy / dist) * maxDist;
1191
+ }
1192
+ setKnob(dx, dy);
1193
+ // Screen up (dy < 0) maps to forward (-z) and screen right (dx > 0) to
1194
+ // +x — the same axes the keyboard uses, so movement feels identical.
1195
+ state.x = dx / maxDist;
1196
+ state.z = dy / maxDist;
1197
+ e.preventDefault();
1198
+ });
1199
+
1200
+ const release = (e) => {
1201
+ if (e.pointerId !== pointerId) return;
1202
+ state.active = false;
1203
+ state.x = 0;
1204
+ state.z = 0;
1205
+ pointerId = null;
1206
+ setKnob(0, 0);
1207
+ };
1208
+ base.addEventListener('pointerup', release);
1209
+ base.addEventListener('pointercancel', release);
1210
+
1211
+ // Keep the cached center correct if the frame is resized/rotated.
1212
+ window.addEventListener('resize', recenter);
1213
+ }
1214
+
1052
1215
  // Check if a key is currently held down
1053
1216
  isKeyDown(code) {
1054
1217
  return !!this.keys[code];
@@ -1059,8 +1222,15 @@ export class InputManager {
1059
1222
  return !!this.keysJustPressed[code];
1060
1223
  }
1061
1224
 
1062
- // Get WASD / arrow key movement vector
1225
+ // Movement vector for the game loop. Mobile virtual joystick takes priority
1226
+ // when engaged; otherwise falls back to WASD / arrow keys on desktop.
1063
1227
  getMovementVector() {
1228
+ // The joystick vector is already analog (magnitude <= 1), so return it as-is
1229
+ // for smooth, speed-proportional movement.
1230
+ if (this.joystick && this.joystick.active) {
1231
+ return { x: this.joystick.x, z: this.joystick.z };
1232
+ }
1233
+
1064
1234
  let x = 0;
1065
1235
  let z = 0;
1066
1236
 
@@ -1613,6 +1783,9 @@ export class GameLogic {
1613
1783
  this.collectedItems = [];
1614
1784
  this.setupPlayer();
1615
1785
  this.spawnCollectibles();
1786
+
1787
+ // Show the on-screen movement joystick on touch devices (no-op on desktop).
1788
+ this.game.input.enableJoystick();
1616
1789
  }
1617
1790
 
1618
1791
  reset() {