@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.
- package/package.json +1 -1
- package/src/assets/game-toolkit/myvillage-game.js +434 -0
- package/src/assets/templates/arena/AGENTS.md +93 -0
- package/src/assets/templates/arena/README.md +36 -0
- package/src/assets/templates/arena/index.html +74 -0
- package/src/assets/templates/arena/src/arena-game.js +443 -0
- package/src/assets/templates/arena/src/arena.json +114 -0
- package/src/assets/templates/arena/src/lobby.js +94 -0
- package/src/assets/templates/arena/src/main.js +46 -0
- package/src/commands/create-game.js +6 -1
- package/src/commands/deploy.js +48 -0
- package/src/index.js +1 -1
- package/src/utils/templates.js +252 -115
- package/vendor/realtime/myvillage-realtime-client.js +634 -0
package/src/utils/templates.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
1092
|
-
: 'If
|
|
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()`, `
|
|
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
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
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:
|
|
1849
|
-
height:
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2086
|
-
|
|
2087
|
-
|
|
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 +
|
|
2331
|
-
bottom: 'calc(24px +
|
|
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: '
|
|
2563
|
-
width: '
|
|
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: '
|
|
2596
|
-
display: 'flex',
|
|
2597
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
//
|
|
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
|
-
|
|
2832
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2867
|
-
|
|
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
|
-
|
|
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) => {
|