@myvillage/cli 2.6.1 → 2.8.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.
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { fileURLToPath } from 'url';
@@ -10,6 +10,13 @@ import * as tar from 'tar';
10
10
  // installed globally via npm.
11
11
  const GAME_ART_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'game-art');
12
12
 
13
+ // Real source files (not strings) for the game toolkit and the arena template,
14
+ // so they can be linted and tested: src/assets/game-toolkit, src/assets/templates.
15
+ const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets');
16
+ // Multiplayer netcode, built from the myvillage-realtime repo
17
+ // (scripts/sync-realtime-client.sh refreshes it).
18
+ const REALTIME_CLIENT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'vendor', 'realtime', 'myvillage-realtime-client.js');
19
+
13
20
  // GameKit SDK version that scaffolded Unity projects pin to.
14
21
  // Bump alongside CLI minor releases as the SDK contract evolves.
15
22
  const GAMEKIT_SDK_VERSION = '1.1.0-alpha.5';
@@ -109,6 +116,11 @@ export function createGameProject(targetDir, options) {
109
116
  createNarrativeProject(targetDir, options);
110
117
  return;
111
118
  }
119
+ // Real-time multiplayer arena: its own scaffold (lobby + live match).
120
+ if (type === 'arena') {
121
+ createArenaProject(targetDir, options);
122
+ return;
123
+ }
112
124
 
113
125
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
114
126
 
@@ -142,7 +154,7 @@ export function createGameProject(targetDir, options) {
142
154
  copyTemplateArt(targetDir, type, orientation);
143
155
 
144
156
  // Write base source files
145
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
157
+ writeGameToolkit(targetDir);
146
158
  writeFileSync(join(targetDir, 'src/main.js'), generateMainJs(type));
147
159
  writeFileSync(join(targetDir, 'src/scenes/MainScene.js'), generateMainScene(type));
148
160
  writeFileSync(join(targetDir, 'src/utils/InputManager.js'), generateInputManager());
@@ -188,7 +200,7 @@ function createQuizProject(targetDir, options) {
188
200
  writeFileSync(join(targetDir, 'src/main.js'), generateQuizApp());
189
201
  writeFileSync(join(targetDir, 'src/questions.js'), generateQuizQuestions(name));
190
202
  writeFileSync(join(targetDir, 'src/styles.css'), generateQuizStyles());
191
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
203
+ writeGameToolkit(targetDir);
192
204
 
193
205
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
194
206
  copyTemplateArt(targetDir, 'quiz', orientation);
@@ -235,7 +247,7 @@ function generate2DGameIndexHtml(name, orientation) {
235
247
  <html lang="en">
236
248
  <head>
237
249
  <meta charset="UTF-8" />
238
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
250
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
239
251
  <!-- orientation: ${orientation} -->
240
252
  <title>${name}</title>
241
253
  <link rel="stylesheet" href="/src/styles.css" />
@@ -435,10 +447,19 @@ html, body {
435
447
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
436
448
  }
437
449
 
450
+ /* The background runs edge to edge on a phone (behind the status bar and home
451
+ indicator); content stays inside the safe area. The extra top space keeps the
452
+ top-left corner clear for the host app's Exit button. The muni app injects
453
+ --mv-safe-* because Android web views don't report env() insets. */
438
454
  body {
455
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
456
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
457
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
458
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
439
459
  background: linear-gradient(160deg, var(--deep-green), var(--dark));
440
460
  color: var(--cream);
441
461
  display: flex;
462
+ padding: calc(var(--safe-top) + 44px) var(--safe-right) var(--safe-bottom) var(--safe-left);
442
463
  -webkit-font-smoothing: antialiased;
443
464
  }
444
465
 
@@ -664,12 +685,61 @@ function createNarrativeProject(targetDir, options) {
664
685
  writeFileSync(join(targetDir, 'src/main.js'), generateNarrativeApp());
665
686
  writeFileSync(join(targetDir, 'src/story.js'), generateNarrativeStory(name));
666
687
  writeFileSync(join(targetDir, 'src/styles.css'), generateNarrativeStyles());
667
- writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
688
+ writeGameToolkit(targetDir);
668
689
 
669
690
  writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
670
691
  copyTemplateArt(targetDir, 'narrative', orientation);
671
692
  }
672
693
 
694
+ // ── Arena Project (real-time multiplayer) ──────────────────────────
695
+ // A first-person arena that plays live matches on MyVillage's match servers.
696
+ // The game owns its lobby (src/lobby.js); the platform plumbing lives in the
697
+ // toolkit. src/arena.json holds the match rules + map, which the game renders
698
+ // and `myvillage deploy` sends to the platform (myvillage.multiplayer.arena).
699
+ // Source files live in src/assets/templates/arena with {{PLACEHOLDERS}}.
700
+ function createArenaProject(targetDir, options) {
701
+ const { name, description, ageGroup, orientation = 'landscape' } = options;
702
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
703
+ const src = join(ASSETS_DIR, 'templates', 'arena');
704
+ const vars = {
705
+ NAME: name,
706
+ SLUG: slug,
707
+ DESCRIPTION: description || 'A real-time multiplayer arena game.',
708
+ ORIENTATION: orientation,
709
+ GOLD: BRAND.gold,
710
+ DARK: BRAND.darkBrown,
711
+ CONTAINER_CSS: orientation === 'portrait'
712
+ ? `#game-container { position: relative; width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; }
713
+ @media (min-aspect-ratio: 3/4) { #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; } }`
714
+ : '#game-container { position: relative; width: 100%; height: 100%; overflow: hidden; }',
715
+ };
716
+ const fill = (file) => readFileSync(join(src, file), 'utf8').replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (k in vars ? vars[k] : m));
717
+
718
+ for (const dir of ['', 'src', 'assets', 'public']) {
719
+ mkdirSync(join(targetDir, dir), { recursive: true });
720
+ }
721
+
722
+ writeFileSync(join(targetDir, 'package.json'), generateArenaPackageJson(name, description, slug, ageGroup, orientation));
723
+ writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
724
+ writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
725
+ for (const file of ['index.html', 'README.md', 'AGENTS.md', 'src/main.js', 'src/lobby.js', 'src/arena-game.js', 'src/arena.json']) {
726
+ writeFileSync(join(targetDir, file), fill(file));
727
+ }
728
+ writeGameToolkit(targetDir);
729
+
730
+ writeFileSync(join(targetDir, 'assets/README.md'), generateAssetsReadme(orientation));
731
+ copyTemplateArt(targetDir, 'arena', orientation);
732
+ }
733
+
734
+ function generateArenaPackageJson(name, description, slug, ageGroup, orientation) {
735
+ const pkg = JSON.parse(generatePackageJson(name, description || 'A real-time multiplayer arena game.', slug, 'arena', ageGroup, orientation));
736
+ pkg.myvillage.category = 'action';
737
+ // Real-time multiplayer: the match rules + map `myvillage deploy` sends to
738
+ // the platform. A path to a JSON file, or the config inline.
739
+ pkg.myvillage.multiplayer = { arena: 'src/arena.json' };
740
+ return JSON.stringify(pkg, null, 2) + '\n';
741
+ }
742
+
673
743
  function generateNarrativeApp() {
674
744
  return `// Narrative Game — an interactive, choice-driven story.
675
745
  //
@@ -851,10 +921,19 @@ html, body {
851
921
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
852
922
  }
853
923
 
924
+ /* The background runs edge to edge on a phone (behind the status bar and home
925
+ indicator); content stays inside the safe area. The extra top space keeps the
926
+ top-left corner clear for the host app's Exit button. The muni app injects
927
+ --mv-safe-* because Android web views don't report env() insets. */
854
928
  body {
929
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
930
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
931
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
932
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
855
933
  background: linear-gradient(160deg, var(--deep-green), var(--dark));
856
934
  color: var(--cream);
857
935
  display: flex;
936
+ padding: calc(var(--safe-top) + 44px) var(--safe-right) var(--safe-bottom) var(--safe-left);
858
937
  -webkit-font-smoothing: antialiased;
859
938
  }
860
939
 
@@ -1041,6 +1120,7 @@ function generate2DGameAgents(name, gameType, orientation) {
1041
1120
  '| `' + contentFile + '` | **The content** — edit this first for content changes. |',
1042
1121
  '| `src/styles.css` | Styling (CSS variables in `:root`). |',
1043
1122
  '| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
1123
+ '| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
1044
1124
  '| `assets/` | Cover art shipped on deploy. |',
1045
1125
  '| `package.json` | The `myvillage` block holds metadata `myvillage deploy` reads. |',
1046
1126
  '',
@@ -1085,11 +1165,11 @@ function generate3DGameAgents(name, type, orientation) {
1085
1165
  ? '9:16 (e.g. 1080x1920) for this portrait game'
1086
1166
  : '16:9 (e.g. 1920x1080) for this landscape game';
1087
1167
  const logicLine = isExploration
1088
- ? '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.'
1168
+ ? 'Holds the gameplay — player movement (via `input.getMovementVector()`), collectible spawning + collision, the drag-to-look follow camera, score, and win condition. It calls `input.enableJoystick()` and `input.enableLookControls()` in `start()` so phones get a move stick plus drag-to-look.'
1089
1169
  : '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.';
1090
1170
  const mobileLine = isExploration
1091
- ? '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()`.'
1092
- : '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()`.';
1171
+ ? 'This game already calls `this.game.input.enableJoystick()` and `this.game.input.enableLookControls()` in `GameLogic.start()`: left thumb moves (WASD on desktop), dragging anywhere else turns the camera (mouse drag on desktop). Movement is relative to where the camera faces. Keep both whenever a character moves through the 3D world.'
1172
+ : 'If a character moves through the 3D world, give it BOTH controls in `GameLogic.start()`: `this.game.input.enableJoystick()` (on-screen move stick on touch devices, WASD on desktop; read with `getMovementVector()`) and `this.game.input.enableLookControls()` (drag to turn the camera; read with `getLookDelta()` each frame). Make movement relative to the camera\'s facing.';
1093
1173
  const uiExtra = isExploration
1094
1174
  ? ' This game also adds `showMinimap(pos, items)` and `showInventory(items)`.'
1095
1175
  : '';
@@ -1119,13 +1199,14 @@ function generate3DGameAgents(name, type, orientation) {
1119
1199
  '| `src/utils/InputManager.js` | Keyboard / mouse / touch + mobile joystick. |',
1120
1200
  '| `src/utils/AudioManager.js` | Sound effects + music (Web Audio). |',
1121
1201
  '| `src/myvillage-game.js` | Host bridge — **do not edit.** |',
1202
+ '| `src/myvillage-realtime.js` | Multiplayer netcode (unused unless the game calls `MyVillageGame.multiplayer()`) — **do not edit.** |',
1122
1203
  '| `assets/` | Cover art shipped on deploy. |',
1123
1204
  '| `public/assets/` | Drop 3D models / textures / audio here. |',
1124
1205
  '',
1125
1206
  '## Engine API (reach it as `this.game` inside GameLogic)',
1126
1207
  '',
1127
1208
  '- `this.game.scene` — `.scene` (THREE.Scene), `.camera` (THREE.PerspectiveCamera), `.sampleObject`, `.update(delta)`.',
1128
- '- `this.game.input` — `getMovementVector()` (returns `{ x, z }` from WASD/arrows or the joystick), `isKeyDown(code)`, `isKeyJustPressed(code)`, `enableJoystick()`, `clearFrame()`, `mouse`, `touches`.',
1209
+ '- `this.game.input` — `getMovementVector()` (returns `{ x, z }` from WASD/arrows or the joystick), `getLookDelta()` (drag since last frame, `{ x, y }` px), `isKeyDown(code)`, `isKeyJustPressed(code)`, `enableJoystick()`, `enableLookControls()`, `mouse`, `touches`. The loop calls `clearFrame()` for you.',
1129
1210
  '- `this.game.audio` — `playSFX(name)`, `playMusic(name)`, `stopMusic()`, `setMusicVolume(v)`, `setSFXVolume(v)`.',
1130
1211
  '- `this.game.ui` — `showMenu()`, `showHUD()`, `showPauseMenu()`, `showGameOver(result)`, `clearOverlay()`, `createElement(tag, styles)`.' + uiExtra,
1131
1212
  '- `this.game` — `startGame()`, `pauseGame()`, `resumeGame()`, `endGame(result)`, `state`.',
@@ -1134,6 +1215,12 @@ function generate3DGameAgents(name, type, orientation) {
1134
1215
  '',
1135
1216
  mobileLine,
1136
1217
  '',
1218
+ '## Screen layout (phones)',
1219
+ '',
1220
+ '- The game fills the whole screen edge to edge, including behind the status bar and home indicator. Never letterbox it or add bars; on a wide desktop window `index.html` frames it automatically.',
1221
+ '- Keep HUD inside the safe area with the CSS variables in `index.html`: top row at `var(--hud-top)`, and `var(--safe-bottom)` / `var(--safe-left)` / `var(--safe-right)` from the other edges.',
1222
+ '- Leave the **top-left corner** (`var(--exit-clear)` wide, below `var(--safe-top)`) empty: the host app draws its Exit button there. Put Pause and other top buttons on the right.',
1223
+ '',
1137
1224
  '## The MyVillage game contract — DO NOT BREAK',
1138
1225
  '',
1139
1226
  '- `MyVillageGame.ready()` — called after assets load in `src/main.js`. Leave it.',
@@ -1839,31 +1926,49 @@ cover art with the submission. An admin sets the game's MVT reward at review tim
1839
1926
 
1840
1927
  function generateIndexHtml(name, orientation = 'landscape') {
1841
1928
  const isPortrait = orientation === 'portrait';
1842
- // Portrait games are framed by a 9:16 stage so they look right both in the
1843
- // muni mobile shell (portrait WebView) and on the web. Landscape fills the
1844
- // viewport as before.
1929
+ // Portrait games fill the whole phone screen edge to edge (behind the status
1930
+ // bar and home indicator — the muni app draws a soft fade + Exit over the top
1931
+ // corner). Only on a wide screen (desktop web) is the game framed in a 9:16
1932
+ // stage so it doesn't stretch. Landscape always fills the viewport.
1845
1933
  const containerStyle = isPortrait
1846
1934
  ? `#game-container {
1847
1935
  position: relative;
1848
- width: min(100vw, calc(100vh * 9 / 16));
1849
- height: min(100vh, calc(100vw * 16 / 9));
1850
- margin: 0 auto;
1936
+ width: 100vw;
1937
+ height: 100vh;
1938
+ height: 100dvh;
1851
1939
  overflow: hidden;
1940
+ }
1941
+ @media (min-aspect-ratio: 3/4) {
1942
+ #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; }
1852
1943
  }`
1853
1944
  : `#game-container { width: 100%; height: 100%; position: relative; overflow: hidden; }`;
1854
1945
  return `<!DOCTYPE html>
1855
1946
  <html lang="en">
1856
1947
  <head>
1857
1948
  <meta charset="UTF-8" />
1858
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
1949
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
1859
1950
  <!-- orientation: ${orientation} -->
1860
1951
  <title>${name}</title>
1861
1952
  <style>
1953
+ /* Screen-edge spacing. On a phone the game runs under the status bar and
1954
+ home indicator; keep HUD inside these. The muni app also injects
1955
+ --mv-safe-* (Android web views don't report env() insets).
1956
+ --hud-top: first row of HUD buttons. The TOP-LEFT corner (--exit-clear
1957
+ wide, below --safe-top) belongs to the app's Exit button — keep it empty. */
1958
+ :root {
1959
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
1960
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
1961
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
1962
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
1963
+ --hud-top: calc(var(--safe-top) + 12px);
1964
+ --exit-clear: 64px;
1965
+ }
1862
1966
  * { margin: 0; padding: 0; box-sizing: border-box; }
1863
1967
  html, body { width: 100%; height: 100%; overflow: hidden; }
1864
1968
  body { background: ${BRAND.darkBrown}; display: flex; align-items: center; justify-content: center; }
1865
1969
  ${containerStyle}
1866
- canvas { display: block; }
1970
+ /* touch-action: none — drags on the game steer the camera, never scroll the page. */
1971
+ canvas { display: block; touch-action: none; }
1867
1972
 
1868
1973
  /* Loading screen */
1869
1974
  #loading-screen {
@@ -2040,6 +2145,8 @@ class Game {
2040
2145
  this.gameLogic.update(delta);
2041
2146
  this.scene.update(delta);
2042
2147
  }
2148
+ // One-shot input (just-pressed keys, look drag) lasts exactly one frame.
2149
+ this.input.clearFrame();
2043
2150
 
2044
2151
  this.renderer.render(this.scene.scene, this.scene.camera);
2045
2152
  }
@@ -2056,87 +2163,16 @@ window.game = game;
2056
2163
  // The universal MyVillage game contract (the "mvgame" protocol). Every
2057
2164
  // scaffolded game ships this verbatim so completion + exit behave identically
2058
2165
  // across the muni mobile app, the web (mvp-video-games), and standalone dev.
2166
+ // The game toolkit every JS game gets: src/myvillage-game.js (the mvgame host
2167
+ // bridge + multiplayer plumbing) and src/myvillage-realtime.js (match netcode,
2168
+ // only loaded by games that call MyVillageGame.multiplayer()).
2059
2169
  function generateMyVillageGameSdk() {
2060
- return `// MyVillage Game Bridge — the "mvgame" protocol (v1).
2061
- //
2062
- // ONE contract for every MyVillage game, on every surface:
2063
- // - muni mobile app (React Native WebView)
2064
- // - the web (mvp-video-games, in an iframe)
2065
- // - standalone dev (logs to the console)
2066
- //
2067
- // Two calls every game makes:
2068
- // MyVillageGame.ready() // once, when your game is interactive
2069
- // MyVillageGame.complete({ score }) // when the game is finished / won / over
2070
- //
2071
- // One the host ALWAYS provides, but you can trigger too:
2072
- // MyVillageGame.exit() // leave the game
2073
- //
2074
- // The host always renders an Exit control, so a player can leave ANY game at
2075
- // any time even if the game never calls exit() itself. Mirrors the native Unity
2076
- // contract (CompleteMission / abort) so there is a single mental model.
2077
-
2078
- const PROTOCOL = 'mvgame';
2079
- const VERSION = 1;
2080
-
2081
- function now() {
2082
- return typeof performance !== 'undefined' ? performance.now() : Date.now();
2170
+ return readFileSync(join(ASSETS_DIR, 'game-toolkit', 'myvillage-game.js'), 'utf8');
2083
2171
  }
2084
2172
 
2085
- function post(message) {
2086
- const payload = { source: PROTOCOL, version: VERSION, ...message };
2087
- const json = JSON.stringify(payload);
2088
- try {
2089
- // React Native WebView (muni mobile app)
2090
- if (typeof window !== 'undefined' && window.ReactNativeWebView) {
2091
- window.ReactNativeWebView.postMessage(json);
2092
- }
2093
- // Iframe parent (mvp-video-games web host) — only if actually embedded
2094
- if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
2095
- window.parent.postMessage(payload, '*');
2096
- }
2097
- // Standalone dev: nothing is listening, so surface it for debugging.
2098
- if (typeof window !== 'undefined' && !window.ReactNativeWebView && window.parent === window) {
2099
- console.info('[mvgame] (no host attached) ' + json);
2100
- }
2101
- } catch (e) {
2102
- console.warn('[mvgame] postMessage failed', e);
2103
- }
2104
- }
2105
-
2106
- let readySent = false;
2107
- let startedAt = now();
2108
-
2109
- export const MyVillageGame = {
2110
- /** Call once when the game has loaded and is interactive. */
2111
- ready() {
2112
- if (readySent) return;
2113
- readySent = true;
2114
- startedAt = now();
2115
- post({ type: 'ready' });
2116
- },
2117
-
2118
- /**
2119
- * Call when the game is finished. \`result\` is optional:
2120
- * { score?: number, rewards?: number, won?: boolean, custom?: object }
2121
- * \`durationMs\` is added automatically.
2122
- */
2123
- complete(result = {}) {
2124
- post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
2125
- },
2126
-
2127
- /** Request to leave the game. The host also always offers an Exit control. */
2128
- exit() {
2129
- post({ type: 'exit' });
2130
- },
2131
- };
2132
-
2133
- // Expose globally too, for non-module games and quick debugging.
2134
- if (typeof window !== 'undefined') {
2135
- window.MyVillageGame = MyVillageGame;
2136
- }
2137
-
2138
- export default MyVillageGame;
2139
- `;
2173
+ function writeGameToolkit(targetDir) {
2174
+ writeFileSync(join(targetDir, 'src/myvillage-game.js'), generateMyVillageGameSdk());
2175
+ writeFileSync(join(targetDir, 'src/myvillage-realtime.js'), readFileSync(REALTIME_CLIENT_PATH, 'utf8'));
2140
2176
  }
2141
2177
 
2142
2178
  function generateMainScene(type) {
@@ -2247,6 +2283,11 @@ export class InputManager {
2247
2283
  // movement-based game calls enableJoystick(), and on desktop.
2248
2284
  this.joystick = null;
2249
2285
 
2286
+ // Look-around drag, in pixels since the last frame. Stays inactive until a
2287
+ // 3D game calls enableLookControls(); read it with getLookDelta().
2288
+ this.lookEnabled = false;
2289
+ this.lookDelta = { x: 0, y: 0 };
2290
+
2250
2291
  this.setupEventListeners();
2251
2292
  }
2252
2293
 
@@ -2327,8 +2368,8 @@ export class InputManager {
2327
2368
  const base = document.createElement('div');
2328
2369
  Object.assign(base.style, {
2329
2370
  position: 'absolute',
2330
- left: 'calc(24px + env(safe-area-inset-left, 0px))',
2331
- bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
2371
+ left: 'calc(24px + var(--safe-left, 0px))',
2372
+ bottom: 'calc(24px + var(--safe-bottom, 0px))',
2332
2373
  width: '120px',
2333
2374
  height: '120px',
2334
2375
  borderRadius: '50%',
@@ -2414,6 +2455,66 @@ export class InputManager {
2414
2455
  window.addEventListener('resize', recenter);
2415
2456
  }
2416
2457
 
2458
+ // Drag anywhere on the game (outside the joystick) to look around — one
2459
+ // finger on a phone, or click-and-drag with a mouse. Pair it with
2460
+ // enableJoystick() in any game where a character moves through a 3D world:
2461
+ // left thumb moves, right thumb looks. Read the drag each frame with
2462
+ // getLookDelta() and turn the camera with it. No-op if already enabled.
2463
+ enableLookControls() {
2464
+ if (this.lookEnabled) return;
2465
+ this.lookEnabled = true;
2466
+
2467
+ let lookId = null;
2468
+ let last = { x: 0, y: 0 };
2469
+
2470
+ this.canvas.addEventListener('pointerdown', (e) => {
2471
+ if (lookId !== null) return;
2472
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
2473
+ lookId = e.pointerId;
2474
+ last = { x: e.clientX, y: e.clientY };
2475
+ this.canvas.setPointerCapture(lookId);
2476
+ hint.style.opacity = '0';
2477
+ });
2478
+
2479
+ this.canvas.addEventListener('pointermove', (e) => {
2480
+ if (e.pointerId !== lookId) return;
2481
+ this.lookDelta.x += e.clientX - last.x;
2482
+ this.lookDelta.y += e.clientY - last.y;
2483
+ last = { x: e.clientX, y: e.clientY };
2484
+ });
2485
+
2486
+ const release = (e) => {
2487
+ if (e.pointerId === lookId) lookId = null;
2488
+ };
2489
+ this.canvas.addEventListener('pointerup', release);
2490
+ this.canvas.addEventListener('pointercancel', release);
2491
+
2492
+ // A small hint on the right, gone after the first drag.
2493
+ const hint = document.createElement('div');
2494
+ hint.textContent = this.isTouchDevice ? 'Drag to look around' : 'Drag to look · WASD to move';
2495
+ Object.assign(hint.style, {
2496
+ position: 'absolute',
2497
+ right: 'calc(20px + var(--safe-right, 0px))',
2498
+ bottom: 'calc(64px + var(--safe-bottom, 0px))',
2499
+ padding: '6px 12px',
2500
+ borderRadius: '14px',
2501
+ background: 'rgba(0, 0, 0, 0.35)',
2502
+ color: 'rgba(255, 255, 255, 0.9)',
2503
+ font: '13px system-ui, sans-serif',
2504
+ pointerEvents: 'none',
2505
+ transition: 'opacity 0.6s',
2506
+ zIndex: '50',
2507
+ });
2508
+ (this.canvas.parentElement || document.body).appendChild(hint);
2509
+ setTimeout(() => { hint.style.opacity = '0'; }, 6000);
2510
+ }
2511
+
2512
+ // Look-around drag since the last frame, in pixels ({ x: right, y: down }).
2513
+ // Multiply by a small sensitivity (e.g. 0.005 rad/px) to turn the camera.
2514
+ getLookDelta() {
2515
+ return { x: this.lookDelta.x, y: this.lookDelta.y };
2516
+ }
2517
+
2417
2518
  // Check if a key is currently held down
2418
2519
  isKeyDown(code) {
2419
2520
  return !!this.keys[code];
@@ -2454,6 +2555,8 @@ export class InputManager {
2454
2555
  // Call at the end of each frame to clear one-shot inputs
2455
2556
  clearFrame() {
2456
2557
  this.keysJustPressed = {};
2558
+ this.lookDelta.x = 0;
2559
+ this.lookDelta.y = 0;
2457
2560
  }
2458
2561
  }
2459
2562
  `;
@@ -2558,9 +2661,10 @@ function generateUI(type) {
2558
2661
  // Minimap rendering in top-right corner
2559
2662
  let minimap = this.overlay.querySelector('#minimap');
2560
2663
  if (!minimap) {
2664
+ // Top-right, under the Pause button (the top-left corner is the app's Exit).
2561
2665
  minimap = this.createElement('canvas', {
2562
- position: 'absolute', top: '10px', right: '10px',
2563
- width: '150px', height: '150px',
2666
+ position: 'absolute', top: 'calc(var(--hud-top) + 48px)', right: 'calc(12px + var(--safe-right))',
2667
+ width: '120px', height: '120px',
2564
2668
  background: 'rgba(48,32,23,0.8)', borderRadius: '8px',
2565
2669
  border: '2px solid #B07C00',
2566
2670
  });
@@ -2571,7 +2675,7 @@ function generateUI(type) {
2571
2675
  }
2572
2676
 
2573
2677
  const ctx = minimap.getContext('2d');
2574
- ctx.clearRect(0, 0, 150, 150);
2678
+ ctx.clearRect(0, 0, 150, 150); // drawing space stays 150; CSS scales it
2575
2679
 
2576
2680
  // Draw player
2577
2681
  ctx.fillStyle = '#FFD700';
@@ -2591,19 +2695,23 @@ function generateUI(type) {
2591
2695
  showInventory(items) {
2592
2696
  let inv = this.overlay.querySelector('#inventory');
2593
2697
  if (!inv) {
2698
+ // Bottom-right, clear of the joystick (bottom-left) and the home indicator.
2699
+ // pointerEvents none so drags over it still turn the camera.
2594
2700
  inv = this.createElement('div', {
2595
- position: 'absolute', bottom: '10px', left: '50%', transform: 'translateX(-50%)',
2596
- display: 'flex', gap: '8px', background: 'rgba(48,32,23,0.85)',
2597
- padding: '10px', borderRadius: '12px',
2701
+ position: 'absolute', bottom: 'calc(12px + var(--safe-bottom))', right: 'calc(12px + var(--safe-right))',
2702
+ maxWidth: '55%', display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end',
2703
+ gap: '6px', background: 'rgba(48,32,23,0.85)',
2704
+ padding: '8px', borderRadius: '12px', pointerEvents: 'none',
2598
2705
  });
2599
2706
  inv.id = 'inventory';
2600
2707
  this.overlay.appendChild(inv);
2601
2708
  }
2602
2709
  inv.innerHTML = '';
2710
+ inv.style.display = items.length ? 'flex' : 'none';
2603
2711
 
2604
2712
  items.forEach((item) => {
2605
2713
  const slot = this.createElement('div', {
2606
- width: '48px', height: '48px', background: '#043922',
2714
+ width: '40px', height: '40px', background: '#043922',
2607
2715
  borderRadius: '8px', border: '2px solid #228B22',
2608
2716
  display: 'flex', alignItems: 'center', justifyContent: 'center',
2609
2717
  color: '#FFD700', fontSize: '12px', textAlign: 'center',
@@ -2668,8 +2776,10 @@ export class UI {
2668
2776
  showHUD() {
2669
2777
  this.clearOverlay();
2670
2778
 
2779
+ // Top-right, inside the safe area. Keep the top-left corner empty — the
2780
+ // host app draws its Exit button there.
2671
2781
  const pauseBtn = this.createElement('button', {
2672
- position: 'absolute', top: '10px', right: '10px',
2782
+ position: 'absolute', top: 'var(--hud-top)', right: 'calc(12px + var(--safe-right))',
2673
2783
  padding: '8px 16px', background: 'rgba(48,32,23,0.7)',
2674
2784
  color: '#E4DCCB', border: '1px solid #B07C00',
2675
2785
  borderRadius: '8px', cursor: 'pointer', fontSize: '14px',
@@ -2798,6 +2908,13 @@ export class GameLogic {
2798
2908
  this.player = null;
2799
2909
  this.collectibles = [];
2800
2910
  this.collectedItems = [];
2911
+
2912
+ // Follow camera: orbits the player. Drag to change yaw (turn) and pitch
2913
+ // (tilt); movement is relative to where the camera faces.
2914
+ this.cameraYaw = 0;
2915
+ this.cameraPitch = 0.55; // radians above the horizon
2916
+ this.cameraDistance = 12;
2917
+ this.lookSensitivity = 0.005; // radians per dragged pixel
2801
2918
  }
2802
2919
 
2803
2920
  start() {
@@ -2806,8 +2923,10 @@ export class GameLogic {
2806
2923
  this.setupPlayer();
2807
2924
  this.spawnCollectibles();
2808
2925
 
2809
- // Show the on-screen movement joystick on touch devices (no-op on desktop).
2926
+ // Phones: left thumb moves (joystick), drag anywhere else to look around.
2927
+ // Desktop: WASD / arrows to move, mouse drag to look.
2810
2928
  this.game.input.enableJoystick();
2929
+ this.game.input.enableLookControls();
2811
2930
  }
2812
2931
 
2813
2932
  reset() {
@@ -2828,8 +2947,19 @@ export class GameLogic {
2828
2947
  this.player.castShadow = true;
2829
2948
  this.game.scene.scene.add(this.player);
2830
2949
 
2831
- // Update camera to follow player
2832
- this.game.scene.camera.position.set(0, 8, 12);
2950
+ this.updateCamera();
2951
+ }
2952
+
2953
+ updateCamera() {
2954
+ const cam = this.game.scene.camera;
2955
+ const target = this.player.position;
2956
+ const flat = Math.cos(this.cameraPitch) * this.cameraDistance;
2957
+ cam.position.set(
2958
+ target.x + Math.sin(this.cameraYaw) * flat,
2959
+ target.y + Math.sin(this.cameraPitch) * this.cameraDistance,
2960
+ target.z + Math.cos(this.cameraYaw) * flat
2961
+ );
2962
+ cam.lookAt(target);
2833
2963
  }
2834
2964
 
2835
2965
  spawnCollectibles() {
@@ -2861,19 +2991,26 @@ export class GameLogic {
2861
2991
  update(delta) {
2862
2992
  if (!this.player) return;
2863
2993
 
2864
- // WASD movement
2994
+ // Look around: drag turns (x) and tilts (y) the camera.
2995
+ const look = this.game.input.getLookDelta();
2996
+ this.cameraYaw -= look.x * this.lookSensitivity;
2997
+ this.cameraPitch = Math.max(0.15, Math.min(1.3, this.cameraPitch + look.y * this.lookSensitivity));
2998
+
2999
+ // Move relative to the camera: stick up / W walks away from the camera.
2865
3000
  const movement = this.game.input.getMovementVector();
2866
- this.player.position.x += movement.x * this.moveSpeed * delta;
2867
- this.player.position.z += movement.z * this.moveSpeed * delta;
3001
+ const sin = Math.sin(this.cameraYaw);
3002
+ const cos = Math.cos(this.cameraYaw);
3003
+ const dx = movement.x * cos + movement.z * sin;
3004
+ const dz = -movement.x * sin + movement.z * cos;
3005
+ this.player.position.x += dx * this.moveSpeed * delta;
3006
+ this.player.position.z += dz * this.moveSpeed * delta;
3007
+ if (dx !== 0 || dz !== 0) this.player.rotation.y = Math.atan2(dx, dz);
2868
3008
 
2869
3009
  // Keep player within bounds
2870
3010
  this.player.position.x = Math.max(-24, Math.min(24, this.player.position.x));
2871
3011
  this.player.position.z = Math.max(-24, Math.min(24, this.player.position.z));
2872
3012
 
2873
- // Camera follows player
2874
- this.game.scene.camera.position.x = this.player.position.x;
2875
- this.game.scene.camera.position.z = this.player.position.z + 12;
2876
- this.game.scene.camera.lookAt(this.player.position);
3013
+ this.updateCamera();
2877
3014
 
2878
3015
  // Check collisions with collectibles
2879
3016
  this.collectibles.forEach((item) => {