@umicat/three-sdk 0.20.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
  ```
@@ -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;
@@ -32,7 +33,7 @@ export interface LoadedScene3D {
32
33
  clips: Map<string, THREE.AnimationClip[]>;
33
34
  /** Entity id → its props, keyed by the attachment's `id` or, failing that,
34
35
  * the socket it went into. How a game reaches the sword it authored. */
35
- attachments: Map<string, Map<string, THREE.Object3D>>;
36
+ attachments: Map<string, WornMap>;
36
37
  /** Entity id → rigid body, when physics was supplied. */
37
38
  bodies: Map<string, any>;
38
39
  world?: any;
@@ -3,7 +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
+ import { applyAttachments } from './Attachments.js';
7
7
  function toVec(v, d = 0) {
8
8
  return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
9
9
  }
@@ -152,6 +152,7 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
152
152
  // whose cast pops from placeholder to costume a frame or two later.
153
153
  const skinJobs = [];
154
154
  const attachments = new Map();
155
+ const attachJobs = [];
155
156
  for (const e of scene3d.entities) {
156
157
  let obj;
157
158
  if (e.modelAssetId) {
@@ -225,36 +226,13 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
225
226
  }
226
227
  }));
227
228
  }
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);
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); }));
258
236
  }
259
237
  }
260
238
  else {
@@ -272,8 +250,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
272
250
  applyTransform(obj, e);
273
251
  entities.set(e.id, obj);
274
252
  }
275
- if (skinJobs.length)
276
- await Promise.all(skinJobs);
253
+ if (skinJobs.length || attachJobs.length)
254
+ await Promise.all([...skinJobs, ...attachJobs]);
277
255
  // Parent after every entity exists, so declaration order doesn't matter.
278
256
  for (const e of scene3d.entities) {
279
257
  const obj = entities.get(e.id);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.20.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",