@umicat/three-sdk 0.7.2 → 0.8.1

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,16 @@ 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. Same number governs
56
+ * touch drags and desktop right-drags, so there is one knob to turn. */
57
+ lookSensitivity?: number;
48
58
  }
49
59
  /**
50
60
  * Movement and jump, from a keyboard or a thumb, behind one interface.
@@ -71,6 +81,9 @@ export declare class Input3D {
71
81
  private touchJump;
72
82
  private actions;
73
83
  private stickMode;
84
+ private wantLook;
85
+ private lookSens;
86
+ private readonly lookDelta;
74
87
  private readonly touchHeld;
75
88
  private readonly pressedAt;
76
89
  private readonly unconsumed;
@@ -78,9 +91,18 @@ export declare class Input3D {
78
91
  private readonly cleanups;
79
92
  constructor(options?: Input3DOptions | Window);
80
93
  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(): {
94
+ /**
95
+ * Which way to walk. Keyboard and stick are merged, so both work on a device
96
+ * with both.
97
+ *
98
+ * Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
99
+ * the camera rather than north. Once the camera can turn, movement that
100
+ * ignores it is the single most disorienting thing a 3D game can do: the
101
+ * player turns to look at something and the stick still walks them the way
102
+ * they were pointed before. Omit it and you get the old world-axis
103
+ * behaviour, which is correct for a camera that never moves.
104
+ */
105
+ direction(cameraYaw?: number): {
84
106
  x: number;
85
107
  z: number;
86
108
  };
@@ -89,6 +111,18 @@ export declare class Input3D {
89
111
  get jump(): boolean;
90
112
  /** Whether an action is held right now — button or bound key, same answer. */
91
113
  held(id: string): boolean;
114
+ /**
115
+ * How far the camera should turn this frame, in radians — and reading it
116
+ * CLEARS it.
117
+ *
118
+ * Consumed rather than sampled because it is a delta, not a state: a frame
119
+ * that forgets to read it must not lose the movement, and a frame that reads
120
+ * it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
121
+ */
122
+ look(): {
123
+ x: number;
124
+ y: number;
125
+ };
92
126
  /** True exactly once per press: one tap is one swing.
93
127
  *
94
128
  * Edge detection done in the game reads the button's STATE and compares it
@@ -108,6 +142,20 @@ export declare class Input3D {
108
142
  press(code: string): void;
109
143
  release(code: string): void;
110
144
  dispose(): void;
145
+ /**
146
+ * Desktop: hold the RIGHT mouse button and drag to turn the camera.
147
+ *
148
+ * The on-screen look zone only exists where the touch controls do, so
149
+ * without this a mouse-and-keyboard player has a camera they cannot turn at
150
+ * all — and now that movement is camera-relative, a camera you cannot turn
151
+ * is one that has permanently decided which way "forward" is.
152
+ *
153
+ * The right button rather than the left: a left-drag is how a game selects,
154
+ * clicks and aims, and taking that away from every game by default would be
155
+ * a worse trade than the one it fixes. Right-drag is also what Roblox uses
156
+ * on desktop, so it is already in players' hands.
157
+ */
158
+ private mountMouseLook;
111
159
  private mountTouch;
112
160
  private wireButton;
113
161
  }
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 = 4.6;
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,29 @@ 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 ?? 4.6;
91
+ if (this.wantLook && typeof document !== 'undefined') {
92
+ this.mountMouseLook(opts.container?.ownerDocument ?? document);
93
+ }
86
94
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
87
95
  if (wantTouch && typeof document !== 'undefined') {
88
96
  this.mountTouch(opts.container ?? document.body);
89
97
  }
90
98
  }
91
99
  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() {
100
+ /**
101
+ * Which way to walk. Keyboard and stick are merged, so both work on a device
102
+ * with both.
103
+ *
104
+ * Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
105
+ * the camera rather than north. Once the camera can turn, movement that
106
+ * ignores it is the single most disorienting thing a 3D game can do: the
107
+ * player turns to look at something and the stick still walks them the way
108
+ * they were pointed before. Omit it and you get the old world-axis
109
+ * behaviour, which is correct for a camera that never moves.
110
+ */
111
+ direction(cameraYaw = 0) {
95
112
  let x = this.stick.x, z = this.stick.z;
96
113
  if (this.isDown('KeyW', 'ArrowUp'))
97
114
  z -= 1;
@@ -103,7 +120,14 @@ export class Input3D {
103
120
  x += 1;
104
121
  const len = Math.hypot(x, z);
105
122
  // Clamp rather than normalise: a half-pushed stick should walk slowly.
106
- return len > 1 ? { x: x / len, z: z / len } : { x, z };
123
+ if (len > 1) {
124
+ x /= len;
125
+ z /= len;
126
+ }
127
+ if (!cameraYaw)
128
+ return { x, z };
129
+ const s = Math.sin(cameraYaw), c = Math.cos(cameraYaw);
130
+ return { x: x * c + z * s, z: z * c - x * s };
107
131
  }
108
132
  /** Whether the jump control is held right now. Pass it to the controller —
109
133
  * coyote time and buffering live there, not here. */
@@ -113,6 +137,20 @@ export class Input3D {
113
137
  const a = this.actions.find((x) => x.id === id);
114
138
  return this.held_(id, this.touchHeld.has(id) || (a?.keys ? this.isDown(...a.keys) : false));
115
139
  }
140
+ /**
141
+ * How far the camera should turn this frame, in radians — and reading it
142
+ * CLEARS it.
143
+ *
144
+ * Consumed rather than sampled because it is a delta, not a state: a frame
145
+ * that forgets to read it must not lose the movement, and a frame that reads
146
+ * it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
147
+ */
148
+ look() {
149
+ const out = { x: this.lookDelta.x, y: this.lookDelta.y };
150
+ this.lookDelta.x = 0;
151
+ this.lookDelta.y = 0;
152
+ return out;
153
+ }
116
154
  /** True exactly once per press: one tap is one swing.
117
155
  *
118
156
  * Edge detection done in the game reads the button's STATE and compares it
@@ -159,6 +197,58 @@ export class Input3D {
159
197
  this.root = null;
160
198
  this.keysDown.clear();
161
199
  }
200
+ /**
201
+ * Desktop: hold the RIGHT mouse button and drag to turn the camera.
202
+ *
203
+ * The on-screen look zone only exists where the touch controls do, so
204
+ * without this a mouse-and-keyboard player has a camera they cannot turn at
205
+ * all — and now that movement is camera-relative, a camera you cannot turn
206
+ * is one that has permanently decided which way "forward" is.
207
+ *
208
+ * The right button rather than the left: a left-drag is how a game selects,
209
+ * clicks and aims, and taking that away from every game by default would be
210
+ * a worse trade than the one it fixes. Right-drag is also what Roblox uses
211
+ * on desktop, so it is already in players' hands.
212
+ */
213
+ mountMouseLook(doc) {
214
+ let id = null;
215
+ let lastX = 0, lastY = 0;
216
+ const down = (e) => {
217
+ if (e.button !== 2 || e.pointerType === 'touch' || id !== null)
218
+ return;
219
+ id = e.pointerId;
220
+ lastX = e.clientX;
221
+ lastY = e.clientY;
222
+ e.preventDefault();
223
+ };
224
+ const move = (e) => {
225
+ if (e.pointerId !== id)
226
+ return;
227
+ // Radians per screen WIDTH, the same unit the touch zone uses, so one
228
+ // sensitivity number means the same thing on both.
229
+ const w = Math.max(1, doc.defaultView?.innerWidth ?? 1000);
230
+ this.lookDelta.x += ((e.clientX - lastX) / w) * this.lookSens;
231
+ this.lookDelta.y += ((e.clientY - lastY) / w) * this.lookSens;
232
+ lastX = e.clientX;
233
+ lastY = e.clientY;
234
+ };
235
+ const up = (e) => { if (e.pointerId === id)
236
+ id = null; };
237
+ const menu = (e) => { e.preventDefault(); };
238
+ doc.addEventListener('pointerdown', down);
239
+ doc.addEventListener('pointermove', move);
240
+ doc.addEventListener('pointerup', up);
241
+ doc.addEventListener('pointercancel', up);
242
+ // Otherwise every right-drag ends with a context menu over the game.
243
+ doc.addEventListener('contextmenu', menu);
244
+ this.cleanups.push(() => {
245
+ doc.removeEventListener('pointerdown', down);
246
+ doc.removeEventListener('pointermove', move);
247
+ doc.removeEventListener('pointerup', up);
248
+ doc.removeEventListener('pointercancel', up);
249
+ doc.removeEventListener('contextmenu', menu);
250
+ });
251
+ }
162
252
  // ── on-screen controls ────────────────────────────────────────────────────
163
253
  mountTouch(container) {
164
254
  const root = document.createElement('div');
@@ -183,6 +273,15 @@ export class Input3D {
183
273
  pointerEvents: floating ? 'auto' : 'none',
184
274
  });
185
275
  Object.assign(zone.style, NO_SELECTION);
276
+ // The other half. Buttons are appended AFTER this and sit above it, so a
277
+ // tap on jump or attack never reaches the look zone -- which is why this
278
+ // can be the whole right half rather than an awkward cut-out around them.
279
+ const lookZone = document.createElement('div');
280
+ Object.assign(lookZone.style, {
281
+ position: 'absolute', right: '0', top: '0', width: '50%', height: '100%',
282
+ pointerEvents: this.wantLook ? 'auto' : 'none',
283
+ });
284
+ Object.assign(lookZone.style, NO_SELECTION);
186
285
  const pad = document.createElement('div');
187
286
  Object.assign(pad.style, floating ? {
188
287
  position: 'absolute', width: '30vmin', height: '30vmin',
@@ -244,7 +343,7 @@ export class Input3D {
244
343
  cluster.append(el);
245
344
  this.wireButton(el, a.id);
246
345
  }
247
- root.append(zone, pad, cluster);
346
+ root.append(zone, lookZone, pad, cluster);
248
347
  container.appendChild(root);
249
348
  this.root = root;
250
349
  // And the page underneath. The overlay covers the controls, but a press
@@ -346,6 +445,35 @@ export class Input3D {
346
445
  });
347
446
  on(grip, 'pointermove', (e) => { if (e.pointerId === stickId)
348
447
  setFromEvent(e); });
448
+ // Looking. A separate pointer id from the stick's, so a thumb on each side
449
+ // works — which is the entire point of splitting the screen in two.
450
+ let lookId = null;
451
+ let lastX = 0, lastY = 0;
452
+ on(lookZone, 'pointerdown', (e) => {
453
+ e.preventDefault();
454
+ if (lookId !== null)
455
+ return;
456
+ lookId = e.pointerId;
457
+ lookZone.setPointerCapture(e.pointerId);
458
+ lastX = e.clientX;
459
+ lastY = e.clientY;
460
+ });
461
+ on(lookZone, 'pointermove', (e) => {
462
+ if (e.pointerId !== lookId)
463
+ return;
464
+ // Scaled by screen WIDTH in both axes, so a drag of the same physical
465
+ // length turns the same amount whichever way it went. Dividing y by
466
+ // height instead makes vertical look wildly faster in landscape.
467
+ const w = Math.max(1, lookZone.getBoundingClientRect().width * 2);
468
+ this.lookDelta.x += ((e.clientX - lastX) / w) * this.lookSens;
469
+ this.lookDelta.y += ((e.clientY - lastY) / w) * this.lookSens;
470
+ lastX = e.clientX;
471
+ lastY = e.clientY;
472
+ });
473
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
474
+ on(lookZone, ev, (e) => { if (e.pointerId === lookId)
475
+ lookId = null; });
476
+ }
349
477
  // pointercancel too: a system gesture steals the pointer without an up, and
350
478
  // the character would walk forever.
351
479
  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.1",
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",