@myvillage/cli 2.6.0 → 2.7.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": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -257,7 +257,7 @@ async function deployThreeJsGame(projectDir, pkg, options = {}) {
257
257
  const metadata = {
258
258
  title: pkg.myvillage.name || pkg.name,
259
259
  description: pkg.description || '',
260
- category: (pkg.myvillage.gameType || 'OTHER').toUpperCase(),
260
+ category: toGameCategory(pkg.myvillage.category || pkg.myvillage.gameType),
261
261
  gameType: 'THREE_JS',
262
262
  targetAge: pkg.myvillage.targetAge || 'all',
263
263
  orientation: pkg.myvillage.orientation || 'landscape',
@@ -521,7 +521,7 @@ async function deployUnityPlatform(projectDir, mvConfig, platform, { gameIdOverr
521
521
  const metadata = {
522
522
  title: mvConfig.name,
523
523
  description: mvConfig.description || '',
524
- category: (mvConfig.category || 'OTHER').toUpperCase(),
524
+ category: toGameCategory(mvConfig.category),
525
525
  gameType,
526
526
  targetAge: mvConfig.targetAge || 'all',
527
527
  orientation: mvConfig.orientation || (platform === 'webgl' ? 'landscape' : 'portrait'),
@@ -775,6 +775,15 @@ function formatStatus(status) {
775
775
  }
776
776
  }
777
777
 
778
+ // The platform's GameCategory enum. A Three.js "custom" type (the create-game
779
+ // default) isn't one, and sending it as-is fails the deploy server-side.
780
+ const GAME_CATEGORIES = ['ACTION', 'PUZZLE', 'ADVENTURE', 'EDUCATION', 'QUIZ', 'EXPLORATION', 'NARRATIVE', 'OTHER'];
781
+
782
+ export function toGameCategory(raw) {
783
+ const v = String(raw || '').trim().toUpperCase();
784
+ return GAME_CATEGORIES.includes(v) ? v : 'OTHER';
785
+ }
786
+
778
787
  function getContentType(filePath) {
779
788
  const ext = filePath.split('.').pop()?.toLowerCase();
780
789
  const types = {
@@ -235,7 +235,7 @@ function generate2DGameIndexHtml(name, orientation) {
235
235
  <html lang="en">
236
236
  <head>
237
237
  <meta charset="UTF-8" />
238
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
238
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
239
239
  <!-- orientation: ${orientation} -->
240
240
  <title>${name}</title>
241
241
  <link rel="stylesheet" href="/src/styles.css" />
@@ -435,10 +435,19 @@ html, body {
435
435
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
436
436
  }
437
437
 
438
+ /* The background runs edge to edge on a phone (behind the status bar and home
439
+ indicator); content stays inside the safe area. The extra top space keeps the
440
+ top-left corner clear for the host app's Exit button. The muni app injects
441
+ --mv-safe-* because Android web views don't report env() insets. */
438
442
  body {
443
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
444
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
445
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
446
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
439
447
  background: linear-gradient(160deg, var(--deep-green), var(--dark));
440
448
  color: var(--cream);
441
449
  display: flex;
450
+ padding: calc(var(--safe-top) + 44px) var(--safe-right) var(--safe-bottom) var(--safe-left);
442
451
  -webkit-font-smoothing: antialiased;
443
452
  }
444
453
 
@@ -851,10 +860,19 @@ html, body {
851
860
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
852
861
  }
853
862
 
863
+ /* The background runs edge to edge on a phone (behind the status bar and home
864
+ indicator); content stays inside the safe area. The extra top space keeps the
865
+ top-left corner clear for the host app's Exit button. The muni app injects
866
+ --mv-safe-* because Android web views don't report env() insets. */
854
867
  body {
868
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
869
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
870
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
871
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
855
872
  background: linear-gradient(160deg, var(--deep-green), var(--dark));
856
873
  color: var(--cream);
857
874
  display: flex;
875
+ padding: calc(var(--safe-top) + 44px) var(--safe-right) var(--safe-bottom) var(--safe-left);
858
876
  -webkit-font-smoothing: antialiased;
859
877
  }
860
878
 
@@ -1085,11 +1103,11 @@ function generate3DGameAgents(name, type, orientation) {
1085
1103
  ? '9:16 (e.g. 1080x1920) for this portrait game'
1086
1104
  : '16:9 (e.g. 1920x1080) for this landscape game';
1087
1105
  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.'
1106
+ ? '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
1107
  : '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
1108
  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()`.';
1109
+ ? '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.'
1110
+ : '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
1111
  const uiExtra = isExploration
1094
1112
  ? ' This game also adds `showMinimap(pos, items)` and `showInventory(items)`.'
1095
1113
  : '';
@@ -1125,7 +1143,7 @@ function generate3DGameAgents(name, type, orientation) {
1125
1143
  '## Engine API (reach it as `this.game` inside GameLogic)',
1126
1144
  '',
1127
1145
  '- `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`.',
1146
+ '- `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
1147
  '- `this.game.audio` — `playSFX(name)`, `playMusic(name)`, `stopMusic()`, `setMusicVolume(v)`, `setSFXVolume(v)`.',
1130
1148
  '- `this.game.ui` — `showMenu()`, `showHUD()`, `showPauseMenu()`, `showGameOver(result)`, `clearOverlay()`, `createElement(tag, styles)`.' + uiExtra,
1131
1149
  '- `this.game` — `startGame()`, `pauseGame()`, `resumeGame()`, `endGame(result)`, `state`.',
@@ -1134,6 +1152,12 @@ function generate3DGameAgents(name, type, orientation) {
1134
1152
  '',
1135
1153
  mobileLine,
1136
1154
  '',
1155
+ '## Screen layout (phones)',
1156
+ '',
1157
+ '- 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.',
1158
+ '- 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.',
1159
+ '- 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.',
1160
+ '',
1137
1161
  '## The MyVillage game contract — DO NOT BREAK',
1138
1162
  '',
1139
1163
  '- `MyVillageGame.ready()` — called after assets load in `src/main.js`. Leave it.',
@@ -1839,31 +1863,49 @@ cover art with the submission. An admin sets the game's MVT reward at review tim
1839
1863
 
1840
1864
  function generateIndexHtml(name, orientation = 'landscape') {
1841
1865
  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.
1866
+ // Portrait games fill the whole phone screen edge to edge (behind the status
1867
+ // bar and home indicator — the muni app draws a soft fade + Exit over the top
1868
+ // corner). Only on a wide screen (desktop web) is the game framed in a 9:16
1869
+ // stage so it doesn't stretch. Landscape always fills the viewport.
1845
1870
  const containerStyle = isPortrait
1846
1871
  ? `#game-container {
1847
1872
  position: relative;
1848
- width: min(100vw, calc(100vh * 9 / 16));
1849
- height: min(100vh, calc(100vw * 16 / 9));
1850
- margin: 0 auto;
1873
+ width: 100vw;
1874
+ height: 100vh;
1875
+ height: 100dvh;
1851
1876
  overflow: hidden;
1877
+ }
1878
+ @media (min-aspect-ratio: 3/4) {
1879
+ #game-container { width: min(100vw, calc(100dvh * 9 / 16)); margin: 0 auto; }
1852
1880
  }`
1853
1881
  : `#game-container { width: 100%; height: 100%; position: relative; overflow: hidden; }`;
1854
1882
  return `<!DOCTYPE html>
1855
1883
  <html lang="en">
1856
1884
  <head>
1857
1885
  <meta charset="UTF-8" />
1858
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
1886
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
1859
1887
  <!-- orientation: ${orientation} -->
1860
1888
  <title>${name}</title>
1861
1889
  <style>
1890
+ /* Screen-edge spacing. On a phone the game runs under the status bar and
1891
+ home indicator; keep HUD inside these. The muni app also injects
1892
+ --mv-safe-* (Android web views don't report env() insets).
1893
+ --hud-top: first row of HUD buttons. The TOP-LEFT corner (--exit-clear
1894
+ wide, below --safe-top) belongs to the app's Exit button — keep it empty. */
1895
+ :root {
1896
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
1897
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
1898
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
1899
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
1900
+ --hud-top: calc(var(--safe-top) + 12px);
1901
+ --exit-clear: 64px;
1902
+ }
1862
1903
  * { margin: 0; padding: 0; box-sizing: border-box; }
1863
1904
  html, body { width: 100%; height: 100%; overflow: hidden; }
1864
1905
  body { background: ${BRAND.darkBrown}; display: flex; align-items: center; justify-content: center; }
1865
1906
  ${containerStyle}
1866
- canvas { display: block; }
1907
+ /* touch-action: none — drags on the game steer the camera, never scroll the page. */
1908
+ canvas { display: block; touch-action: none; }
1867
1909
 
1868
1910
  /* Loading screen */
1869
1911
  #loading-screen {
@@ -2040,6 +2082,8 @@ class Game {
2040
2082
  this.gameLogic.update(delta);
2041
2083
  this.scene.update(delta);
2042
2084
  }
2085
+ // One-shot input (just-pressed keys, look drag) lasts exactly one frame.
2086
+ this.input.clearFrame();
2043
2087
 
2044
2088
  this.renderer.render(this.scene.scene, this.scene.camera);
2045
2089
  }
@@ -2247,6 +2291,11 @@ export class InputManager {
2247
2291
  // movement-based game calls enableJoystick(), and on desktop.
2248
2292
  this.joystick = null;
2249
2293
 
2294
+ // Look-around drag, in pixels since the last frame. Stays inactive until a
2295
+ // 3D game calls enableLookControls(); read it with getLookDelta().
2296
+ this.lookEnabled = false;
2297
+ this.lookDelta = { x: 0, y: 0 };
2298
+
2250
2299
  this.setupEventListeners();
2251
2300
  }
2252
2301
 
@@ -2327,8 +2376,8 @@ export class InputManager {
2327
2376
  const base = document.createElement('div');
2328
2377
  Object.assign(base.style, {
2329
2378
  position: 'absolute',
2330
- left: 'calc(24px + env(safe-area-inset-left, 0px))',
2331
- bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
2379
+ left: 'calc(24px + var(--safe-left, 0px))',
2380
+ bottom: 'calc(24px + var(--safe-bottom, 0px))',
2332
2381
  width: '120px',
2333
2382
  height: '120px',
2334
2383
  borderRadius: '50%',
@@ -2414,6 +2463,66 @@ export class InputManager {
2414
2463
  window.addEventListener('resize', recenter);
2415
2464
  }
2416
2465
 
2466
+ // Drag anywhere on the game (outside the joystick) to look around — one
2467
+ // finger on a phone, or click-and-drag with a mouse. Pair it with
2468
+ // enableJoystick() in any game where a character moves through a 3D world:
2469
+ // left thumb moves, right thumb looks. Read the drag each frame with
2470
+ // getLookDelta() and turn the camera with it. No-op if already enabled.
2471
+ enableLookControls() {
2472
+ if (this.lookEnabled) return;
2473
+ this.lookEnabled = true;
2474
+
2475
+ let lookId = null;
2476
+ let last = { x: 0, y: 0 };
2477
+
2478
+ this.canvas.addEventListener('pointerdown', (e) => {
2479
+ if (lookId !== null) return;
2480
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
2481
+ lookId = e.pointerId;
2482
+ last = { x: e.clientX, y: e.clientY };
2483
+ this.canvas.setPointerCapture(lookId);
2484
+ hint.style.opacity = '0';
2485
+ });
2486
+
2487
+ this.canvas.addEventListener('pointermove', (e) => {
2488
+ if (e.pointerId !== lookId) return;
2489
+ this.lookDelta.x += e.clientX - last.x;
2490
+ this.lookDelta.y += e.clientY - last.y;
2491
+ last = { x: e.clientX, y: e.clientY };
2492
+ });
2493
+
2494
+ const release = (e) => {
2495
+ if (e.pointerId === lookId) lookId = null;
2496
+ };
2497
+ this.canvas.addEventListener('pointerup', release);
2498
+ this.canvas.addEventListener('pointercancel', release);
2499
+
2500
+ // A small hint on the right, gone after the first drag.
2501
+ const hint = document.createElement('div');
2502
+ hint.textContent = this.isTouchDevice ? 'Drag to look around' : 'Drag to look · WASD to move';
2503
+ Object.assign(hint.style, {
2504
+ position: 'absolute',
2505
+ right: 'calc(20px + var(--safe-right, 0px))',
2506
+ bottom: 'calc(64px + var(--safe-bottom, 0px))',
2507
+ padding: '6px 12px',
2508
+ borderRadius: '14px',
2509
+ background: 'rgba(0, 0, 0, 0.35)',
2510
+ color: 'rgba(255, 255, 255, 0.9)',
2511
+ font: '13px system-ui, sans-serif',
2512
+ pointerEvents: 'none',
2513
+ transition: 'opacity 0.6s',
2514
+ zIndex: '50',
2515
+ });
2516
+ (this.canvas.parentElement || document.body).appendChild(hint);
2517
+ setTimeout(() => { hint.style.opacity = '0'; }, 6000);
2518
+ }
2519
+
2520
+ // Look-around drag since the last frame, in pixels ({ x: right, y: down }).
2521
+ // Multiply by a small sensitivity (e.g. 0.005 rad/px) to turn the camera.
2522
+ getLookDelta() {
2523
+ return { x: this.lookDelta.x, y: this.lookDelta.y };
2524
+ }
2525
+
2417
2526
  // Check if a key is currently held down
2418
2527
  isKeyDown(code) {
2419
2528
  return !!this.keys[code];
@@ -2454,6 +2563,8 @@ export class InputManager {
2454
2563
  // Call at the end of each frame to clear one-shot inputs
2455
2564
  clearFrame() {
2456
2565
  this.keysJustPressed = {};
2566
+ this.lookDelta.x = 0;
2567
+ this.lookDelta.y = 0;
2457
2568
  }
2458
2569
  }
2459
2570
  `;
@@ -2558,9 +2669,10 @@ function generateUI(type) {
2558
2669
  // Minimap rendering in top-right corner
2559
2670
  let minimap = this.overlay.querySelector('#minimap');
2560
2671
  if (!minimap) {
2672
+ // Top-right, under the Pause button (the top-left corner is the app's Exit).
2561
2673
  minimap = this.createElement('canvas', {
2562
- position: 'absolute', top: '10px', right: '10px',
2563
- width: '150px', height: '150px',
2674
+ position: 'absolute', top: 'calc(var(--hud-top) + 48px)', right: 'calc(12px + var(--safe-right))',
2675
+ width: '120px', height: '120px',
2564
2676
  background: 'rgba(48,32,23,0.8)', borderRadius: '8px',
2565
2677
  border: '2px solid #B07C00',
2566
2678
  });
@@ -2571,7 +2683,7 @@ function generateUI(type) {
2571
2683
  }
2572
2684
 
2573
2685
  const ctx = minimap.getContext('2d');
2574
- ctx.clearRect(0, 0, 150, 150);
2686
+ ctx.clearRect(0, 0, 150, 150); // drawing space stays 150; CSS scales it
2575
2687
 
2576
2688
  // Draw player
2577
2689
  ctx.fillStyle = '#FFD700';
@@ -2591,19 +2703,23 @@ function generateUI(type) {
2591
2703
  showInventory(items) {
2592
2704
  let inv = this.overlay.querySelector('#inventory');
2593
2705
  if (!inv) {
2706
+ // Bottom-right, clear of the joystick (bottom-left) and the home indicator.
2707
+ // pointerEvents none so drags over it still turn the camera.
2594
2708
  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',
2709
+ position: 'absolute', bottom: 'calc(12px + var(--safe-bottom))', right: 'calc(12px + var(--safe-right))',
2710
+ maxWidth: '55%', display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end',
2711
+ gap: '6px', background: 'rgba(48,32,23,0.85)',
2712
+ padding: '8px', borderRadius: '12px', pointerEvents: 'none',
2598
2713
  });
2599
2714
  inv.id = 'inventory';
2600
2715
  this.overlay.appendChild(inv);
2601
2716
  }
2602
2717
  inv.innerHTML = '';
2718
+ inv.style.display = items.length ? 'flex' : 'none';
2603
2719
 
2604
2720
  items.forEach((item) => {
2605
2721
  const slot = this.createElement('div', {
2606
- width: '48px', height: '48px', background: '#043922',
2722
+ width: '40px', height: '40px', background: '#043922',
2607
2723
  borderRadius: '8px', border: '2px solid #228B22',
2608
2724
  display: 'flex', alignItems: 'center', justifyContent: 'center',
2609
2725
  color: '#FFD700', fontSize: '12px', textAlign: 'center',
@@ -2668,8 +2784,10 @@ export class UI {
2668
2784
  showHUD() {
2669
2785
  this.clearOverlay();
2670
2786
 
2787
+ // Top-right, inside the safe area. Keep the top-left corner empty — the
2788
+ // host app draws its Exit button there.
2671
2789
  const pauseBtn = this.createElement('button', {
2672
- position: 'absolute', top: '10px', right: '10px',
2790
+ position: 'absolute', top: 'var(--hud-top)', right: 'calc(12px + var(--safe-right))',
2673
2791
  padding: '8px 16px', background: 'rgba(48,32,23,0.7)',
2674
2792
  color: '#E4DCCB', border: '1px solid #B07C00',
2675
2793
  borderRadius: '8px', cursor: 'pointer', fontSize: '14px',
@@ -2798,6 +2916,13 @@ export class GameLogic {
2798
2916
  this.player = null;
2799
2917
  this.collectibles = [];
2800
2918
  this.collectedItems = [];
2919
+
2920
+ // Follow camera: orbits the player. Drag to change yaw (turn) and pitch
2921
+ // (tilt); movement is relative to where the camera faces.
2922
+ this.cameraYaw = 0;
2923
+ this.cameraPitch = 0.55; // radians above the horizon
2924
+ this.cameraDistance = 12;
2925
+ this.lookSensitivity = 0.005; // radians per dragged pixel
2801
2926
  }
2802
2927
 
2803
2928
  start() {
@@ -2806,8 +2931,10 @@ export class GameLogic {
2806
2931
  this.setupPlayer();
2807
2932
  this.spawnCollectibles();
2808
2933
 
2809
- // Show the on-screen movement joystick on touch devices (no-op on desktop).
2934
+ // Phones: left thumb moves (joystick), drag anywhere else to look around.
2935
+ // Desktop: WASD / arrows to move, mouse drag to look.
2810
2936
  this.game.input.enableJoystick();
2937
+ this.game.input.enableLookControls();
2811
2938
  }
2812
2939
 
2813
2940
  reset() {
@@ -2828,8 +2955,19 @@ export class GameLogic {
2828
2955
  this.player.castShadow = true;
2829
2956
  this.game.scene.scene.add(this.player);
2830
2957
 
2831
- // Update camera to follow player
2832
- this.game.scene.camera.position.set(0, 8, 12);
2958
+ this.updateCamera();
2959
+ }
2960
+
2961
+ updateCamera() {
2962
+ const cam = this.game.scene.camera;
2963
+ const target = this.player.position;
2964
+ const flat = Math.cos(this.cameraPitch) * this.cameraDistance;
2965
+ cam.position.set(
2966
+ target.x + Math.sin(this.cameraYaw) * flat,
2967
+ target.y + Math.sin(this.cameraPitch) * this.cameraDistance,
2968
+ target.z + Math.cos(this.cameraYaw) * flat
2969
+ );
2970
+ cam.lookAt(target);
2833
2971
  }
2834
2972
 
2835
2973
  spawnCollectibles() {
@@ -2861,19 +2999,26 @@ export class GameLogic {
2861
2999
  update(delta) {
2862
3000
  if (!this.player) return;
2863
3001
 
2864
- // WASD movement
3002
+ // Look around: drag turns (x) and tilts (y) the camera.
3003
+ const look = this.game.input.getLookDelta();
3004
+ this.cameraYaw -= look.x * this.lookSensitivity;
3005
+ this.cameraPitch = Math.max(0.15, Math.min(1.3, this.cameraPitch + look.y * this.lookSensitivity));
3006
+
3007
+ // Move relative to the camera: stick up / W walks away from the camera.
2865
3008
  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;
3009
+ const sin = Math.sin(this.cameraYaw);
3010
+ const cos = Math.cos(this.cameraYaw);
3011
+ const dx = movement.x * cos + movement.z * sin;
3012
+ const dz = -movement.x * sin + movement.z * cos;
3013
+ this.player.position.x += dx * this.moveSpeed * delta;
3014
+ this.player.position.z += dz * this.moveSpeed * delta;
3015
+ if (dx !== 0 || dz !== 0) this.player.rotation.y = Math.atan2(dx, dz);
2868
3016
 
2869
3017
  // Keep player within bounds
2870
3018
  this.player.position.x = Math.max(-24, Math.min(24, this.player.position.x));
2871
3019
  this.player.position.z = Math.max(-24, Math.min(24, this.player.position.z));
2872
3020
 
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);
3021
+ this.updateCamera();
2877
3022
 
2878
3023
  // Check collisions with collectibles
2879
3024
  this.collectibles.forEach((item) => {