@umicat/three-sdk 0.19.0 → 0.20.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
@@ -73,6 +73,11 @@ Each rule is a decision:
73
73
  Guessing that every model calls its walk cycle `Walk` fails silently; an
74
74
  independent review's cross-rig retarget returned zero matched bones and zero
75
75
  tracks, which is the same class of failure, quieter.
76
+ - **What a character wears is authored on the entity**
77
+ (`attachments: [{ modelAssetId: 'sword' }]`). Because the character already
78
+ says where its sockets are and the prop already says which one it belongs in,
79
+ naming the prop is the whole declaration — a knight in armour is data, not
80
+ equip code written once per game.
76
81
  - **A prop declares which socket it belongs in** (`socket: 'hand-right'` on the
77
82
  sword, `sockets: { 'hand-right': {...} }` on the character). Both halves are
78
83
  properties of the MODEL, not of any game — a cap goes on a head in every game
@@ -30,6 +30,9 @@ export interface LoadedScene3D {
30
30
  /** Model asset id → every clip that model shipped with. A game switching
31
31
  * between idle and walk needs the clips, not just the one playing. */
32
32
  clips: Map<string, THREE.AnimationClip[]>;
33
+ /** Entity id → its props, keyed by the attachment's `id` or, failing that,
34
+ * the socket it went into. How a game reaches the sword it authored. */
35
+ attachments: Map<string, Map<string, THREE.Object3D>>;
33
36
  /** Entity id → rigid body, when physics was supplied. */
34
37
  bodies: Map<string, any>;
35
38
  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 { attachToSocket } from './Sockets.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,7 @@ 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();
139
155
  for (const e of scene3d.entities) {
140
156
  let obj;
141
157
  if (e.modelAssetId) {
@@ -209,8 +225,42 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
209
225
  }
210
226
  }));
211
227
  }
228
+ for (const a of e.attachments ?? []) {
229
+ const prop = models.get(a.modelAssetId); // prefetch already proved it exists
230
+ // The prop knows where it belongs; the scene only has to say so when
231
+ // it wants it somewhere else.
232
+ const socketName = a.socket ?? prop.socket;
233
+ if (!socketName) {
234
+ throw new Error(`entity '${e.id}' attaches '${a.modelAssetId}' with no socket, and that model ` +
235
+ `declares none of its own — give the attachment a 'socket', or the model one`);
236
+ }
237
+ const socket = asset.sockets?.[socketName];
238
+ if (!socket) {
239
+ throw new Error(`entity '${e.id}' wants socket '${socketName}' for '${a.modelAssetId}', but model ` +
240
+ `'${e.modelAssetId}' has: ${Object.keys(asset.sockets ?? {}).join(', ') || '(none)'}`);
241
+ }
242
+ const propGltf = loaded.get(a.modelAssetId);
243
+ // Always a clone: one prop asset can be worn by a whole squad, and the
244
+ // first wearer must not end up holding everyone's sword.
245
+ const propObj = isRigged(propGltf.scene)
246
+ ? cloneRigged(propGltf.scene) : propGltf.scene.clone(true);
247
+ if (prop.importScale !== undefined)
248
+ propObj.scale.setScalar(prop.importScale);
249
+ propObj.userData.modelAssetId = a.modelAssetId;
250
+ propObj.name = a.id ?? a.modelAssetId;
251
+ attachToSocket(obj, socket, propObj);
252
+ let mine = attachments.get(e.id);
253
+ if (!mine) {
254
+ mine = new Map();
255
+ attachments.set(e.id, mine);
256
+ }
257
+ mine.set(a.id ?? socketName, propObj);
258
+ }
212
259
  }
213
260
  else {
261
+ if (e.attachments?.length) {
262
+ throw new Error(`entity '${e.id}' has attachments but no model to hang them on`);
263
+ }
214
264
  obj = makePrimitive(e);
215
265
  }
216
266
  obj.name = e.name ?? e.id;
@@ -345,7 +395,7 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
345
395
  ? entities.get(scene3d.camera.target) : undefined;
346
396
  const want = new THREE.Vector3();
347
397
  return {
348
- scene, camera, entities, mixers, mixerFor, clips, bodies, world,
398
+ scene, camera, entities, mixers, mixerFor, clips, attachments, bodies, world,
349
399
  get cameraYaw() { return camYaw; },
350
400
  get cameraPitch() { return camPitch; },
351
401
  orbit(dYaw, dPitch) {
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.20.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",