@umicat/three-sdk 0.5.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) => {
@@ -46,4 +46,19 @@ export interface LoadedScene3D {
46
46
  * settled and shipped for 2D, and it is the reason an editor can render a scene
47
47
  * without starting a game.
48
48
  */
49
+ /** One model from the manifest, loaded on its own.
50
+ *
51
+ * `loadScene3D` only fetches what the SCENE references, which is everything a
52
+ * game needs until the first thing that appears at runtime — a weapon picked
53
+ * up, a projectile, a dropped item. Without this, a game reaches for
54
+ * GLTFLoader itself and re-implements asset-base resolution and importScale
55
+ * slightly differently from the loader beside it.
56
+ *
57
+ * Returns a fresh object each call, so two swords do not share one transform. */
58
+ export declare function loadModelAsset(manifest: Manifest3D, modelAssetId: string, opts?: {
59
+ assetBase?: string;
60
+ }): Promise<{
61
+ object: THREE.Object3D;
62
+ clips: THREE.AnimationClip[];
63
+ }>;
49
64
  export declare function loadScene3D(scene3d: Scene3D, manifest: Manifest3D, opts?: LoadSceneOptions): Promise<LoadedScene3D>;
@@ -45,6 +45,28 @@ function makePrimitive(e) {
45
45
  * settled and shipped for 2D, and it is the reason an editor can render a scene
46
46
  * without starting a game.
47
47
  */
48
+ /** One model from the manifest, loaded on its own.
49
+ *
50
+ * `loadScene3D` only fetches what the SCENE references, which is everything a
51
+ * game needs until the first thing that appears at runtime — a weapon picked
52
+ * up, a projectile, a dropped item. Without this, a game reaches for
53
+ * GLTFLoader itself and re-implements asset-base resolution and importScale
54
+ * slightly differently from the loader beside it.
55
+ *
56
+ * Returns a fresh object each call, so two swords do not share one transform. */
57
+ export async function loadModelAsset(manifest, modelAssetId, opts = {}) {
58
+ const asset = (manifest.models ?? []).find((m) => m.id === modelAssetId);
59
+ if (!asset) {
60
+ const have = (manifest.models ?? []).map((m) => m.id).join(', ');
61
+ throw new Error(`[umicat] manifest has no model '${modelAssetId}'. It has: ${have || '(none)'}`);
62
+ }
63
+ const gltf = await new GLTFLoader().loadAsync((opts.assetBase ?? '') + asset.path);
64
+ const object = gltf.scene;
65
+ if (asset.importScale !== undefined)
66
+ object.scale.setScalar(asset.importScale);
67
+ object.userData.modelAssetId = modelAssetId;
68
+ return { object, clips: gltf.animations };
69
+ }
48
70
  export async function loadScene3D(scene3d, manifest, opts = {}) {
49
71
  const problems = validateScene(scene3d);
50
72
  if (problems.length) {
@@ -0,0 +1,38 @@
1
+ import * as THREE from 'three';
2
+ import type { Socket3D } from './scene3d';
3
+ /**
4
+ * Put a thing in a character's hand.
5
+ *
6
+ * A held object is not a child of the character — it is a child of a BONE, so
7
+ * it inherits that bone's animated transform and follows the swing for free.
8
+ * Parenting to the model root instead produces a sword that hovers politely
9
+ * beside a character doing all the work.
10
+ *
11
+ * The catch, and the reason this is a platform primitive rather than four
12
+ * lines in each game: **nothing guarantees a rig has a hand bone.** The
13
+ * character every 3D Umicat game ships with has seven bones — root, two legs,
14
+ * torso, two arms, head — and no wrist at all. So "the hand" is a position
15
+ * along the arm bone, found by tuning, and it is a property of that model.
16
+ * Every game re-deriving it by eye is how you get twelve games with the sword
17
+ * in twelve slightly different places.
18
+ */
19
+ export interface Attachment {
20
+ /** The bone it ended up on — useful when you want to check your work. */
21
+ readonly bone: THREE.Bone;
22
+ /** The wrapper that carries the socket offset; the object is its child. */
23
+ readonly holder: THREE.Object3D;
24
+ detach(): void;
25
+ }
26
+ /** Find a bone by exact name anywhere under `root`. */
27
+ export declare function findBone(root: THREE.Object3D, name: string): THREE.Bone | null;
28
+ /** Every bone name under `root` — what an error message should offer. */
29
+ export declare function boneNames(root: THREE.Object3D): string[];
30
+ /**
31
+ * Attach `object` to a socket on `root`.
32
+ *
33
+ * Throws if the bone is missing, and says which bones DO exist. A silent miss
34
+ * here is the worst outcome available: the weapon stays parented where it was,
35
+ * usually at the world origin, and the character walks around empty-handed
36
+ * while every line of the game's equip logic reports success.
37
+ */
38
+ export declare function attachToSocket(root: THREE.Object3D, socket: Socket3D, object: THREE.Object3D): Attachment;
@@ -0,0 +1,48 @@
1
+ import * as THREE from 'three';
2
+ /** Find a bone by exact name anywhere under `root`. */
3
+ export function findBone(root, name) {
4
+ let found = null;
5
+ root.traverse((o) => { if (!found && o.isBone && o.name === name)
6
+ found = o; });
7
+ return found;
8
+ }
9
+ /** Every bone name under `root` — what an error message should offer. */
10
+ export function boneNames(root) {
11
+ const out = [];
12
+ root.traverse((o) => { if (o.isBone)
13
+ out.push(o.name); });
14
+ return out;
15
+ }
16
+ /**
17
+ * Attach `object` to a socket on `root`.
18
+ *
19
+ * Throws if the bone is missing, and says which bones DO exist. A silent miss
20
+ * here is the worst outcome available: the weapon stays parented where it was,
21
+ * usually at the world origin, and the character walks around empty-handed
22
+ * while every line of the game's equip logic reports success.
23
+ */
24
+ export function attachToSocket(root, socket, object) {
25
+ const bone = findBone(root, socket.bone);
26
+ if (!bone) {
27
+ throw new Error(`[umicat] no bone "${socket.bone}" on this model. It has: ${boneNames(root).join(', ') || '(no bones at all)'}`);
28
+ }
29
+ // A holder, not the object itself: the socket offset and whatever transform
30
+ // the object arrives with are different things, and baking them together
31
+ // means a game cannot move the object within the hand without losing the
32
+ // socket. It also makes detaching exact.
33
+ const holder = new THREE.Object3D();
34
+ holder.name = `socket:${socket.bone}`;
35
+ if (socket.position)
36
+ holder.position.set(socket.position.x, socket.position.y, socket.position.z);
37
+ if (socket.rotation)
38
+ holder.rotation.set(socket.rotation.x, socket.rotation.y, socket.rotation.z);
39
+ if (socket.scale !== undefined)
40
+ holder.scale.setScalar(socket.scale);
41
+ holder.add(object);
42
+ bone.add(holder);
43
+ return {
44
+ bone,
45
+ holder,
46
+ detach() { holder.removeFromParent(); },
47
+ };
48
+ }
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
@@ -1,13 +1,16 @@
1
1
  export { ThreeUmicat } from './ThreeUmicat.js';
2
2
  export type { UmicatInitOptions } from './ThreeUmicat.js';
3
3
  export * from './scene3d.js';
4
- export { loadScene3D } from './SceneLoader3D.js';
4
+ export { loadScene3D, loadModelAsset } from './SceneLoader3D.js';
5
5
  export { CharacterController3D } from './CharacterController3D.js';
6
6
  export type { CharacterOptions, CharacterState, CharacterInput } from './CharacterController3D.js';
7
7
  export { CharacterAnimator } from './CharacterAnimator.js';
8
8
  export type { ClipMap, CharacterAnimatorOptions } from './CharacterAnimator.js';
9
9
  export { Input3D } from './Input3D.js';
10
- export type { Input3DOptions } from './Input3D.js';
10
+ export type { Input3DOptions, Input3DAction } from './Input3D.js';
11
+ export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
+ export { flashTint, updateTints, isTinted } from './Tint.js';
13
+ export type { Attachment } from './Sockets.js';
11
14
  export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
12
15
  export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
13
16
  export type { Orientation } from '@umicat/platform-sdk/orientation.js';
package/dist/index.js CHANGED
@@ -5,10 +5,12 @@
5
5
  // that turns it into a three.js scene with physics, and the runtime glue.
6
6
  export { ThreeUmicat } from './ThreeUmicat.js';
7
7
  export * from './scene3d.js';
8
- export { loadScene3D } from './SceneLoader3D.js';
8
+ export { loadScene3D, loadModelAsset } from './SceneLoader3D.js';
9
9
  export { CharacterController3D } from './CharacterController3D.js';
10
10
  export { CharacterAnimator } from './CharacterAnimator.js';
11
11
  export { Input3D } from './Input3D.js';
12
+ export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
+ export { flashTint, updateTints, isTinted } from './Tint.js';
12
14
  // Re-exported so a game imports one package for the common case. A game should
13
15
  // not have to know that identity and saves come from a different package than
14
16
  // the renderer.
package/dist/scene3d.d.ts CHANGED
@@ -92,6 +92,26 @@ export interface Entity3D {
92
92
  * model calls its walk cycle `Walk` fails the same way, only more quietly.
93
93
  */
94
94
  export type AnimationMap = Record<string, string>;
95
+ /**
96
+ * Where a held object sits on a character, named by what it is FOR rather
97
+ * than by which bone it happens to hang off.
98
+ *
99
+ * Rigs do not necessarily have a hand bone — this character's has seven bones
100
+ * and the arm is one of them, so a sword hangs off `arm-right` with an offset
101
+ * that puts it where a hand would be. That offset is a property of the MODEL,
102
+ * not of any game, so it travels in the manifest: tune it once and every game
103
+ * using this character gets a sword in the right place.
104
+ */
105
+ export interface Socket3D {
106
+ /** Bone to hang from. Names are exact; the loader reports what it found. */
107
+ bone: string;
108
+ /** Offset in the bone's local space. */
109
+ position?: Vec3;
110
+ /** Euler angles in radians, applied in the bone's local space. */
111
+ rotation?: Vec3;
112
+ /** Uniform scale for whatever is attached. */
113
+ scale?: number;
114
+ }
95
115
  export interface ModelAsset3D {
96
116
  id: string;
97
117
  /** Resolved by the runtime against the project's asset base url. */
@@ -99,6 +119,8 @@ export interface ModelAsset3D {
99
119
  animations?: AnimationMap;
100
120
  /** Authoring correction applied at load: most models are not born 1 unit tall. */
101
121
  importScale?: number;
122
+ /** Named attachment points — `{ "hand-right": { bone: "arm-right", ... } }`. */
123
+ sockets?: Record<string, Socket3D>;
102
124
  }
103
125
  export interface Light3D {
104
126
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.5.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",