@umicat/three-sdk 0.6.0 → 0.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/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
@@ -52,6 +52,7 @@ export class Input3D {
52
52
  this.stick = { x: 0, z: 0 };
53
53
  this.touchJump = false;
54
54
  this.actions = [];
55
+ this.stickMode = 'floating';
55
56
  this.touchHeld = new Set();
56
57
  this.pressedAt = new Map();
57
58
  this.unconsumed = new Set();
@@ -66,6 +67,7 @@ export class Input3D {
66
67
  // Losing focus mid-press would otherwise leave the character walking.
67
68
  this.target.addEventListener('blur', this.onBlur);
68
69
  this.actions = opts.actions ?? [];
70
+ this.stickMode = opts.stick ?? 'floating';
69
71
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
70
72
  if (wantTouch && typeof document !== 'undefined') {
71
73
  this.mountTouch(opts.container ?? document.body);
@@ -113,8 +115,23 @@ export class Input3D {
113
115
  this.pressedAt.set(id, now());
114
116
  this.unconsumed.add(id);
115
117
  }
116
- /** Test seam: drive the controller without synthesising DOM events. */
117
- press(code) { this.keysDown.add(code); }
118
+ /** Test seam: drive the controller without synthesising DOM events.
119
+ *
120
+ * This latches exactly as a real keydown does. It used to only add the code
121
+ * to the held set, so `consume()` never saw it — a seam that behaved
122
+ * differently from the thing it stands in for, which made a test report
123
+ * that an attack did not land when the only thing that had not happened was
124
+ * the press. */
125
+ press(code) {
126
+ if (this.keysDown.has(code))
127
+ return; // held, not re-pressed
128
+ this.keysDown.add(code);
129
+ if (code === 'Space')
130
+ this.beginPress('jump');
131
+ for (const a of this.actions)
132
+ if (a.keys?.includes(code))
133
+ this.beginPress(a.id);
134
+ }
118
135
  release(code) { this.keysDown.delete(code); }
119
136
  dispose() {
120
137
  this.target.removeEventListener('keydown', this.onDown);
@@ -140,8 +157,28 @@ export class Input3D {
140
157
  position: 'fixed', inset: '0', pointerEvents: 'none',
141
158
  touchAction: 'none', userSelect: 'none', zIndex: '10',
142
159
  });
160
+ const floating = this.stickMode === 'floating';
161
+ // The zone a thumb may land on to summon the stick. It is the LEFT HALF,
162
+ // not the pad: the whole point is that the player does not have to find
163
+ // anything. It deliberately stops short of the buttons on the right.
164
+ const zone = document.createElement('div');
165
+ Object.assign(zone.style, {
166
+ position: 'absolute', left: '0', top: '0', width: '50%', height: '100%',
167
+ pointerEvents: floating ? 'auto' : 'none',
168
+ });
143
169
  const pad = document.createElement('div');
144
- Object.assign(pad.style, {
170
+ Object.assign(pad.style, floating ? {
171
+ position: 'absolute', width: '30vmin', height: '30vmin',
172
+ maxWidth: '180px', maxHeight: '180px',
173
+ borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
174
+ border: '2px solid rgba(255,255,255,0.35)',
175
+ // Never interactive when floating: the ZONE owns the pointer, and a pad
176
+ // that also captured it would steal the very first move event as the
177
+ // thumb crosses its edge.
178
+ pointerEvents: 'none', display: 'none',
179
+ transform: 'translate(-50%, -50%)',
180
+ transition: 'opacity 120ms linear',
181
+ } : {
145
182
  position: 'absolute', left: '5vmin', bottom: '5vmin',
146
183
  width: '30vmin', height: '30vmin', maxWidth: '180px', maxHeight: '180px',
147
184
  borderRadius: '50%', background: 'rgba(255,255,255,0.14)',
@@ -185,17 +222,37 @@ export class Input3D {
185
222
  cluster.append(el);
186
223
  this.wireButton(el, a.id);
187
224
  }
188
- root.append(pad, cluster);
225
+ root.append(zone, pad, cluster);
189
226
  container.appendChild(root);
190
227
  this.root = root;
191
228
  // Track by pointerId so a thumb on the stick and a thumb on the button do
192
229
  // not fight over one piece of state.
193
230
  let stickId = null;
194
- const setFromEvent = (e) => {
231
+ // Where the stick is centred. When floating this is wherever the thumb
232
+ // landed, so it is remembered rather than read back off the element —
233
+ // reading the rect would make the origin drift with the pad's own
234
+ // transform and the stick would feel like it was sliding away.
235
+ let originX = 0, originY = 0, radius = 0;
236
+ const measurePad = () => {
195
237
  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;
238
+ radius = r.width / 2;
239
+ if (!floating) {
240
+ originX = r.left + radius;
241
+ originY = r.top + r.height / 2;
242
+ }
243
+ };
244
+ const showAt = (x, y) => {
245
+ originX = x;
246
+ originY = y;
247
+ pad.style.display = 'block';
248
+ pad.style.left = `${x}px`;
249
+ pad.style.top = `${y}px`;
250
+ measurePad();
251
+ };
252
+ const setFromEvent = (e) => {
253
+ if (!radius)
254
+ measurePad();
255
+ let dx = (e.clientX - originX) / radius, dy = (e.clientY - originY) / radius;
199
256
  const len = Math.hypot(dx, dy);
200
257
  if (len > 1) {
201
258
  dx /= len;
@@ -212,19 +269,34 @@ export class Input3D {
212
269
  this.stick.z = 0;
213
270
  knob.style.left = '50%';
214
271
  knob.style.top = '50%';
272
+ if (floating)
273
+ pad.style.display = 'none';
215
274
  };
216
275
  const on = (el, ev, fn) => {
217
276
  const h = fn;
218
277
  el.addEventListener(ev, h);
219
278
  this.cleanups.push(() => el.removeEventListener(ev, h));
220
279
  };
221
- on(pad, 'pointerdown', (e) => { stickId = e.pointerId; pad.setPointerCapture(e.pointerId); setFromEvent(e); });
222
- on(pad, 'pointermove', (e) => { if (e.pointerId === stickId)
280
+ // The element that owns the gesture differs by mode, but the handlers do
281
+ // not — which is the point: `direction()` reads the same either way.
282
+ const grip = floating ? zone : pad;
283
+ on(grip, 'pointerdown', (e) => {
284
+ // One thumb drives the stick. A second finger landing in the zone must
285
+ // not move the origin out from under the first.
286
+ if (stickId !== null)
287
+ return;
288
+ stickId = e.pointerId;
289
+ grip.setPointerCapture(e.pointerId);
290
+ if (floating)
291
+ showAt(e.clientX, e.clientY);
292
+ setFromEvent(e);
293
+ });
294
+ on(grip, 'pointermove', (e) => { if (e.pointerId === stickId)
223
295
  setFromEvent(e); });
224
296
  // pointercancel too: a system gesture steals the pointer without an up, and
225
297
  // the character would walk forever.
226
298
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
227
- on(pad, ev, (e) => { if (e.pointerId === stickId)
299
+ on(grip, ev, (e) => { if (e.pointerId === stickId)
228
300
  reset(); });
229
301
  }
230
302
  on(btn, 'pointerdown', (e) => {
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.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",