@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 +20 -1
- package/dist/Input3D.js +83 -11
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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.
|
|
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",
|