@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 +2 -1
- package/dist/Attachments.d.ts +38 -0
- package/dist/Attachments.js +77 -0
- package/dist/SceneLoader3D.d.ts +2 -1
- package/dist/SceneLoader3D.js +11 -33
- package/dist/editor/EditorDesignPlayer3D.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
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 ·
|
|
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
|
+
}
|
package/dist/SceneLoader3D.d.ts
CHANGED
|
@@ -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,
|
|
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;
|
package/dist/SceneLoader3D.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
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",
|