@umicat/three-sdk 0.6.0 → 0.7.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
@@ -33,6 +33,18 @@ export interface Input3DOptions {
33
33
  * platform places the controls, so the platform has to place ALL of them.
34
34
  */
35
35
  actions?: Input3DAction[];
36
+ /**
37
+ * How the thumbstick behaves on touch.
38
+ *
39
+ * `'floating'` (the default) is the phone convention Roblox made standard:
40
+ * nothing is drawn until a thumb lands on the left half of the screen, and
41
+ * then the stick appears exactly there. A fixed stick makes the player find
42
+ * a target before they can move, and on a screen you cannot feel, that is a
43
+ * thumb-sized target somewhere the hand is not.
44
+ *
45
+ * `'fixed'` keeps the old always-visible pad at the bottom left.
46
+ */
47
+ stick?: 'floating' | 'fixed';
36
48
  }
37
49
  /**
38
50
  * Movement and jump, from a keyboard or a thumb, behind one interface.
@@ -58,6 +70,7 @@ export declare class Input3D {
58
70
  private readonly stick;
59
71
  private touchJump;
60
72
  private actions;
73
+ private stickMode;
61
74
  private readonly touchHeld;
62
75
  private readonly pressedAt;
63
76
  private readonly unconsumed;
@@ -85,7 +98,13 @@ export declare class Input3D {
85
98
  consume(id: string): boolean;
86
99
  private held_;
87
100
  private beginPress;
88
- /** Test seam: drive the controller without synthesising DOM events. */
101
+ /** Test seam: drive the controller without synthesising DOM events.
102
+ *
103
+ * This latches exactly as a real keydown does. It used to only add the code
104
+ * to the held set, so `consume()` never saw it — a seam that behaved
105
+ * differently from the thing it stands in for, which made a test report
106
+ * that an attack did not land when the only thing that had not happened was
107
+ * the press. */
89
108
  press(code: string): void;
90
109
  release(code: string): void;
91
110
  dispose(): void;
package/dist/Input3D.js CHANGED
@@ -6,6 +6,21 @@
6
6
  * and makes a quick tap always count. */
7
7
  const MIN_PRESS_MS = 80;
8
8
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
9
+ /**
10
+ * Everything needed to stop a long press from being read as "select this text".
11
+ *
12
+ * A thumbstick REQUIRES holding still, which is exactly the gesture iOS uses to
13
+ * start a selection — so on a phone the Copy / Look Up / Translate callout came
14
+ * up over the game the first time anyone tried to walk. `user-select` alone is
15
+ * not enough: `-webkit-touch-callout` is a separate switch, and neither is
16
+ * implied by `touch-action: none`.
17
+ */
18
+ const NO_SELECTION = {
19
+ userSelect: 'none',
20
+ WebkitUserSelect: 'none',
21
+ WebkitTouchCallout: 'none',
22
+ WebkitTapHighlightColor: 'transparent',
23
+ };
9
24
  const isTouchDevice = () => typeof matchMedia === 'function' &&
10
25
  matchMedia('(pointer: coarse)').matches &&
11
26
  !matchMedia('(pointer: fine)').matches;
@@ -52,6 +67,7 @@ export class Input3D {
52
67
  this.stick = { x: 0, z: 0 };
53
68
  this.touchJump = false;
54
69
  this.actions = [];
70
+ this.stickMode = 'floating';
55
71
  this.touchHeld = new Set();
56
72
  this.pressedAt = new Map();
57
73
  this.unconsumed = new Set();
@@ -66,6 +82,7 @@ export class Input3D {
66
82
  // Losing focus mid-press would otherwise leave the character walking.
67
83
  this.target.addEventListener('blur', this.onBlur);
68
84
  this.actions = opts.actions ?? [];
85
+ this.stickMode = opts.stick ?? 'floating';
69
86
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
70
87
  if (wantTouch && typeof document !== 'undefined') {
71
88
  this.mountTouch(opts.container ?? document.body);
@@ -113,8 +130,23 @@ export class Input3D {
113
130
  this.pressedAt.set(id, now());
114
131
  this.unconsumed.add(id);
115
132
  }
116
- /** Test seam: drive the controller without synthesising DOM events. */
117
- press(code) { this.keysDown.add(code); }
133
+ /** Test seam: drive the controller without synthesising DOM events.
134
+ *
135
+ * This latches exactly as a real keydown does. It used to only add the code
136
+ * to the held set, so `consume()` never saw it — a seam that behaved
137
+ * differently from the thing it stands in for, which made a test report
138
+ * that an attack did not land when the only thing that had not happened was
139
+ * the press. */
140
+ press(code) {
141
+ if (this.keysDown.has(code))
142
+ return; // held, not re-pressed
143
+ this.keysDown.add(code);
144
+ if (code === 'Space')
145
+ this.beginPress('jump');
146
+ for (const a of this.actions)
147
+ if (a.keys?.includes(code))
148
+ this.beginPress(a.id);
149
+ }
118
150
  release(code) { this.keysDown.delete(code); }
119
151
  dispose() {
120
152
  this.target.removeEventListener('keydown', this.onDown);
@@ -138,10 +170,32 @@ export class Input3D {
138
170
  // some ancestor happens to be positioned, and "happens to be" is not a
139
171
  // layout strategy.
140
172
  position: 'fixed', inset: '0', pointerEvents: 'none',
141
- touchAction: 'none', userSelect: 'none', zIndex: '10',
173
+ touchAction: 'none', zIndex: '10',
142
174
  });
175
+ Object.assign(root.style, NO_SELECTION);
176
+ const floating = this.stickMode === 'floating';
177
+ // The zone a thumb may land on to summon the stick. It is the LEFT HALF,
178
+ // not the pad: the whole point is that the player does not have to find
179
+ // anything. It deliberately stops short of the buttons on the right.
180
+ const zone = document.createElement('div');
181
+ Object.assign(zone.style, {
182
+ position: 'absolute', left: '0', top: '0', width: '50%', height: '100%',
183
+ pointerEvents: floating ? 'auto' : 'none',
184
+ });
185
+ Object.assign(zone.style, NO_SELECTION);
143
186
  const pad = document.createElement('div');
144
- Object.assign(pad.style, {
187
+ Object.assign(pad.style, floating ? {
188
+ position: 'absolute', width: '30vmin', height: '30vmin',
189
+ maxWidth: '180px', maxHeight: '180px',
190
+ borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
191
+ border: '2px solid rgba(255,255,255,0.35)',
192
+ // Never interactive when floating: the ZONE owns the pointer, and a pad
193
+ // that also captured it would steal the very first move event as the
194
+ // thumb crosses its edge.
195
+ pointerEvents: 'none', display: 'none',
196
+ transform: 'translate(-50%, -50%)',
197
+ transition: 'opacity 120ms linear',
198
+ } : {
145
199
  position: 'absolute', left: '5vmin', bottom: '5vmin',
146
200
  width: '30vmin', height: '30vmin', maxWidth: '180px', maxHeight: '180px',
147
201
  borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
@@ -176,6 +230,11 @@ export class Input3D {
176
230
  color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
177
231
  });
178
232
  el.textContent = label;
233
+ // Explicitly, not by inheritance. The label IS text -- a glyph in a div
234
+ // -- and long-pressing the attack button is how the iOS Copy / Look Up /
235
+ // Translate callout came up mid-fight, with the selection handles
236
+ // clamped around the little crossed swords.
237
+ Object.assign(el.style, NO_SELECTION);
179
238
  return el;
180
239
  };
181
240
  const btn = makeButton('▲');
@@ -185,17 +244,61 @@ export class Input3D {
185
244
  cluster.append(el);
186
245
  this.wireButton(el, a.id);
187
246
  }
188
- root.append(pad, cluster);
247
+ root.append(zone, pad, cluster);
189
248
  container.appendChild(root);
190
249
  this.root = root;
250
+ // And the page underneath. The overlay covers the controls, but a press
251
+ // that lands on the HUD or the canvas would still summon the callout — and
252
+ // a game is not a document; there is nothing here to select.
253
+ //
254
+ // A stylesheet rather than inline styles, so form fields can opt back IN:
255
+ // blanket `user-select: none` on <body> can stop a player selecting text
256
+ // inside their own name field, and a game that takes typed input would
257
+ // have inherited a bug from its movement controls.
258
+ const doc = container.ownerDocument;
259
+ const style = doc.createElement('style');
260
+ style.dataset.umicatTouch = '';
261
+ style.textContent = `
262
+ html, body { -webkit-user-select: none; user-select: none;
263
+ -webkit-touch-callout: none;
264
+ -webkit-tap-highlight-color: transparent; }
265
+ input, textarea, select, [contenteditable] {
266
+ -webkit-user-select: text; user-select: text;
267
+ -webkit-touch-callout: default; }
268
+ `;
269
+ doc.head.appendChild(style);
270
+ this.cleanups.push(() => style.remove());
271
+ const noMenu = (e) => e.preventDefault();
272
+ doc.addEventListener('contextmenu', noMenu);
273
+ this.cleanups.push(() => doc.removeEventListener('contextmenu', noMenu));
191
274
  // Track by pointerId so a thumb on the stick and a thumb on the button do
192
275
  // not fight over one piece of state.
193
276
  let stickId = null;
194
- const setFromEvent = (e) => {
277
+ // Where the stick is centred. When floating this is wherever the thumb
278
+ // landed, so it is remembered rather than read back off the element —
279
+ // reading the rect would make the origin drift with the pad's own
280
+ // transform and the stick would feel like it was sliding away.
281
+ let originX = 0, originY = 0, radius = 0;
282
+ const measurePad = () => {
195
283
  const r = pad.getBoundingClientRect();
196
- const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
197
- const max = r.width / 2;
198
- let dx = (e.clientX - cx) / max, dy = (e.clientY - cy) / max;
284
+ radius = r.width / 2;
285
+ if (!floating) {
286
+ originX = r.left + radius;
287
+ originY = r.top + r.height / 2;
288
+ }
289
+ };
290
+ const showAt = (x, y) => {
291
+ originX = x;
292
+ originY = y;
293
+ pad.style.display = 'block';
294
+ pad.style.left = `${x}px`;
295
+ pad.style.top = `${y}px`;
296
+ measurePad();
297
+ };
298
+ const setFromEvent = (e) => {
299
+ if (!radius)
300
+ measurePad();
301
+ let dx = (e.clientX - originX) / radius, dy = (e.clientY - originY) / radius;
199
302
  const len = Math.hypot(dx, dy);
200
303
  if (len > 1) {
201
304
  dx /= len;
@@ -212,19 +315,37 @@ export class Input3D {
212
315
  this.stick.z = 0;
213
316
  knob.style.left = '50%';
214
317
  knob.style.top = '50%';
318
+ if (floating)
319
+ pad.style.display = 'none';
215
320
  };
216
321
  const on = (el, ev, fn) => {
217
322
  const h = fn;
218
323
  el.addEventListener(ev, h);
219
324
  this.cleanups.push(() => el.removeEventListener(ev, h));
220
325
  };
221
- on(pad, 'pointerdown', (e) => { stickId = e.pointerId; pad.setPointerCapture(e.pointerId); setFromEvent(e); });
222
- on(pad, 'pointermove', (e) => { if (e.pointerId === stickId)
326
+ // The element that owns the gesture differs by mode, but the handlers do
327
+ // not — which is the point: `direction()` reads the same either way.
328
+ const grip = floating ? zone : pad;
329
+ on(grip, 'pointerdown', (e) => {
330
+ // Cancel the default gesture: on iOS, holding still on the screen is how
331
+ // a text selection begins, and holding still is the entire thumbstick.
332
+ e.preventDefault();
333
+ // One thumb drives the stick. A second finger landing in the zone must
334
+ // not move the origin out from under the first.
335
+ if (stickId !== null)
336
+ return;
337
+ stickId = e.pointerId;
338
+ grip.setPointerCapture(e.pointerId);
339
+ if (floating)
340
+ showAt(e.clientX, e.clientY);
341
+ setFromEvent(e);
342
+ });
343
+ on(grip, 'pointermove', (e) => { if (e.pointerId === stickId)
223
344
  setFromEvent(e); });
224
345
  // pointercancel too: a system gesture steals the pointer without an up, and
225
346
  // the character would walk forever.
226
347
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
227
- on(pad, ev, (e) => { if (e.pointerId === stickId)
348
+ on(grip, ev, (e) => { if (e.pointerId === stickId)
228
349
  reset(); });
229
350
  }
230
351
  on(btn, 'pointerdown', (e) => {
@@ -243,6 +364,7 @@ export class Input3D {
243
364
  this.cleanups.push(() => el.removeEventListener(ev, h));
244
365
  };
245
366
  on('pointerdown', (e) => {
367
+ // Cancels the selection gesture as well as the default press behaviour.
246
368
  e.preventDefault();
247
369
  el.setPointerCapture(e.pointerId);
248
370
  this.touchHeld.add(id);
package/dist/Tint.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import * as THREE from 'three';
2
+ /**
3
+ * Flash `object` for `ms` milliseconds. Call `updateTints` each frame.
4
+ *
5
+ * Flashing something already flashing restarts it rather than stacking, so a
6
+ * fast combo does not leave a character permanently red.
7
+ */
8
+ export declare function flashTint(object: THREE.Object3D, opts?: {
9
+ color?: THREE.ColorRepresentation;
10
+ ms?: number;
11
+ intensity?: number;
12
+ }): void;
13
+ /** Restore anything whose flash has expired. Call once per frame. */
14
+ export declare function updateTints(objects: Iterable<THREE.Object3D>): void;
15
+ /** Whether this object is mid-flash — handy for tests and for not stacking. */
16
+ export declare function isTinted(object: THREE.Object3D): boolean;
package/dist/Tint.js ADDED
@@ -0,0 +1,62 @@
1
+ import * as THREE from 'three';
2
+ const states = new WeakMap();
3
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
4
+ /** Give this object its own materials, so tinting it tints only it. */
5
+ function isolate(object) {
6
+ const out = [];
7
+ object.traverse((o) => {
8
+ const mesh = o;
9
+ if (!mesh.isMesh)
10
+ return;
11
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
12
+ const cloned = list.map((m) => {
13
+ const c = m.clone();
14
+ return c;
15
+ });
16
+ mesh.material = Array.isArray(mesh.material) ? cloned : cloned[0];
17
+ for (const m of cloned) {
18
+ if (!m.emissive)
19
+ continue;
20
+ out.push({ mat: m, emissive: m.emissive.clone(), intensity: m.emissiveIntensity ?? 1 });
21
+ }
22
+ });
23
+ return out;
24
+ }
25
+ /**
26
+ * Flash `object` for `ms` milliseconds. Call `updateTints` each frame.
27
+ *
28
+ * Flashing something already flashing restarts it rather than stacking, so a
29
+ * fast combo does not leave a character permanently red.
30
+ */
31
+ export function flashTint(object, opts = {}) {
32
+ let st = states.get(object);
33
+ if (!st) {
34
+ st = { materials: isolate(object), until: 0 };
35
+ states.set(object, st);
36
+ }
37
+ const color = new THREE.Color(opts.color ?? 0xff3020);
38
+ for (const m of st.materials) {
39
+ m.mat.emissive.copy(color);
40
+ m.mat.emissiveIntensity = opts.intensity ?? 0.9;
41
+ }
42
+ st.until = now() + (opts.ms ?? 140);
43
+ }
44
+ /** Restore anything whose flash has expired. Call once per frame. */
45
+ export function updateTints(objects) {
46
+ const t = now();
47
+ for (const o of objects) {
48
+ const st = states.get(o);
49
+ if (!st || st.until === 0 || t < st.until)
50
+ continue;
51
+ st.until = 0;
52
+ for (const m of st.materials) {
53
+ m.mat.emissive.copy(m.emissive);
54
+ m.mat.emissiveIntensity = m.intensity;
55
+ }
56
+ }
57
+ }
58
+ /** Whether this object is mid-flash — handy for tests and for not stacking. */
59
+ export function isTinted(object) {
60
+ const st = states.get(object);
61
+ return !!st && st.until > now();
62
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export type { ClipMap, CharacterAnimatorOptions } from './CharacterAnimator.js';
9
9
  export { Input3D } from './Input3D.js';
10
10
  export type { Input3DOptions, Input3DAction } from './Input3D.js';
11
11
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
+ export { flashTint, updateTints, isTinted } from './Tint.js';
12
13
  export type { Attachment } from './Sockets.js';
13
14
  export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
14
15
  export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { CharacterController3D } from './CharacterController3D.js';
10
10
  export { CharacterAnimator } from './CharacterAnimator.js';
11
11
  export { Input3D } from './Input3D.js';
12
12
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
+ export { flashTint, updateTints, isTinted } from './Tint.js';
13
14
  // Re-exported so a game imports one package for the common case. A game should
14
15
  // not have to know that identity and saves come from a different package than
15
16
  // the renderer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",