@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 +51 -3
- package/dist/Input3D.js +133 -5
- package/dist/SceneLoader3D.d.ts +9 -0
- package/dist/SceneLoader3D.js +28 -1
- package/package.json +1 -1
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
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
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
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
|
|
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
|
-
|
|
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']) {
|
package/dist/SceneLoader3D.d.ts
CHANGED
|
@@ -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;
|
package/dist/SceneLoader3D.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|