@umicat/three-sdk 0.7.1 → 0.8.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 +36 -3
- package/dist/Input3D.js +83 -6
- 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,15 @@ 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. */
|
|
56
|
+
lookSensitivity?: number;
|
|
48
57
|
}
|
|
49
58
|
/**
|
|
50
59
|
* Movement and jump, from a keyboard or a thumb, behind one interface.
|
|
@@ -71,6 +80,9 @@ export declare class Input3D {
|
|
|
71
80
|
private touchJump;
|
|
72
81
|
private actions;
|
|
73
82
|
private stickMode;
|
|
83
|
+
private wantLook;
|
|
84
|
+
private lookSens;
|
|
85
|
+
private readonly lookDelta;
|
|
74
86
|
private readonly touchHeld;
|
|
75
87
|
private readonly pressedAt;
|
|
76
88
|
private readonly unconsumed;
|
|
@@ -78,9 +90,18 @@ export declare class Input3D {
|
|
|
78
90
|
private readonly cleanups;
|
|
79
91
|
constructor(options?: Input3DOptions | Window);
|
|
80
92
|
isDown(...codes: string[]): boolean;
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Which way to walk. Keyboard and stick are merged, so both work on a device
|
|
95
|
+
* with both.
|
|
96
|
+
*
|
|
97
|
+
* Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
|
|
98
|
+
* the camera rather than north. Once the camera can turn, movement that
|
|
99
|
+
* ignores it is the single most disorienting thing a 3D game can do: the
|
|
100
|
+
* player turns to look at something and the stick still walks them the way
|
|
101
|
+
* they were pointed before. Omit it and you get the old world-axis
|
|
102
|
+
* behaviour, which is correct for a camera that never moves.
|
|
103
|
+
*/
|
|
104
|
+
direction(cameraYaw?: number): {
|
|
84
105
|
x: number;
|
|
85
106
|
z: number;
|
|
86
107
|
};
|
|
@@ -89,6 +110,18 @@ export declare class Input3D {
|
|
|
89
110
|
get jump(): boolean;
|
|
90
111
|
/** Whether an action is held right now — button or bound key, same answer. */
|
|
91
112
|
held(id: string): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* How far the camera should turn this frame, in radians — and reading it
|
|
115
|
+
* CLEARS it.
|
|
116
|
+
*
|
|
117
|
+
* Consumed rather than sampled because it is a delta, not a state: a frame
|
|
118
|
+
* that forgets to read it must not lose the movement, and a frame that reads
|
|
119
|
+
* it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
|
|
120
|
+
*/
|
|
121
|
+
look(): {
|
|
122
|
+
x: number;
|
|
123
|
+
y: number;
|
|
124
|
+
};
|
|
92
125
|
/** True exactly once per press: one tap is one swing.
|
|
93
126
|
*
|
|
94
127
|
* Edge detection done in the game reads the button's STATE and compares it
|
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 = 3.2;
|
|
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,26 @@ 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 ?? 3.2;
|
|
86
91
|
const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
|
|
87
92
|
if (wantTouch && typeof document !== 'undefined') {
|
|
88
93
|
this.mountTouch(opts.container ?? document.body);
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
isDown(...codes) { return codes.some((c) => this.keysDown.has(c)); }
|
|
92
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Which way to walk. Keyboard and stick are merged, so both work on a device
|
|
99
|
+
* with both.
|
|
100
|
+
*
|
|
101
|
+
* Pass the camera's yaw (`LoadedScene3D.cameraYaw`) and "up" means away from
|
|
102
|
+
* the camera rather than north. Once the camera can turn, movement that
|
|
103
|
+
* ignores it is the single most disorienting thing a 3D game can do: the
|
|
104
|
+
* player turns to look at something and the stick still walks them the way
|
|
105
|
+
* they were pointed before. Omit it and you get the old world-axis
|
|
106
|
+
* behaviour, which is correct for a camera that never moves.
|
|
107
|
+
*/
|
|
108
|
+
direction(cameraYaw = 0) {
|
|
95
109
|
let x = this.stick.x, z = this.stick.z;
|
|
96
110
|
if (this.isDown('KeyW', 'ArrowUp'))
|
|
97
111
|
z -= 1;
|
|
@@ -103,7 +117,14 @@ export class Input3D {
|
|
|
103
117
|
x += 1;
|
|
104
118
|
const len = Math.hypot(x, z);
|
|
105
119
|
// Clamp rather than normalise: a half-pushed stick should walk slowly.
|
|
106
|
-
|
|
120
|
+
if (len > 1) {
|
|
121
|
+
x /= len;
|
|
122
|
+
z /= len;
|
|
123
|
+
}
|
|
124
|
+
if (!cameraYaw)
|
|
125
|
+
return { x, z };
|
|
126
|
+
const s = Math.sin(cameraYaw), c = Math.cos(cameraYaw);
|
|
127
|
+
return { x: x * c + z * s, z: z * c - x * s };
|
|
107
128
|
}
|
|
108
129
|
/** Whether the jump control is held right now. Pass it to the controller —
|
|
109
130
|
* coyote time and buffering live there, not here. */
|
|
@@ -113,6 +134,20 @@ export class Input3D {
|
|
|
113
134
|
const a = this.actions.find((x) => x.id === id);
|
|
114
135
|
return this.held_(id, this.touchHeld.has(id) || (a?.keys ? this.isDown(...a.keys) : false));
|
|
115
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* How far the camera should turn this frame, in radians — and reading it
|
|
139
|
+
* CLEARS it.
|
|
140
|
+
*
|
|
141
|
+
* Consumed rather than sampled because it is a delta, not a state: a frame
|
|
142
|
+
* that forgets to read it must not lose the movement, and a frame that reads
|
|
143
|
+
* it twice must not apply it twice. Pass straight to `LoadedScene3D.orbit`.
|
|
144
|
+
*/
|
|
145
|
+
look() {
|
|
146
|
+
const out = { x: this.lookDelta.x, y: this.lookDelta.y };
|
|
147
|
+
this.lookDelta.x = 0;
|
|
148
|
+
this.lookDelta.y = 0;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
116
151
|
/** True exactly once per press: one tap is one swing.
|
|
117
152
|
*
|
|
118
153
|
* Edge detection done in the game reads the button's STATE and compares it
|
|
@@ -183,6 +218,15 @@ export class Input3D {
|
|
|
183
218
|
pointerEvents: floating ? 'auto' : 'none',
|
|
184
219
|
});
|
|
185
220
|
Object.assign(zone.style, NO_SELECTION);
|
|
221
|
+
// The other half. Buttons are appended AFTER this and sit above it, so a
|
|
222
|
+
// tap on jump or attack never reaches the look zone -- which is why this
|
|
223
|
+
// can be the whole right half rather than an awkward cut-out around them.
|
|
224
|
+
const lookZone = document.createElement('div');
|
|
225
|
+
Object.assign(lookZone.style, {
|
|
226
|
+
position: 'absolute', right: '0', top: '0', width: '50%', height: '100%',
|
|
227
|
+
pointerEvents: this.wantLook ? 'auto' : 'none',
|
|
228
|
+
});
|
|
229
|
+
Object.assign(lookZone.style, NO_SELECTION);
|
|
186
230
|
const pad = document.createElement('div');
|
|
187
231
|
Object.assign(pad.style, floating ? {
|
|
188
232
|
position: 'absolute', width: '30vmin', height: '30vmin',
|
|
@@ -244,7 +288,7 @@ export class Input3D {
|
|
|
244
288
|
cluster.append(el);
|
|
245
289
|
this.wireButton(el, a.id);
|
|
246
290
|
}
|
|
247
|
-
root.append(zone, pad, cluster);
|
|
291
|
+
root.append(zone, lookZone, pad, cluster);
|
|
248
292
|
container.appendChild(root);
|
|
249
293
|
this.root = root;
|
|
250
294
|
// And the page underneath. The overlay covers the controls, but a press
|
|
@@ -257,7 +301,11 @@ export class Input3D {
|
|
|
257
301
|
// have inherited a bug from its movement controls.
|
|
258
302
|
const doc = container.ownerDocument;
|
|
259
303
|
const style = doc.createElement('style');
|
|
260
|
-
|
|
304
|
+
// A DIFFERENT marker from the control layer's. Sharing `data-umicat-touch`
|
|
305
|
+
// put a <style> in <head> ahead of the layer in document order, so
|
|
306
|
+
// `querySelector('[data-umicat-touch]')` started returning the stylesheet
|
|
307
|
+
// and anything looking up the controls found an element with no children.
|
|
308
|
+
style.dataset.umicatTouchCss = '';
|
|
261
309
|
style.textContent = `
|
|
262
310
|
html, body { -webkit-user-select: none; user-select: none;
|
|
263
311
|
-webkit-touch-callout: none;
|
|
@@ -342,6 +390,35 @@ export class Input3D {
|
|
|
342
390
|
});
|
|
343
391
|
on(grip, 'pointermove', (e) => { if (e.pointerId === stickId)
|
|
344
392
|
setFromEvent(e); });
|
|
393
|
+
// Looking. A separate pointer id from the stick's, so a thumb on each side
|
|
394
|
+
// works — which is the entire point of splitting the screen in two.
|
|
395
|
+
let lookId = null;
|
|
396
|
+
let lastX = 0, lastY = 0;
|
|
397
|
+
on(lookZone, 'pointerdown', (e) => {
|
|
398
|
+
e.preventDefault();
|
|
399
|
+
if (lookId !== null)
|
|
400
|
+
return;
|
|
401
|
+
lookId = e.pointerId;
|
|
402
|
+
lookZone.setPointerCapture(e.pointerId);
|
|
403
|
+
lastX = e.clientX;
|
|
404
|
+
lastY = e.clientY;
|
|
405
|
+
});
|
|
406
|
+
on(lookZone, 'pointermove', (e) => {
|
|
407
|
+
if (e.pointerId !== lookId)
|
|
408
|
+
return;
|
|
409
|
+
// Scaled by screen WIDTH in both axes, so a drag of the same physical
|
|
410
|
+
// length turns the same amount whichever way it went. Dividing y by
|
|
411
|
+
// height instead makes vertical look wildly faster in landscape.
|
|
412
|
+
const w = Math.max(1, lookZone.getBoundingClientRect().width * 2);
|
|
413
|
+
this.lookDelta.x += ((e.clientX - lastX) / w) * this.lookSens;
|
|
414
|
+
this.lookDelta.y += ((e.clientY - lastY) / w) * this.lookSens;
|
|
415
|
+
lastX = e.clientX;
|
|
416
|
+
lastY = e.clientY;
|
|
417
|
+
});
|
|
418
|
+
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
|
419
|
+
on(lookZone, ev, (e) => { if (e.pointerId === lookId)
|
|
420
|
+
lookId = null; });
|
|
421
|
+
}
|
|
345
422
|
// pointercancel too: a system gesture steals the pointer without an up, and
|
|
346
423
|
// the character would walk forever.
|
|
347
424
|
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.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",
|