@umicat/three-sdk 0.4.0 → 0.6.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
@@ -1,3 +1,12 @@
1
+ /** An extra on-screen button, and the keys that do the same thing. */
2
+ export interface Input3DAction {
3
+ /** What the game calls it: `input.held('attack')`, `input.consume('attack')`. */
4
+ id: string;
5
+ /** What the button shows. Keep it one glyph. */
6
+ label?: string;
7
+ /** Keyboard equivalents, e.g. `['KeyJ']`. */
8
+ keys?: string[];
9
+ }
1
10
  export interface Input3DOptions {
2
11
  /** Where key events are read from. */
3
12
  target?: Window;
@@ -14,6 +23,16 @@ export interface Input3DOptions {
14
23
  * this layer belongs to the platform.
15
24
  */
16
25
  container?: HTMLElement | null;
26
+ /**
27
+ * Extra action buttons — attack, interact, whatever the game has.
28
+ *
29
+ * These exist because a game that mounts its own button has no idea where
30
+ * the jump button is, and the first one to try landed exactly on top of it:
31
+ * same corner, and this layer sits above, so every tap in that area jumped
32
+ * and the attack button could not be pressed at all. Nothing errored. The
33
+ * platform places the controls, so the platform has to place ALL of them.
34
+ */
35
+ actions?: Input3DAction[];
17
36
  }
18
37
  /**
19
38
  * Movement and jump, from a keyboard or a thumb, behind one interface.
@@ -31,13 +50,17 @@ export interface Input3DOptions {
31
50
  * has to branch on input source.
32
51
  */
33
52
  export declare class Input3D {
34
- private readonly held;
53
+ private readonly keysDown;
35
54
  private readonly target;
36
55
  private readonly onDown;
37
56
  private readonly onUp;
38
57
  private readonly onBlur;
39
58
  private readonly stick;
40
59
  private touchJump;
60
+ private actions;
61
+ private readonly touchHeld;
62
+ private readonly pressedAt;
63
+ private readonly unconsumed;
41
64
  private root;
42
65
  private readonly cleanups;
43
66
  constructor(options?: Input3DOptions | Window);
@@ -51,9 +74,21 @@ export declare class Input3D {
51
74
  /** Whether the jump control is held right now. Pass it to the controller —
52
75
  * coyote time and buffering live there, not here. */
53
76
  get jump(): boolean;
77
+ /** Whether an action is held right now — button or bound key, same answer. */
78
+ held(id: string): boolean;
79
+ /** True exactly once per press: one tap is one swing.
80
+ *
81
+ * Edge detection done in the game reads the button's STATE and compares it
82
+ * to last frame's, which means a press that begins and ends between two
83
+ * frames never happened. This latches at the event, so it cannot be missed
84
+ * — and it clears on read, so holding the button does not chain. */
85
+ consume(id: string): boolean;
86
+ private held_;
87
+ private beginPress;
54
88
  /** Test seam: drive the controller without synthesising DOM events. */
55
89
  press(code: string): void;
56
90
  release(code: string): void;
57
91
  dispose(): void;
58
92
  private mountTouch;
93
+ private wireButton;
59
94
  }
package/dist/Input3D.js CHANGED
@@ -1,3 +1,11 @@
1
+ /** How long a press stays readable, however briefly it was actually made.
2
+ *
3
+ * A tap whose down and up both land between two frames is invisible to a
4
+ * loop that polls state — the game samples `false`, `false`, and the player
5
+ * swears the button did nothing. Holding it for a few frames costs nothing
6
+ * and makes a quick tap always count. */
7
+ const MIN_PRESS_MS = 80;
8
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
1
9
  const isTouchDevice = () => typeof matchMedia === 'function' &&
2
10
  matchMedia('(pointer: coarse)').matches &&
3
11
  !matchMedia('(pointer: fine)').matches;
@@ -18,18 +26,35 @@ const isTouchDevice = () => typeof matchMedia === 'function' &&
18
26
  */
19
27
  export class Input3D {
20
28
  constructor(options = {}) {
21
- this.held = new Set();
29
+ this.keysDown = new Set();
22
30
  this.onDown = (e) => {
23
- this.held.add(e.code);
31
+ this.keysDown.add(e.code);
32
+ if (!e.repeat) {
33
+ if (e.code === 'Space')
34
+ this.beginPress('jump');
35
+ for (const a of this.actions)
36
+ if (a.keys?.includes(e.code))
37
+ this.beginPress(a.id);
38
+ }
24
39
  // Space scrolls the page, which in an embedded game scrolls the HOST page
25
40
  // out from under the player.
26
41
  if (e.code === 'Space')
27
42
  e.preventDefault();
28
43
  };
29
- this.onUp = (e) => { this.held.delete(e.code); };
30
- this.onBlur = () => { this.held.clear(); this.stick.x = 0; this.stick.z = 0; this.touchJump = false; };
44
+ this.onUp = (e) => { this.keysDown.delete(e.code); };
45
+ this.onBlur = () => {
46
+ this.keysDown.clear();
47
+ this.stick.x = 0;
48
+ this.stick.z = 0;
49
+ this.touchJump = false;
50
+ this.touchHeld.clear();
51
+ };
31
52
  this.stick = { x: 0, z: 0 };
32
53
  this.touchJump = false;
54
+ this.actions = [];
55
+ this.touchHeld = new Set();
56
+ this.pressedAt = new Map();
57
+ this.unconsumed = new Set();
33
58
  this.root = null;
34
59
  this.cleanups = [];
35
60
  // A Window here is the pre-touch signature; keep it working.
@@ -40,12 +65,13 @@ export class Input3D {
40
65
  this.target.addEventListener('keyup', this.onUp);
41
66
  // Losing focus mid-press would otherwise leave the character walking.
42
67
  this.target.addEventListener('blur', this.onBlur);
68
+ this.actions = opts.actions ?? [];
43
69
  const wantTouch = opts.touch === undefined || opts.touch === 'auto' ? isTouchDevice() : opts.touch;
44
70
  if (wantTouch && typeof document !== 'undefined') {
45
71
  this.mountTouch(opts.container ?? document.body);
46
72
  }
47
73
  }
48
- isDown(...codes) { return codes.some((c) => this.held.has(c)); }
74
+ isDown(...codes) { return codes.some((c) => this.keysDown.has(c)); }
49
75
  /** Camera-relative would need the camera; this is world-axis movement.
50
76
  * Keyboard and stick are merged, so both work on a device with both. */
51
77
  direction() {
@@ -64,10 +90,32 @@ export class Input3D {
64
90
  }
65
91
  /** Whether the jump control is held right now. Pass it to the controller —
66
92
  * coyote time and buffering live there, not here. */
67
- get jump() { return this.touchJump || this.isDown('Space'); }
93
+ get jump() { return this.held_('jump', this.touchJump || this.isDown('Space')); }
94
+ /** Whether an action is held right now — button or bound key, same answer. */
95
+ held(id) {
96
+ const a = this.actions.find((x) => x.id === id);
97
+ return this.held_(id, this.touchHeld.has(id) || (a?.keys ? this.isDown(...a.keys) : false));
98
+ }
99
+ /** True exactly once per press: one tap is one swing.
100
+ *
101
+ * Edge detection done in the game reads the button's STATE and compares it
102
+ * to last frame's, which means a press that begins and ends between two
103
+ * frames never happened. This latches at the event, so it cannot be missed
104
+ * — and it clears on read, so holding the button does not chain. */
105
+ consume(id) { return this.unconsumed.delete(id); }
106
+ held_(id, physical) {
107
+ if (physical)
108
+ return true;
109
+ const t = this.pressedAt.get(id);
110
+ return t !== undefined && now() - t < MIN_PRESS_MS;
111
+ }
112
+ beginPress(id) {
113
+ this.pressedAt.set(id, now());
114
+ this.unconsumed.add(id);
115
+ }
68
116
  /** Test seam: drive the controller without synthesising DOM events. */
69
- press(code) { this.held.add(code); }
70
- release(code) { this.held.delete(code); }
117
+ press(code) { this.keysDown.add(code); }
118
+ release(code) { this.keysDown.delete(code); }
71
119
  dispose() {
72
120
  this.target.removeEventListener('keydown', this.onDown);
73
121
  this.target.removeEventListener('keyup', this.onUp);
@@ -77,7 +125,7 @@ export class Input3D {
77
125
  this.cleanups.length = 0;
78
126
  this.root?.remove();
79
127
  this.root = null;
80
- this.held.clear();
128
+ this.keysDown.clear();
81
129
  }
82
130
  // ── on-screen controls ────────────────────────────────────────────────────
83
131
  mountTouch(container) {
@@ -106,17 +154,38 @@ export class Input3D {
106
154
  transform: 'translate(-50%, -50%)', pointerEvents: 'none',
107
155
  });
108
156
  pad.appendChild(knob);
109
- const btn = document.createElement('div');
110
- Object.assign(btn.style, {
157
+ // ONE cluster holds every button, laid out by flexbox, because the bug this
158
+ // replaces was two buttons independently choosing "bottom right" and
159
+ // landing on top of each other. Jump sits at the corner where it always
160
+ // was; actions stack to its left and wrap upward.
161
+ const cluster = document.createElement('div');
162
+ Object.assign(cluster.style, {
111
163
  position: 'absolute', right: '6vmin', bottom: '7vmin',
112
- width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
113
- borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
114
- border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
115
- display: 'flex', alignItems: 'center', justifyContent: 'center',
116
- color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
164
+ display: 'flex', flexDirection: 'row-reverse', alignItems: 'flex-end',
165
+ flexWrap: 'wrap-reverse', justifyContent: 'flex-start',
166
+ gap: '3vmin', maxWidth: '52vmin', pointerEvents: 'none',
117
167
  });
118
- btn.textContent = '▲';
119
- root.append(pad, btn);
168
+ const makeButton = (label) => {
169
+ const el = document.createElement('div');
170
+ Object.assign(el.style, {
171
+ width: '20vmin', height: '20vmin', maxWidth: '120px', maxHeight: '120px',
172
+ flex: '0 0 auto',
173
+ borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
174
+ border: '2px solid rgba(255,255,255,0.4)', pointerEvents: 'auto',
175
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
176
+ color: 'rgba(255,255,255,0.85)', font: '600 4vmin/1 system-ui, sans-serif',
177
+ });
178
+ el.textContent = label;
179
+ return el;
180
+ };
181
+ const btn = makeButton('▲');
182
+ cluster.append(btn);
183
+ for (const a of this.actions) {
184
+ const el = makeButton(a.label ?? a.id.slice(0, 1).toUpperCase());
185
+ cluster.append(el);
186
+ this.wireButton(el, a.id);
187
+ }
188
+ root.append(pad, cluster);
120
189
  container.appendChild(root);
121
190
  this.root = root;
122
191
  // Track by pointerId so a thumb on the stick and a thumb on the button do
@@ -158,9 +227,31 @@ export class Input3D {
158
227
  on(pad, ev, (e) => { if (e.pointerId === stickId)
159
228
  reset(); });
160
229
  }
161
- on(btn, 'pointerdown', (e) => { btn.setPointerCapture(e.pointerId); this.touchJump = true; });
230
+ on(btn, 'pointerdown', (e) => {
231
+ btn.setPointerCapture(e.pointerId);
232
+ this.touchJump = true;
233
+ this.beginPress('jump');
234
+ });
162
235
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
163
236
  on(btn, ev, () => { this.touchJump = false; });
164
237
  }
165
238
  }
239
+ wireButton(el, id) {
240
+ const on = (ev, fn) => {
241
+ const h = fn;
242
+ el.addEventListener(ev, h);
243
+ this.cleanups.push(() => el.removeEventListener(ev, h));
244
+ };
245
+ on('pointerdown', (e) => {
246
+ e.preventDefault();
247
+ el.setPointerCapture(e.pointerId);
248
+ this.touchHeld.add(id);
249
+ this.beginPress(id);
250
+ });
251
+ // pointercancel included: a system gesture steals the pointer with no `up`,
252
+ // and the button would read as held for the rest of the run.
253
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) {
254
+ on(ev, () => { this.touchHeld.delete(id); });
255
+ }
256
+ }
166
257
  }
@@ -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
+ }
@@ -21,6 +21,33 @@ export class ThreeUmicat extends UmicatCore {
21
21
  this.dialogue = new DialogueModule(this.saves, () => transport.locale ?? 'en', () => transport.user?.name ?? null);
22
22
  }
23
23
  static async init(options = {}) {
24
- return new ThreeUmicat(await UmicatCore.connect(SDK_VERSION, options));
24
+ const game = new ThreeUmicat(await UmicatCore.connect(SDK_VERSION, options));
25
+ announceScaleMode();
26
+ return game;
27
+ }
28
+ }
29
+ /**
30
+ * Tell the host this game fills the frame.
31
+ *
32
+ * The host letterboxes to the game's authored aspect UNLESS the game says
33
+ * otherwise, and it reads silence as "built before the SDK announced anything"
34
+ * — which every 3D game was, because only the Phaser SDK ever sent this. So a
35
+ * 3D game on a phone rendered into a 16:9 box with black down the side, losing
36
+ * a third of the screen, and the only symptom was a black band nobody could
37
+ * attribute to anything.
38
+ *
39
+ * A 3D game is always `resize`: the camera's aspect follows the window, so
40
+ * there is no authored pixel canvas to preserve. That is a property of drawing
41
+ * in 3D, not a per-game preference, which is why this is not an option.
42
+ */
43
+ function announceScaleMode() {
44
+ try {
45
+ // Safe no-op when not embedded — the same game runs standalone in dev.
46
+ if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
47
+ window.parent.postMessage({ type: 'umicat:scaleMode', mode: 'resize' }, '*');
48
+ }
49
+ }
50
+ catch {
51
+ /* cross-origin parent access can throw in odd embeds — non-fatal */
25
52
  }
26
53
  }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
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 type { Attachment } from './Sockets.js';
11
13
  export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
12
14
  export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
13
15
  export type { Orientation } from '@umicat/platform-sdk/orientation.js';
package/dist/index.js CHANGED
@@ -5,10 +5,11 @@
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';
12
13
  // Re-exported so a game imports one package for the common case. A game should
13
14
  // not have to know that identity and saves come from a different package than
14
15
  // 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.4.0",
3
+ "version": "0.6.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",