@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 +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/commands/create-game.js +7 -8
- package/src/utils/templates.js +207 -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
|
|
@@ -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
|
|
91
|
-
{ name: '
|
|
92
|
-
{ name: '13
|
|
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
|
|
186
|
-
{ name: '
|
|
187
|
-
{ name: '13
|
|
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
|
]);
|
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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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(
|
|
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
|
-
|
|
751
|
-
|
|
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,
|
|
908
|
-
|
|
909
|
-
0.1,
|
|
910
|
-
1000
|
|
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
|
-
|
|
1013
|
-
this.mouse.x =
|
|
1014
|
-
this.mouse.y =
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
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() {
|