@umicat/three-sdk 0.7.2 → 0.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/dist/Input3D.d.ts CHANGED
@@ -45,6 +45,15 @@ export interface Input3DOptions {
45
45
  * `'fixed'` keeps the old always-visible pad at the bottom left.
46
46
  */
47
47
  stick?: 'floating' | 'fixed';
48
+ /**
49
+ * Drag the right half of the screen to turn the camera — the other half of
50
+ * the phone convention, and the half without which a turnable camera is
51
+ * unreachable on a phone. Buttons sit above this zone, so they keep their
52
+ * taps. Default on wherever touch controls are shown.
53
+ */
54
+ look?: boolean;
55
+ /** Radians of camera rotation per screen-width dragged. */
56
+ lookSensitivity?: number;
48
57
  }
49
58
  /**
50
59
  * Movement and jump, from a keyboard or a thumb, behind one interface.
@@ -71,6 +80,9 @@ export declare class Input3D {
71
80
  private touchJump;
72
81
  private actions;
73
82
  private stickMode;
83
+ private wantLook;
84
+ private lookSens;
85
+ private readonly lookDelta;
74
86
  private readonly touchHeld;
75
87
  private readonly pressedAt;
76
88
  private readonly unconsumed;
@@ -78,9 +90,18 @@ export declare class Input3D {
78
90
  private readonly cleanups;
79
91
  constructor(options?: Input3DOptions | Window);
80
92
  isDown(...codes: string[]): boolean;
81
- /** Camera-relative would need the camera; this is world-axis movement.
82
- * Keyboard and stick are merged, so both work on a device with both. */
83
- direction(): {
93
+ /**
94
+ * Which way to walk. Keyboard and stick are merged, so both work on a device
95
+ * with both.
96
+ *
97
+ * Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
98
+ * the camera rather than north. Once the camera can turn, movement that
99
+ * ignores it is the single most disorienting thing a 3D game can do: the
100
+ * player turns to look at something and the stick still walks them the way
101
+ * they were pointed before. Omit it and you get the old world-axis
102
+ * behaviour, which is correct for a camera that never moves.
103
+ */
104
+ direction(cameraYaw?: number): {
84
105
  x: number;
85
106
  z: number;
86
107
  };
@@ -89,6 +110,18 @@ export declare class Input3D {
89
110
  get jump(): boolean;
90
111
  /** Whether an action is held right now — button or bound key, same answer. */
91
112
  held(id: string): boolean;
113
+ /**
114
+ * How far the camera should turn this frame, in radians — and reading it
115
+ * CLEARS it.
116
+ *
117
+ * Consumed rather than sampled because it is a delta, not a state: a frame
118
+ * that forgets to read it must not lose the movement, and a frame that reads
119
+ * it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
120
+ */
121
+ look(): {
122
+ x: number;
123
+ y: number;
124
+ };
92
125
  /** True exactly once per press: one tap is one swing.
93
126
  *
94
127
  * Edge detection done in the game reads the button's STATE and compares it
package/dist/Input3D.js CHANGED
@@ -68,6 +68,9 @@ export class Input3D {
68
68
  this.touchJump = false;
69
69
  this.actions = [];
70
70
  this.stickMode = 'floating';
71
+ this.wantLook = true;
72
+ this.lookSens = 3.2;
73
+ this.lookDelta = { x: 0, y: 0 };
71
74
  this.touchHeld = new Set();
72
75
  this.pressedAt = new Map();
73
76
  this.unconsumed = new Set();
@@ -83,15 +86,26 @@ export class Input3D {
83
86
  this.target.addEventListener('blur', this.onBlur);
84
87
  this.actions = opts.actions ?? [];
85
88
  this.stickMode = opts.stick ?? 'floating';
89
+ this.wantLook = opts.look ?? true;
90
+ this.lookSens = opts.lookSensitivity ?? 3.2;
86
91
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
87
92
  if (wantTouch && typeof document !== 'undefined') {
88
93
  this.mountTouch(opts.container ?? document.body);
89
94
  }
90
95
  }
91
96
  isDown(...codes) { return codes.some((c) => this.keysDown.has(c)); }
92
- /** Camera-relative would need the camera; this is world-axis movement.
93
- * Keyboard and stick are merged, so both work on a device with both. */
94
- direction() {
97
+ /**
98
+ * Which way to walk. Keyboard and stick are merged, so both work on a device
99
+ * with both.
100
+ *
101
+ * Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
102
+ * the camera rather than north. Once the camera can turn, movement that
103
+ * ignores it is the single most disorienting thing a 3D game can do: the
104
+ * player turns to look at something and the stick still walks them the way
105
+ * they were pointed before. Omit it and you get the old world-axis
106
+ * behaviour, which is correct for a camera that never moves.
107
+ */
108
+ direction(cameraYaw = 0) {
95
109
  let x = this.stick.x, z = this.stick.z;
96
110
  if (this.isDown('KeyW', 'ArrowUp'))
97
111
  z -= 1;
@@ -103,7 +117,14 @@ export class Input3D {
103
117
  x += 1;
104
118
  const len = Math.hypot(x, z);
105
119
  // Clamp rather than normalise: a half-pushed stick should walk slowly.
106
- return len > 1 ? { x: x / len, z: z / len } : { x, z };
120
+ if (len > 1) {
121
+ x /= len;
122
+ z /= len;
123
+ }
124
+ if (!cameraYaw)
125
+ return { x, z };
126
+ const s = Math.sin(cameraYaw), c = Math.cos(cameraYaw);
127
+ return { x: x * c + z * s, z: z * c - x * s };
107
128
  }
108
129
  /** Whether the jump control is held right now. Pass it to the controller —
109
130
  * coyote time and buffering live there, not here. */
@@ -113,6 +134,20 @@ export class Input3D {
113
134
  const a = this.actions.find((x) => x.id === id);
114
135
  return this.held_(id, this.touchHeld.has(id) || (a?.keys ? this.isDown(...a.keys) : false));
115
136
  }
137
+ /**
138
+ * How far the camera should turn this frame, in radians — and reading it
139
+ * CLEARS it.
140
+ *
141
+ * Consumed rather than sampled because it is a delta, not a state: a frame
142
+ * that forgets to read it must not lose the movement, and a frame that reads
143
+ * it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
144
+ */
145
+ look() {
146
+ const out = { x: this.lookDelta.x, y: this.lookDelta.y };
147
+ this.lookDelta.x = 0;
148
+ this.lookDelta.y = 0;
149
+ return out;
150
+ }
116
151
  /** True exactly once per press: one tap is one swing.
117
152
  *
118
153
  * Edge detection done in the game reads the button's STATE and compares it
@@ -183,6 +218,15 @@ export class Input3D {
183
218
  pointerEvents: floating ? 'auto' : 'none',
184
219
  });
185
220
  Object.assign(zone.style, NO_SELECTION);
221
+ // The other half. Buttons are appended AFTER this and sit above it, so a
222
+ // tap on jump or attack never reaches the look zone -- which is why this
223
+ // can be the whole right half rather than an awkward cut-out around them.
224
+ const lookZone = document.createElement('div');
225
+ Object.assign(lookZone.style, {
226
+ position: 'absolute', right: '0', top: '0', width: '50%', height: '100%',
227
+ pointerEvents: this.wantLook ? 'auto' : 'none',
228
+ });
229
+ Object.assign(lookZone.style, NO_SELECTION);
186
230
  const pad = document.createElement('div');
187
231
  Object.assign(pad.style, floating ? {
188
232
  position: 'absolute', width: '30vmin', height: '30vmin',
@@ -244,7 +288,7 @@ export class Input3D {
244
288
  cluster.append(el);
245
289
  this.wireButton(el, a.id);
246
290
  }
247
- root.append(zone, pad, cluster);
291
+ root.append(zone, lookZone, pad, cluster);
248
292
  container.appendChild(root);
249
293
  this.root = root;
250
294
  // And the page underneath. The overlay covers the controls, but a press
@@ -346,6 +390,35 @@ export class Input3D {
346
390
  });
347
391
  on(grip, 'pointermove', (e) => { if (e.pointerId === stickId)
348
392
  setFromEvent(e); });
393
+ // Looking. A separate pointer id from the stick's, so a thumb on each side
394
+ // works — which is the entire point of splitting the screen in two.
395
+ let lookId = null;
396
+ let lastX = 0, lastY = 0;
397
+ on(lookZone, 'pointerdown', (e) => {
398
+ e.preventDefault();
399
+ if (lookId !== null)
400
+ return;
401
+ lookId = e.pointerId;
402
+ lookZone.setPointerCapture(e.pointerId);
403
+ lastX = e.clientX;
404
+ lastY = e.clientY;
405
+ });
406
+ on(lookZone, 'pointermove', (e) => {
407
+ if (e.pointerId !== lookId)
408
+ return;
409
+ // Scaled by screen WIDTH in both axes, so a drag of the same physical
410
+ // length turns the same amount whichever way it went. Dividing y by
411
+ // height instead makes vertical look wildly faster in landscape.
412
+ const w = Math.max(1, lookZone.getBoundingClientRect().width * 2);
413
+ this.lookDelta.x += ((e.clientX - lastX) / w) * this.lookSens;
414
+ this.lookDelta.y += ((e.clientY - lastY) / w) * this.lookSens;
415
+ lastX = e.clientX;
416
+ lastY = e.clientY;
417
+ });
418
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
419
+ on(lookZone, ev, (e) => { if (e.pointerId === lookId)
420
+ lookId = null; });
421
+ }
349
422
  // pointercancel too: a system gesture steals the pointer without an up, and
350
423
  // the character would walk forever.
351
424
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
@@ -33,6 +33,15 @@ export interface LoadedScene3D {
33
33
  /** Entity id → rigid body, when physics was supplied. */
34
34
  bodies: Map<string, any>;
35
35
  world?: any;
36
+ /** Where the follow camera is looking from, in radians. Feed it to
37
+ * `Input3D.direction(yaw)` — once the camera can turn, movement that ignores
38
+ * it walks north no matter which way the player is facing, which is the
39
+ * single most disorienting thing a 3D game can do. */
40
+ readonly cameraYaw: number;
41
+ readonly cameraPitch: number;
42
+ /** Turn the follow camera. Deltas in radians; pitch is clamped so it cannot
43
+ * swing under the floor or flip over the top. No-op without a follow target. */
44
+ orbit(dYaw: number, dPitch: number): void;
36
45
  /** Advance animation + physics. Call once per frame with seconds. */
37
46
  update(dt: number): void;
38
47
  dispose(): void;
@@ -214,10 +214,36 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
214
214
  const camera = new THREE.PerspectiveCamera(scene3d.camera?.fov ?? 55, 1, 0.1, 500);
215
215
  const camOffset = toVec(scene3d.camera?.offset ?? { x: 0, y: 4, z: 8 });
216
216
  camera.position.copy(camOffset);
217
+ // The follow camera orbits. The authored offset is not replaced by this --
218
+ // it is READ as the starting angle and distance, so a scene that never calls
219
+ // `orbit` looks exactly as it was laid out.
220
+ const camRadius = camOffset.length() || 1;
221
+ let camYaw = Math.atan2(camOffset.x, camOffset.z);
222
+ let camPitch = Math.asin(Math.max(-1, Math.min(1, camOffset.y / camRadius)));
223
+ // Clamped so the camera cannot swing under the floor or flip over the top --
224
+ // both of which leave the player looking at the underside of the world with
225
+ // no way to explain what happened.
226
+ //
227
+ // The LOW limit is a HEIGHT, not an angle, and that distinction is the whole
228
+ // of it: a fixed -0.25rad floor put a camera orbiting at radius 5.4 nearly a
229
+ // unit UNDERGROUND, because how low an angle puts you depends on how far out
230
+ // you are. Expressed as "stay at least this far above what you are looking
231
+ // at", it holds for any scene's camera distance.
232
+ const MIN_RISE = 0.3;
233
+ const PITCH_MIN = Math.asin(Math.min(0.9, MIN_RISE / camRadius));
234
+ const PITCH_MAX = 1.25;
235
+ camPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, camPitch));
217
236
  const followTarget = scene3d.camera?.kind === 'follow' && scene3d.camera.target
218
237
  ? entities.get(scene3d.camera.target) : undefined;
238
+ const want = new THREE.Vector3();
219
239
  return {
220
240
  scene, camera, entities, mixers, mixerFor, clips, bodies, world,
241
+ get cameraYaw() { return camYaw; },
242
+ get cameraPitch() { return camPitch; },
243
+ orbit(dYaw, dPitch) {
244
+ camYaw -= dYaw;
245
+ camPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, camPitch + dPitch));
246
+ },
221
247
  update(dt) {
222
248
  for (const m of mixers)
223
249
  m.update(dt);
@@ -237,7 +263,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
237
263
  }
238
264
  }
239
265
  if (followTarget) {
240
- const want = followTarget.position.clone().add(camOffset);
266
+ const cp = Math.cos(camPitch);
267
+ want.set(followTarget.position.x + camRadius * cp * Math.sin(camYaw), followTarget.position.y + camRadius * Math.sin(camPitch), followTarget.position.z + camRadius * cp * Math.cos(camYaw));
241
268
  camera.position.lerp(want, Math.min(1, dt * 6));
242
269
  camera.lookAt(followTarget.position.x, followTarget.position.y + 1, followTarget.position.z);
243
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",