@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 +20 -1
- package/dist/Input3D.js +134 -12
- package/dist/Tint.d.ts +16 -0
- package/dist/Tint.js +62 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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.
|
|
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",
|