@umicat/three-sdk 0.19.0 → 0.21.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/README.md CHANGED
@@ -24,7 +24,8 @@ editor.
24
24
  │
25
25
  @umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
26
26
  CharacterController3D · CharacterAnimator · GameAudio
27
- sockets · tints · setupScreenshotListener/setupRecordingListener
27
+ sockets · skins · attachments · tints
28
+ setupScreenshotListener/setupRecordingListener
28
29
  ▲
29
30
  your game gameplay
30
31
  ```
@@ -73,6 +74,11 @@ Each rule is a decision:
73
74
  Guessing that every model calls its walk cycle `Walk` fails silently; an
74
75
  independent review's cross-rig retarget returned zero matched bones and zero
75
76
  tracks, which is the same class of failure, quieter.
77
+ - **What a character wears is authored on the entity**
78
+ (`attachments: [{ modelAssetId: 'sword' }]`). Because the character already
79
+ says where its sockets are and the prop already says which one it belongs in,
80
+ naming the prop is the whole declaration — a knight in armour is data, not
81
+ equip code written once per game.
76
82
  - **A prop declares which socket it belongs in** (`socket: 'hand-right'` on the
77
83
  sword, `sockets: { 'hand-right': {...} }` on the character). Both halves are
78
84
  properties of the MODEL, not of any game — a cap goes on a head in every game
@@ -0,0 +1,38 @@
1
+ import * as THREE from 'three';
2
+ import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
+ import type { Attachment3D, Manifest3D, ModelAsset3D } from './scene3d.js';
4
+ /**
5
+ * Put the props a character is wearing on it, and take off whatever it was
6
+ * wearing before.
7
+ *
8
+ * **Whole-list, not incremental.** `applyAttachments(hero, …, [])` strips the
9
+ * character; passing a list makes it wear exactly that list. Idempotent, which
10
+ * is what both callers actually want: `loadScene3D` builds a character once,
11
+ * and an editor re-applies the list every time a person changes a dropdown. An
12
+ * add/remove API would make "what is this character wearing" a thing you have
13
+ * to replay history to answer.
14
+ *
15
+ * Which socket a prop goes in is resolved from the prop itself
16
+ * (`ModelAsset3D.socket`) unless the attachment overrides it — the character
17
+ * already declares where its sockets ARE, so naming the prop is normally the
18
+ * whole instruction.
19
+ */
20
+ /** What a wearer currently has on: attachment id (or socket) → the object. */
21
+ export type WornMap = Map<string, THREE.Object3D>;
22
+ export interface ApplyAttachmentsOptions {
23
+ /** Base url props resolve against. */
24
+ assetBase?: string;
25
+ /**
26
+ * Already-fetched props, keyed by model id. `loadScene3D` passes the batch
27
+ * it downloaded in parallel; anyone else can leave it out and have them
28
+ * fetched here.
29
+ */
30
+ preloaded?: Map<string, GLTF>;
31
+ /** What this wearer has on now, so it can be taken off first. */
32
+ previous?: WornMap;
33
+ /** Names the wearer in error messages — an entity id, usually. */
34
+ label?: string;
35
+ }
36
+ /** Drop the prop cache. For a game unloading a level, and for tests. */
37
+ export declare function clearAttachmentCache(): void;
38
+ export declare function applyAttachments(wearer: THREE.Object3D, wearerAsset: ModelAsset3D, manifest: Manifest3D, list: Attachment3D[], opts?: ApplyAttachmentsOptions): Promise<WornMap>;
@@ -0,0 +1,77 @@
1
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
2
+ import { clone as cloneRigged } from 'three/examples/jsm/utils/SkeletonUtils.js';
3
+ import { attachToSocket } from './Sockets.js';
4
+ const cache = new Map();
5
+ let loader = null;
6
+ function fetchProp(url) {
7
+ const hit = cache.get(url);
8
+ if (hit)
9
+ return hit;
10
+ loader ?? (loader = new GLTFLoader());
11
+ const p = loader.loadAsync(url);
12
+ cache.set(url, p);
13
+ // A prop that 404s once must not 404 for the rest of the session.
14
+ p.catch(() => cache.delete(url));
15
+ return p;
16
+ }
17
+ /** Drop the prop cache. For a game unloading a level, and for tests. */
18
+ export function clearAttachmentCache() { cache.clear(); }
19
+ function isRigged(root) {
20
+ let rigged = false;
21
+ root.traverse((o) => { if (o.isSkinnedMesh)
22
+ rigged = true; });
23
+ return rigged;
24
+ }
25
+ /**
26
+ * Take one prop back off.
27
+ *
28
+ * The object's parent is the HOLDER `attachToSocket` created to carry the
29
+ * socket offset, and that is what has to go — removing the object alone
30
+ * leaves an empty holder on the bone, and enough dressing changes leave a
31
+ * character wearing a stack of invisible ones.
32
+ */
33
+ function detach(object) {
34
+ (object.parent ?? object).removeFromParent();
35
+ }
36
+ export async function applyAttachments(wearer, wearerAsset, manifest, list, opts = {}) {
37
+ const who = opts.label ? `entity '${opts.label}'` : `model '${wearerAsset.id}'`;
38
+ const models = new Map((manifest.models ?? []).map((m) => [m.id, m]));
39
+ // Resolve the whole list BEFORE touching the character. A bad prop halfway
40
+ // down otherwise leaves it half-dressed, which is worse than unchanged.
41
+ const resolved = [];
42
+ for (const a of list) {
43
+ const prop = models.get(a.modelAssetId);
44
+ if (!prop) {
45
+ throw new Error(`${who} attaches model '${a.modelAssetId}', which the manifest does not define. ` +
46
+ `It has: ${[...models.keys()].join(', ') || '(none)'}`);
47
+ }
48
+ const socketName = a.socket ?? prop.socket;
49
+ if (!socketName) {
50
+ throw new Error(`${who} attaches '${a.modelAssetId}' with no socket, and that model declares none of ` +
51
+ `its own — give the attachment a 'socket', or the model one`);
52
+ }
53
+ const socket = wearerAsset.sockets?.[socketName];
54
+ if (!socket) {
55
+ throw new Error(`${who} wants socket '${socketName}' for '${a.modelAssetId}', but model ` +
56
+ `'${wearerAsset.id}' has: ${Object.keys(wearerAsset.sockets ?? {}).join(', ') || '(none)'}`);
57
+ }
58
+ resolved.push({ key: a.id ?? socketName, name: a.id ?? prop.id, prop, socket });
59
+ }
60
+ const gltfs = await Promise.all(resolved.map(({ prop }) => opts.preloaded?.get(prop.id) ?? fetchProp((opts.assetBase ?? '') + prop.path)));
61
+ for (const object of opts.previous?.values() ?? [])
62
+ detach(object);
63
+ const worn = new Map();
64
+ resolved.forEach(({ key, name, prop, socket }, i) => {
65
+ const gltf = gltfs[i];
66
+ // Always a clone: one prop asset can be worn by a whole squad, and the
67
+ // first wearer must not end up holding everyone's sword.
68
+ const object = isRigged(gltf.scene) ? cloneRigged(gltf.scene) : gltf.scene.clone(true);
69
+ if (prop.importScale !== undefined)
70
+ object.scale.setScalar(prop.importScale);
71
+ object.userData.modelAssetId = prop.id;
72
+ object.name = name;
73
+ attachToSocket(wearer, socket, object);
74
+ worn.set(key, object);
75
+ });
76
+ return worn;
77
+ }
@@ -1,5 +1,6 @@
1
1
  import * as THREE from 'three';
2
2
  import { type Scene3D, type Manifest3D } from './scene3d.js';
3
+ import { type WornMap } from './Attachments.js';
3
4
  export interface LoadSceneOptions {
4
5
  /** Base url assets resolve against (the project's asset host). */
5
6
  assetBase?: string;
@@ -30,6 +31,9 @@ export interface LoadedScene3D {
30
31
  /** Model asset id → every clip that model shipped with. A game switching
31
32
  * between idle and walk needs the clips, not just the one playing. */
32
33
  clips: Map<string, THREE.AnimationClip[]>;
34
+ /** Entity id → its props, keyed by the attachment's `id` or, failing that,
35
+ * the socket it went into. How a game reaches the sword it authored. */
36
+ attachments: Map<string, WornMap>;
33
37
  /** Entity id → rigid body, when physics was supplied. */
34
38
  bodies: Map<string, any>;
35
39
  world?: any;
@@ -3,6 +3,7 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
3
  import { clone as cloneRigged } from 'three/examples/jsm/utils/SkeletonUtils.js';
4
4
  import { validateScene, IDENTITY_QUAT, } from './scene3d.js';
5
5
  import { applySkin } from './Skin.js';
6
+ import { applyAttachments } from './Attachments.js';
6
7
  function toVec(v, d = 0) {
7
8
  return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
8
9
  }
@@ -115,6 +116,20 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
115
116
  // a model must not download it twice.
116
117
  const models = new Map((manifest.models ?? []).map((m) => [m.id, m]));
117
118
  const needed = new Set(scene3d.entities.map((e) => e.modelAssetId).filter(Boolean));
119
+ // Props are referenced only from `attachments`, so a set built from
120
+ // `modelAssetId` alone leaves every sword unfetched and every attachment
121
+ // silently empty-handed.
122
+ for (const e of scene3d.entities) {
123
+ for (const a of e.attachments ?? []) {
124
+ // Checked HERE rather than at attach time so the message can name the
125
+ // wearer: the generic prefetch error fires first and only knows the id.
126
+ if (!models.has(a.modelAssetId)) {
127
+ throw new Error(`entity '${e.id}' attaches model '${a.modelAssetId}', which the manifest does not define. ` +
128
+ `It has: ${[...models.keys()].join(', ') || '(none)'}`);
129
+ }
130
+ needed.add(a.modelAssetId);
131
+ }
132
+ }
118
133
  const loaded = new Map();
119
134
  if (needed.size) {
120
135
  const loader = new GLTFLoader();
@@ -136,6 +151,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
136
151
  // a caller that awaits `loadScene3D` gets a scene that is finished — not one
137
152
  // whose cast pops from placeholder to costume a frame or two later.
138
153
  const skinJobs = [];
154
+ const attachments = new Map();
155
+ const attachJobs = [];
139
156
  for (const e of scene3d.entities) {
140
157
  let obj;
141
158
  if (e.modelAssetId) {
@@ -209,8 +226,19 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
209
226
  }
210
227
  }));
211
228
  }
229
+ if (e.attachments?.length) {
230
+ // Deferred with the skins: both fetch, and a caller that awaits
231
+ // `loadScene3D` should get a scene that is finished rather than one
232
+ // that arms itself a frame later.
233
+ const wearer = obj;
234
+ attachJobs.push(applyAttachments(wearer, asset, manifest, e.attachments, { assetBase: opts.assetBase, preloaded: loaded, label: e.id })
235
+ .then((worn) => { attachments.set(e.id, worn); }));
236
+ }
212
237
  }
213
238
  else {
239
+ if (e.attachments?.length) {
240
+ throw new Error(`entity '${e.id}' has attachments but no model to hang them on`);
241
+ }
214
242
  obj = makePrimitive(e);
215
243
  }
216
244
  obj.name = e.name ?? e.id;
@@ -222,8 +250,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
222
250
  applyTransform(obj, e);
223
251
  entities.set(e.id, obj);
224
252
  }
225
- if (skinJobs.length)
226
- await Promise.all(skinJobs);
253
+ if (skinJobs.length || attachJobs.length)
254
+ await Promise.all([...skinJobs, ...attachJobs]);
227
255
  // Parent after every entity exists, so declaration order doesn't matter.
228
256
  for (const e of scene3d.entities) {
229
257
  const obj = entities.get(e.id);
@@ -345,7 +373,7 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
345
373
  ? entities.get(scene3d.camera.target) : undefined;
346
374
  const want = new THREE.Vector3();
347
375
  return {
348
- scene, camera, entities, mixers, mixerFor, clips, bodies, world,
376
+ scene, camera, entities, mixers, mixerFor, clips, attachments, bodies, world,
349
377
  get cameraYaw() { return camYaw; },
350
378
  get cameraPitch() { return camPitch; },
351
379
  orbit(dYaw, dPitch) {
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
3
  import { loadScene3D } from '../SceneLoader3D.js';
4
4
  import { applySkin } from '../Skin.js';
5
+ import { applyAttachments } from '../Attachments.js';
5
6
  /**
6
7
  * The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
7
8
  * — renders a scene's AUTHORED data with no game code and no save, so the
@@ -77,6 +78,9 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
77
78
  const objectByEntity = new Map();
78
79
  let manifest = null;
79
80
  let sceneData = null;
81
+ // What each entity is wearing right now, so a re-dress can take the old
82
+ // set off. `loadScene3D` hands this over; every patch replaces an entry.
83
+ let worn = new Map();
80
84
  const resize = () => {
81
85
  renderer.setSize(window.innerWidth, window.innerHeight);
82
86
  camera.aspect = window.innerWidth / window.innerHeight;
@@ -264,6 +268,21 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
264
268
  const object = objectByEntity.get(m.entityId);
265
269
  if (!object)
266
270
  return done(`no entity '${m.entityId}' in the scene being shown`);
271
+ if (m.patch?.attachments !== undefined) {
272
+ const authored = sceneData?.entities.find((e) => e.id === m.entityId);
273
+ const asset = manifest?.models?.find((x) => x.id === authored?.modelAssetId);
274
+ if (!asset)
275
+ return done(`entity '${m.entityId}' has no model to hang props on`);
276
+ try {
277
+ // Whole-list, so the panel can say "this is the outfit" rather than
278
+ // having to work out a diff against what is already on.
279
+ const next = await applyAttachments(object, asset, manifest, m.patch.attachments, { assetBase: '', previous: worn.get(m.entityId), label: m.entityId });
280
+ worn.set(m.entityId, next);
281
+ }
282
+ catch (e) {
283
+ return done(e instanceof Error ? e.message : String(e));
284
+ }
285
+ }
267
286
  if (m.patch?.skin !== undefined) {
268
287
  const authored = sceneData?.entities.find((e) => e.id === m.entityId);
269
288
  const asset = manifest?.models?.find((x) => x.id === authored?.modelAssetId);
@@ -322,6 +341,7 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
322
341
  manifest = await fetch('scenes3d/manifest.json').then((r) => r.json());
323
342
  sceneData = await fetch(`scenes3d/${sceneId}.json`).then((r) => r.json());
324
343
  const loaded = await loadScene3D(sceneData, manifest, { assetBase: '', designMode: true });
344
+ worn = loaded.attachments;
325
345
  scene = loaded.scene;
326
346
  entityByObject.clear();
327
347
  objectByEntity.clear();
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { Input3DOptions, Input3DAction } from './Input3D.js';
11
11
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
12
  export { flashTint, updateTints, isTinted } from './Tint.js';
13
13
  export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
14
+ export { applyAttachments, clearAttachmentCache } from './Attachments.js';
15
+ export type { WornMap, ApplyAttachmentsOptions } from './Attachments.js';
14
16
  export type { SkinOptions, SkinResult } from './Skin.js';
15
17
  export { GameAudio } from './GameAudio.js';
16
18
  export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { Input3D } from './Input3D.js';
12
12
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
13
  export { flashTint, updateTints, isTinted } from './Tint.js';
14
14
  export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
15
+ export { applyAttachments, clearAttachmentCache } from './Attachments.js';
15
16
  export { GameAudio } from './GameAudio.js';
16
17
  export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
17
18
  export { setupRecordingListener } from './capture/RecordingManager.js';
package/dist/scene3d.d.ts CHANGED
@@ -59,6 +59,27 @@ export interface Collider3D {
59
59
  /** Offset from the entity's own origin. */
60
60
  offset?: Vec3;
61
61
  }
62
+ /**
63
+ * Something this entity wears or carries.
64
+ *
65
+ * The pairing that makes this short is already in the manifest from both ends
66
+ * — the character says where its sockets are, the prop says which one it
67
+ * belongs in — so `{ modelAssetId: 'sword' }` is usually the whole thing. A
68
+ * knight in armour becomes authored data rather than equip code written per
69
+ * game, which is the same reason `skin` and `animation.play` are data.
70
+ */
71
+ export interface Attachment3D {
72
+ /** Manifest id of the prop. */
73
+ modelAssetId: string;
74
+ /**
75
+ * Which of the wearer's sockets. Omit to use the prop's own declared
76
+ * `socket` — override only to put something where it does not normally go
77
+ * (a shield strapped to the back, a torch in the off hand).
78
+ */
79
+ socket?: string;
80
+ /** Optional name, so game code can find this one again to swap or drop it. */
81
+ id?: string;
82
+ }
62
83
  export interface Entity3D {
63
84
  /** Stable, authored, unique within the scene. */
64
85
  id: string;
@@ -83,6 +104,8 @@ export interface Entity3D {
83
104
  /** Which skin to wear, by name from the model's `skins` — see `SkinMap`.
84
105
  * Omit to keep whatever texture the glb itself was built with. */
85
106
  skin?: string;
107
+ /** Props worn or carried on this entity's sockets — see `Attachment3D`. */
108
+ attachments?: Attachment3D[];
86
109
  /** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
87
110
  properties?: Record<string, unknown>;
88
111
  visible?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",