@umicat/three-sdk 0.17.2 → 0.18.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 +7 -0
- package/dist/SceneLoader3D.d.ts +1 -0
- package/dist/SceneLoader3D.js +70 -5
- package/dist/Skin.d.ts +82 -0
- package/dist/Skin.js +133 -0
- package/dist/editor/EditorDesignPlayer3D.d.ts +14 -4
- package/dist/editor/EditorDesignPlayer3D.js +202 -43
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scene3d.d.ts +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,13 @@ 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
|
+
- **Skins are declared per asset and chosen per entity**
|
|
77
|
+
(`skins: { zombie: 'skins/zombieA.png' }`, then `skin: 'zombie'`). A body and
|
|
78
|
+
its wardrobe are separate files, so N bodies × M skins costs N + M assets
|
|
79
|
+
rather than N × M baked glb files. It is authored data rather than a call a
|
|
80
|
+
game makes, for the same reason the clip map is: a capability only reachable
|
|
81
|
+
from code is one most games never find, and an editor cannot offer a list it
|
|
82
|
+
cannot see.
|
|
76
83
|
|
|
77
84
|
`validateScene` refuses duplicate ids, dangling parents, entities that would
|
|
78
85
|
render nothing, trimesh colliders on dynamic bodies, and malformed quaternions —
|
package/dist/SceneLoader3D.d.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface LoadedScene3D {
|
|
|
66
66
|
* Returns a fresh object each call, so two swords do not share one transform. */
|
|
67
67
|
export declare function loadModelAsset(manifest: Manifest3D, modelAssetId: string, opts?: {
|
|
68
68
|
assetBase?: string;
|
|
69
|
+
skin?: string;
|
|
69
70
|
}): Promise<{
|
|
70
71
|
object: THREE.Object3D;
|
|
71
72
|
clips: THREE.AnimationClip[];
|
package/dist/SceneLoader3D.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
3
|
+
import { clone as cloneRigged } from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
3
4
|
import { validateScene, IDENTITY_QUAT, } from './scene3d.js';
|
|
5
|
+
import { applySkin } from './Skin.js';
|
|
4
6
|
function toVec(v, d = 0) {
|
|
5
7
|
return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
|
|
6
8
|
}
|
|
9
|
+
/** Does this model deform with bones? Decides which clone it needs. */
|
|
10
|
+
function isRigged(root) {
|
|
11
|
+
let rigged = false;
|
|
12
|
+
root.traverse((o) => { if (o.isSkinnedMesh)
|
|
13
|
+
rigged = true; });
|
|
14
|
+
return rigged;
|
|
15
|
+
}
|
|
7
16
|
function applyTransform(obj, e) {
|
|
8
17
|
const t = e.transform;
|
|
9
18
|
obj.position.copy(toVec(t.position));
|
|
@@ -62,6 +71,14 @@ export async function loadModelAsset(manifest, modelAssetId, opts = {}) {
|
|
|
62
71
|
}
|
|
63
72
|
const gltf = await new GLTFLoader().loadAsync((opts.assetBase ?? '') + asset.path);
|
|
64
73
|
const object = gltf.scene;
|
|
74
|
+
if (opts.skin) {
|
|
75
|
+
const path = asset.skins?.[opts.skin];
|
|
76
|
+
if (!path) {
|
|
77
|
+
throw new Error(`[umicat] model '${modelAssetId}' has no skin '${opts.skin}'. It declares: ` +
|
|
78
|
+
`${Object.keys(asset.skins ?? {}).join(', ') || '(none)'}`);
|
|
79
|
+
}
|
|
80
|
+
await applySkin(object, path, { assetBase: opts.assetBase });
|
|
81
|
+
}
|
|
65
82
|
if (asset.importScale !== undefined)
|
|
66
83
|
object.scale.setScalar(asset.importScale);
|
|
67
84
|
object.userData.modelAssetId = modelAssetId;
|
|
@@ -115,16 +132,31 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
115
132
|
const clips = new Map();
|
|
116
133
|
for (const [id, gltf] of loaded)
|
|
117
134
|
clips.set(id, gltf.animations);
|
|
135
|
+
// Skins are fetched per entity and awaited before this function resolves, so
|
|
136
|
+
// a caller that awaits `loadScene3D` gets a scene that is finished — not one
|
|
137
|
+
// whose cast pops from placeholder to costume a frame or two later.
|
|
138
|
+
const skinJobs = [];
|
|
118
139
|
for (const e of scene3d.entities) {
|
|
119
140
|
let obj;
|
|
120
141
|
if (e.modelAssetId) {
|
|
121
142
|
const gltf = loaded.get(e.modelAssetId);
|
|
122
143
|
const asset = models.get(e.modelAssetId);
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
144
|
+
// Clone only when a second entity wants the same asset — the first one
|
|
145
|
+
// gets the loaded scene itself.
|
|
146
|
+
//
|
|
147
|
+
// **A rigged model must be cloned with `SkeletonUtils.clone`.**
|
|
148
|
+
// `Object3D.clone()` copies the bone hierarchy but leaves the cloned
|
|
149
|
+
// SkinnedMesh pointing at the ORIGINAL `Skeleton`, so every clone
|
|
150
|
+
// deforms with the first one's bones: a squad of guards all play the
|
|
151
|
+
// same animation in the same place, stacked on the leader, while each
|
|
152
|
+
// one's own mixer reports that it is playing something else. Nothing
|
|
153
|
+
// errors. This became load-bearing the moment one body plus a set of
|
|
154
|
+
// skins became the way characters ship — "many instances of one model"
|
|
155
|
+
// went from a corner to the main case.
|
|
126
156
|
const used = [...entities.values()].some((o) => o.userData.modelAssetId === e.modelAssetId);
|
|
127
|
-
obj = used ? gltf.scene
|
|
157
|
+
obj = !used ? gltf.scene
|
|
158
|
+
: isRigged(gltf.scene) ? cloneRigged(gltf.scene)
|
|
159
|
+
: gltf.scene.clone(true);
|
|
128
160
|
obj.userData.modelAssetId = e.modelAssetId;
|
|
129
161
|
if (asset.importScale)
|
|
130
162
|
obj.scale.setScalar(asset.importScale);
|
|
@@ -160,6 +192,23 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
160
192
|
mixers.push(mixer);
|
|
161
193
|
mixerFor.set(e.id, mixer);
|
|
162
194
|
}
|
|
195
|
+
if (e.skin) {
|
|
196
|
+
const path = asset.skins?.[e.skin];
|
|
197
|
+
if (!path) {
|
|
198
|
+
// Same contract as a missing clip: say which names DO exist. A skin
|
|
199
|
+
// that silently doesn't apply looks exactly like a model built with
|
|
200
|
+
// the wrong default, and you go looking in Blender.
|
|
201
|
+
throw new Error(`entity '${e.id}' wants skin '${e.skin}', but model '${e.modelAssetId}' declares: ` +
|
|
202
|
+
`${Object.keys(asset.skins ?? {}).join(', ') || '(none)'}`);
|
|
203
|
+
}
|
|
204
|
+
const target = obj;
|
|
205
|
+
skinJobs.push(applySkin(target, path, { assetBase: opts.assetBase }).then((r) => {
|
|
206
|
+
if (r.repainted === 0) {
|
|
207
|
+
throw new Error(`entity '${e.id}' skin '${e.skin}' repainted nothing — model ` +
|
|
208
|
+
`'${e.modelAssetId}' has no material with a base-colour texture to replace`);
|
|
209
|
+
}
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
163
212
|
}
|
|
164
213
|
else {
|
|
165
214
|
obj = makePrimitive(e);
|
|
@@ -173,6 +222,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
173
222
|
applyTransform(obj, e);
|
|
174
223
|
entities.set(e.id, obj);
|
|
175
224
|
}
|
|
225
|
+
if (skinJobs.length)
|
|
226
|
+
await Promise.all(skinJobs);
|
|
176
227
|
// Parent after every entity exists, so declaration order doesn't matter.
|
|
177
228
|
for (const e of scene3d.entities) {
|
|
178
229
|
const obj = entities.get(e.id);
|
|
@@ -220,8 +271,22 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
220
271
|
// hits it immediately.
|
|
221
272
|
{
|
|
222
273
|
const bounds = new THREE.Box3();
|
|
274
|
+
// precise:true is load-bearing. Box3.expandByObject's default path, for any
|
|
275
|
+
// object exposing its own `.boundingBox` (only THREE.SkinnedMesh does),
|
|
276
|
+
// calls `object.computeBoundingBox()` and PERMANENTLY CACHES the result on
|
|
277
|
+
// the mesh — and for `bindMode: 'attached'` (GLTFLoader's default), that
|
|
278
|
+
// computation folds in each bone's absolute matrixWorld, so the cached box
|
|
279
|
+
// ends up sitting at the entity's placed-in-scene position. `raycast()`
|
|
280
|
+
// then treats that same cached box as LOCAL space and inverts matrixWorld
|
|
281
|
+
// before testing against it, so every ray misses. Net effect: any rigged
|
|
282
|
+
// model entity (a hero, an NPC) becomes permanently unraycastable — hit
|
|
283
|
+
// everywhere on screen except the one thing worth clicking — the moment
|
|
284
|
+
// this shadow-fitting pass runs. `precise:true` walks vertices directly
|
|
285
|
+
// instead and never touches `object.boundingBox`. Confirmed by direct
|
|
286
|
+
// repro against a shipped game's rigged `hero` entity: unclickable with
|
|
287
|
+
// the default call, hits correctly once this switched to precise.
|
|
223
288
|
for (const o of entities.values())
|
|
224
|
-
bounds.expandByObject(o);
|
|
289
|
+
bounds.expandByObject(o, true);
|
|
225
290
|
if (!bounds.isEmpty()) {
|
|
226
291
|
const size = bounds.getSize(new THREE.Vector3());
|
|
227
292
|
const centre = bounds.getCenter(new THREE.Vector3());
|
package/dist/Skin.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Repaint a character by swapping its base-colour texture.
|
|
4
|
+
*
|
|
5
|
+
* One rigged body plus N textures is how a character pack is actually shipped
|
|
6
|
+
* — the Kenney set every Umicat 3D game draws from has four bodies, seventeen
|
|
7
|
+
* clips and eighty-one skins painted on ONE shared UV layout, which is 324
|
|
8
|
+
* characters from 85 files. Baking the combinations instead would be 324 glb
|
|
9
|
+
* files, the same mesh and the same clips repeated 324 times, and a palette
|
|
10
|
+
* swap would cost a fresh megabyte-and-a-half download.
|
|
11
|
+
*
|
|
12
|
+
* It is a platform primitive rather than `mat.map = tex` in each game because
|
|
13
|
+
* of three traps, each of which fails quietly:
|
|
14
|
+
*
|
|
15
|
+
* **Clones share materials.** `gltf.scene.clone(true)` hands every clone the
|
|
16
|
+
* same material instance, so reskinning one zombie reskins the whole horde.
|
|
17
|
+
* `Tint.ts` exists for the same reason and its notes tell the same story: the
|
|
18
|
+
* bug reads as a gameplay bug, not a graphics one. Materials are therefore
|
|
19
|
+
* cloned per object, once, the first time it is skinned.
|
|
20
|
+
*
|
|
21
|
+
* **A plainly-loaded texture is upside down.** `GLTFLoader` sets `flipY =
|
|
22
|
+
* false` on everything it loads (glTF puts the UV origin top-left);
|
|
23
|
+
* `TextureLoader` defaults to `true`. So the obvious one-liner gives you a
|
|
24
|
+
* character wearing its own texture mirrored vertically — shoes on the head —
|
|
25
|
+
* and nothing anywhere reports a problem. The replacement copies its settings
|
|
26
|
+
* from the map it replaces, which also preserves the model's authored
|
|
27
|
+
* filtering: these skins are region maps, and a bilinear tap straddling two
|
|
28
|
+
* regions invents a colour that is in neither.
|
|
29
|
+
*
|
|
30
|
+
* **Not every material is skin.** A character carrying a sword has the sword's
|
|
31
|
+
* steel in the same subtree. Repainting every material would paint the blade
|
|
32
|
+
* with a face. The default target is "materials that already have a base
|
|
33
|
+
* colour map", which selects the textured body and leaves flat palette colours
|
|
34
|
+
* alone — and correctly follows through to attachments that ARE skin-painted,
|
|
35
|
+
* like the animal ears and tails, whose material is the character's own.
|
|
36
|
+
*/
|
|
37
|
+
export interface SkinOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Narrow which materials get repainted, by exact name or pattern. Omit to
|
|
40
|
+
* repaint every material that already has a base-colour map, which is the
|
|
41
|
+
* right answer for a body-plus-props subtree.
|
|
42
|
+
*/
|
|
43
|
+
material?: string | RegExp;
|
|
44
|
+
/**
|
|
45
|
+
* Reuse one `THREE.Texture` per url across every call. Default true — a
|
|
46
|
+
* squad of eight guards in the same uniform should download and upload one
|
|
47
|
+
* texture, not eight. Turn it off only if a game mutates the texture it gets.
|
|
48
|
+
*/
|
|
49
|
+
cache?: boolean;
|
|
50
|
+
/** Where to resolve a relative url against, e.g. the project's asset host. */
|
|
51
|
+
assetBase?: string;
|
|
52
|
+
}
|
|
53
|
+
/** What a skin swap did, so a caller can tell "worked" from "hit nothing". */
|
|
54
|
+
export interface SkinResult {
|
|
55
|
+
/** How many materials were repainted. Zero means the filter matched nothing. */
|
|
56
|
+
readonly repainted: number;
|
|
57
|
+
/** The texture now in use, for a game that wants to reuse it directly. */
|
|
58
|
+
readonly texture: THREE.Texture;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Put `texture` on `object` as its base colour. The synchronous half — use it
|
|
62
|
+
* when a game already holds the texture (a preloaded team colour, an atlas
|
|
63
|
+
* page); otherwise use `applySkin`, which fetches.
|
|
64
|
+
*
|
|
65
|
+
* The texture is NOT disposed on replacement: the map coming off usually came
|
|
66
|
+
* from the glb and is shared with every other instance of that model, so
|
|
67
|
+
* disposing it blanks the rest of the cast.
|
|
68
|
+
*/
|
|
69
|
+
export declare function applySkinTexture(object: THREE.Object3D, texture: THREE.Texture, opts?: SkinOptions): SkinResult;
|
|
70
|
+
/**
|
|
71
|
+
* Fetch a skin texture and put it on `object`.
|
|
72
|
+
*
|
|
73
|
+
* Returns how many materials it actually repainted. **Zero is worth checking**
|
|
74
|
+
* — it means the filter matched nothing, which on a model whose body material
|
|
75
|
+
* has no texture at all is the difference between "reskinned" and "silently
|
|
76
|
+
* did nothing", and there is no other signal.
|
|
77
|
+
*/
|
|
78
|
+
export declare function applySkin(object: THREE.Object3D, url: string, opts?: SkinOptions): Promise<SkinResult>;
|
|
79
|
+
/** Load (and by default cache) one skin texture. Exposed for preloading. */
|
|
80
|
+
export declare function loadSkinTexture(url: string, cache?: boolean): Promise<THREE.Texture>;
|
|
81
|
+
/** Drop the texture cache. For a game unloading a level, and for tests. */
|
|
82
|
+
export declare function clearSkinCache(): void;
|
package/dist/Skin.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/** Objects whose materials have already been given their own copies. */
|
|
3
|
+
const isolated = new WeakSet();
|
|
4
|
+
const textures = new Map();
|
|
5
|
+
let loader = null;
|
|
6
|
+
function matches(material, filter) {
|
|
7
|
+
if (filter === undefined) {
|
|
8
|
+
// Untextured materials are palette colours — a sword's steel, a cap's
|
|
9
|
+
// felt. They are not skin and must not be painted with one.
|
|
10
|
+
return material.map != null;
|
|
11
|
+
}
|
|
12
|
+
return typeof filter === 'string' ? material.name === filter : filter.test(material.name);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Give `object` its own copy of every material it uses, so painting it paints
|
|
16
|
+
* only it. Idempotent: the second call on the same object does nothing.
|
|
17
|
+
*/
|
|
18
|
+
function isolate(object) {
|
|
19
|
+
if (isolated.has(object))
|
|
20
|
+
return;
|
|
21
|
+
isolated.add(object);
|
|
22
|
+
object.traverse((o) => {
|
|
23
|
+
const mesh = o;
|
|
24
|
+
if (!mesh.isMesh)
|
|
25
|
+
return;
|
|
26
|
+
mesh.material = Array.isArray(mesh.material)
|
|
27
|
+
? mesh.material.map((m) => m.clone())
|
|
28
|
+
: mesh.material.clone();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Point `texture` at the same sampling the map it replaces was authored with.
|
|
33
|
+
*
|
|
34
|
+
* This is the whole reason a skin swap is not a one-liner. `flipY` alone is
|
|
35
|
+
* the difference between a character and a character wearing its texture
|
|
36
|
+
* upside down, and it differs between the loader that produced the model and
|
|
37
|
+
* the loader producing the replacement.
|
|
38
|
+
*/
|
|
39
|
+
function matchSampling(texture, previous) {
|
|
40
|
+
if (previous) {
|
|
41
|
+
texture.flipY = previous.flipY;
|
|
42
|
+
texture.colorSpace = previous.colorSpace;
|
|
43
|
+
texture.wrapS = previous.wrapS;
|
|
44
|
+
texture.wrapT = previous.wrapT;
|
|
45
|
+
texture.magFilter = previous.magFilter;
|
|
46
|
+
texture.minFilter = previous.minFilter;
|
|
47
|
+
texture.anisotropy = previous.anisotropy;
|
|
48
|
+
texture.generateMipmaps = previous.generateMipmaps;
|
|
49
|
+
texture.channel = previous.channel;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// No map to copy from: assume the glTF convention, since that is what
|
|
53
|
+
// every model this package loads was born as.
|
|
54
|
+
texture.flipY = false;
|
|
55
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
56
|
+
}
|
|
57
|
+
texture.needsUpdate = true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Put `texture` on `object` as its base colour. The synchronous half — use it
|
|
61
|
+
* when a game already holds the texture (a preloaded team colour, an atlas
|
|
62
|
+
* page); otherwise use `applySkin`, which fetches.
|
|
63
|
+
*
|
|
64
|
+
* The texture is NOT disposed on replacement: the map coming off usually came
|
|
65
|
+
* from the glb and is shared with every other instance of that model, so
|
|
66
|
+
* disposing it blanks the rest of the cast.
|
|
67
|
+
*/
|
|
68
|
+
export function applySkinTexture(object, texture, opts = {}) {
|
|
69
|
+
isolate(object);
|
|
70
|
+
let repainted = 0;
|
|
71
|
+
const done = new Set();
|
|
72
|
+
object.traverse((o) => {
|
|
73
|
+
const mesh = o;
|
|
74
|
+
if (!mesh.isMesh)
|
|
75
|
+
return;
|
|
76
|
+
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
77
|
+
for (const m of list) {
|
|
78
|
+
if (done.has(m) || !matches(m, opts.material))
|
|
79
|
+
continue;
|
|
80
|
+
done.add(m);
|
|
81
|
+
const std = m;
|
|
82
|
+
// Clone per material rather than sharing one Texture object across
|
|
83
|
+
// materials: sampling is copied from whatever each one had, and two
|
|
84
|
+
// materials in the same model need not have been authored the same.
|
|
85
|
+
const tex = texture.clone();
|
|
86
|
+
matchSampling(tex, std.map ?? null);
|
|
87
|
+
std.map = tex;
|
|
88
|
+
std.needsUpdate = true;
|
|
89
|
+
repainted += 1;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return { repainted, texture };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fetch a skin texture and put it on `object`.
|
|
96
|
+
*
|
|
97
|
+
* Returns how many materials it actually repainted. **Zero is worth checking**
|
|
98
|
+
* — it means the filter matched nothing, which on a model whose body material
|
|
99
|
+
* has no texture at all is the difference between "reskinned" and "silently
|
|
100
|
+
* did nothing", and there is no other signal.
|
|
101
|
+
*/
|
|
102
|
+
export async function applySkin(object, url, opts = {}) {
|
|
103
|
+
const full = (opts.assetBase ?? '') + url;
|
|
104
|
+
const texture = await loadSkinTexture(full, opts.cache !== false);
|
|
105
|
+
return applySkinTexture(object, texture, opts);
|
|
106
|
+
}
|
|
107
|
+
/** Load (and by default cache) one skin texture. Exposed for preloading. */
|
|
108
|
+
export function loadSkinTexture(url, cache = true) {
|
|
109
|
+
if (cache) {
|
|
110
|
+
const hit = textures.get(url);
|
|
111
|
+
if (hit)
|
|
112
|
+
return hit;
|
|
113
|
+
}
|
|
114
|
+
loader ?? (loader = new THREE.TextureLoader());
|
|
115
|
+
const p = loader.loadAsync(url).then((t) => {
|
|
116
|
+
t.flipY = false;
|
|
117
|
+
t.colorSpace = THREE.SRGBColorSpace;
|
|
118
|
+
return t;
|
|
119
|
+
});
|
|
120
|
+
if (cache) {
|
|
121
|
+
textures.set(url, p);
|
|
122
|
+
// A failed fetch must not be remembered as the answer: a skin that 404s
|
|
123
|
+
// once would then 404 for the rest of the session even after a fix.
|
|
124
|
+
p.catch(() => textures.delete(url));
|
|
125
|
+
}
|
|
126
|
+
return p;
|
|
127
|
+
}
|
|
128
|
+
/** Drop the texture cache. For a game unloading a level, and for tests. */
|
|
129
|
+
export function clearSkinCache() {
|
|
130
|
+
for (const p of textures.values())
|
|
131
|
+
p.then((t) => t.dispose()).catch(() => { });
|
|
132
|
+
textures.clear();
|
|
133
|
+
}
|
|
@@ -16,10 +16,20 @@ export interface EditorDesignPlayer3DOptions {
|
|
|
16
16
|
* The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
|
|
17
17
|
* — renders a scene's AUTHORED data with no game code and no save, so the
|
|
18
18
|
* platform's Edit tab can show a 3D game's layout the same way it already
|
|
19
|
-
* does for 2D.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* does for 2D.
|
|
20
|
+
*
|
|
21
|
+
* Two interactions:
|
|
22
|
+
* - **Click** an entity to select it — a bounding box frames it and a
|
|
23
|
+
* marker reads off the exact world coordinate, posted to the host so a
|
|
24
|
+
* person can point at a real spot instead of guessing one blind when
|
|
25
|
+
* telling the AI where to place something.
|
|
26
|
+
* - **Drag** an entity to move it (Phase 1 of real editing, ground-plane
|
|
27
|
+
* constrained — position only, no rotation/scale, no gizmo, no
|
|
28
|
+
* Inspector). This function itself never writes anything — it only
|
|
29
|
+
* moves the live THREE.Object3D and posts the final LOCAL position on
|
|
30
|
+
* release (`umicat:editor:dragEnd3d`); the host owns turning that into a
|
|
31
|
+
* disk write. Grabbing empty space/sky orbits instead, same convention
|
|
32
|
+
* as Unity/Blender.
|
|
23
33
|
*
|
|
24
34
|
* Takes over `renderer`'s animation loop entirely for the rest of the page's
|
|
25
35
|
* life — there is no scene-swap API and no teardown, matching how the
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
3
3
|
import { loadScene3D } from '../SceneLoader3D.js';
|
|
4
|
+
import { applySkin } from '../Skin.js';
|
|
4
5
|
/**
|
|
5
6
|
* The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
|
|
6
7
|
* — renders a scene's AUTHORED data with no game code and no save, so the
|
|
7
8
|
* platform's Edit tab can show a 3D game's layout the same way it already
|
|
8
|
-
* does for 2D.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* does for 2D.
|
|
10
|
+
*
|
|
11
|
+
* Two interactions:
|
|
12
|
+
* - **Click** an entity to select it — a bounding box frames it and a
|
|
13
|
+
* marker reads off the exact world coordinate, posted to the host so a
|
|
14
|
+
* person can point at a real spot instead of guessing one blind when
|
|
15
|
+
* telling the AI where to place something.
|
|
16
|
+
* - **Drag** an entity to move it (Phase 1 of real editing, ground-plane
|
|
17
|
+
* constrained — position only, no rotation/scale, no gizmo, no
|
|
18
|
+
* Inspector). This function itself never writes anything — it only
|
|
19
|
+
* moves the live THREE.Object3D and posts the final LOCAL position on
|
|
20
|
+
* release (`umicat:editor:dragEnd3d`); the host owns turning that into a
|
|
21
|
+
* disk write. Grabbing empty space/sky orbits instead, same convention
|
|
22
|
+
* as Unity/Blender.
|
|
12
23
|
*
|
|
13
24
|
* Takes over `renderer`'s animation loop entirely for the rest of the page's
|
|
14
25
|
* life — there is no scene-swap API and no teardown, matching how the
|
|
@@ -60,6 +71,12 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
60
71
|
}
|
|
61
72
|
let scene = null;
|
|
62
73
|
const entityByObject = new Map();
|
|
74
|
+
// The reverse direction, and the authored data behind it. Picking only ever
|
|
75
|
+
// needed object→id; editing a PROPERTY needs to go the other way, and needs
|
|
76
|
+
// the manifest to resolve what a name like `skin: "zombieA"` refers to.
|
|
77
|
+
const objectByEntity = new Map();
|
|
78
|
+
let manifest = null;
|
|
79
|
+
let sceneData = null;
|
|
63
80
|
const resize = () => {
|
|
64
81
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
65
82
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
@@ -67,68 +84,207 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
67
84
|
};
|
|
68
85
|
resize();
|
|
69
86
|
window.addEventListener('resize', resize);
|
|
87
|
+
const raycaster = new THREE.Raycaster();
|
|
88
|
+
const ndcFromEvent = (e) => {
|
|
89
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
90
|
+
return new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
91
|
+
};
|
|
92
|
+
// Only VISIBLE entity roots are ever raycast against. three.js's
|
|
93
|
+
// Mesh.raycast does NOT check `.visible` on its own (confirmed against the
|
|
94
|
+
// pinned three source — no such check in Raycaster.js or Object3D.raycast),
|
|
95
|
+
// and a real scene can ship an invisible collision box alongside
|
|
96
|
+
// separately-placed visible ground models — without this filter, a click
|
|
97
|
+
// that visibly lands on the rendered ground can silently return the
|
|
98
|
+
// collider's coordinates instead.
|
|
99
|
+
const pickEntityAt = (e) => {
|
|
100
|
+
raycaster.setFromCamera(ndcFromEvent(e), camera);
|
|
101
|
+
const targets = Array.from(entityByObject.keys()).filter((o) => o.visible !== false);
|
|
102
|
+
const hits = raycaster.intersectObjects(targets, true);
|
|
103
|
+
if (hits.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
let node = hits[0].object;
|
|
106
|
+
while (node) {
|
|
107
|
+
const id = entityByObject.get(node);
|
|
108
|
+
if (id)
|
|
109
|
+
return { entityId: id, entityRoot: node, point: hits[0].point };
|
|
110
|
+
node = node.parent;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
70
114
|
// Orbit-vs-click: a real drag moves the pointer more than a few px.
|
|
71
115
|
let downX = 0;
|
|
72
116
|
let downY = 0;
|
|
73
117
|
let moved = false;
|
|
118
|
+
// Entity drag — Phase 1 of real editing (translate only, ground-plane
|
|
119
|
+
// constrained). Grabbing on an entity moves it; grabbing empty space/sky
|
|
120
|
+
// still orbits, same convention as Unity/Blender. Whether THIS gesture is
|
|
121
|
+
// a drag is decided synchronously in pointerdown (a raycast hit or not) —
|
|
122
|
+
// `OrbitControls`'s own pointerdown listener runs first (registered
|
|
123
|
+
// earlier) and always starts its internal drag bookkeeping, but its
|
|
124
|
+
// `onPointerMove`/related handlers re-check `this.enabled` on every event
|
|
125
|
+
// rather than only at attach time (confirmed against the pinned
|
|
126
|
+
// OrbitControls source), so disabling it HERE still correctly suppresses
|
|
127
|
+
// every subsequent move for this gesture; its `onPointerUp` cleanup does
|
|
128
|
+
// not check `enabled` at all, so re-enabling afterward never leaves it
|
|
129
|
+
// stuck. No drag state survives a scene reload — it's cleared on release,
|
|
130
|
+
// and a scene swap only ever happens via a full iframe reload anyway.
|
|
131
|
+
let dragEntity = null;
|
|
132
|
+
let dragEntityId = null;
|
|
133
|
+
let dragPlane = null;
|
|
134
|
+
let dragGrabOffset = null; // world-space, plane-point minus object position at grab time
|
|
74
135
|
const onPointerDown = (e) => {
|
|
75
136
|
downX = e.clientX;
|
|
76
137
|
downY = e.clientY;
|
|
77
138
|
moved = false;
|
|
139
|
+
if (!scene)
|
|
140
|
+
return;
|
|
141
|
+
const hit = pickEntityAt(e);
|
|
142
|
+
if (!hit)
|
|
143
|
+
return; // empty space — let OrbitControls own this gesture
|
|
144
|
+
const objectPos = hit.entityRoot.getWorldPosition(new THREE.Vector3());
|
|
145
|
+
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -objectPos.y);
|
|
146
|
+
const planePoint = new THREE.Vector3();
|
|
147
|
+
// Looking exactly parallel to the ground has no plane intersection —
|
|
148
|
+
// vanishingly rare for this camera (auto-framed, always angled down at
|
|
149
|
+
// the scene) but cheap to guard: fall through to orbit rather than
|
|
150
|
+
// start a drag from a garbage grab offset.
|
|
151
|
+
if (!raycaster.ray.intersectPlane(plane, planePoint))
|
|
152
|
+
return;
|
|
153
|
+
dragEntity = hit.entityRoot;
|
|
154
|
+
dragEntityId = hit.entityId;
|
|
155
|
+
dragPlane = plane;
|
|
156
|
+
dragGrabOffset = planePoint.sub(objectPos);
|
|
157
|
+
controls.enabled = false;
|
|
158
|
+
try {
|
|
159
|
+
renderer.domElement.setPointerCapture(e.pointerId);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Multi-touch / an already-released pointer can throw here — never
|
|
163
|
+
// let that leave the gesture half-started (see the board-game touch
|
|
164
|
+
// lesson: an uncaught throw from setPointerCapture stranded a finger
|
|
165
|
+
// as permanently "down"). The drag still works via normal bubbling;
|
|
166
|
+
// capture is an enhancement (keeps it live if the cursor leaves the
|
|
167
|
+
// canvas mid-drag), not a requirement.
|
|
168
|
+
}
|
|
78
169
|
};
|
|
79
170
|
const onPointerMove = (e) => {
|
|
80
171
|
if (Math.abs(e.clientX - downX) > 4 || Math.abs(e.clientY - downY) > 4)
|
|
81
172
|
moved = true;
|
|
173
|
+
if (!dragEntity || !dragPlane || !dragGrabOffset || !scene)
|
|
174
|
+
return;
|
|
175
|
+
raycaster.setFromCamera(ndcFromEvent(e), camera);
|
|
176
|
+
const planePoint = new THREE.Vector3();
|
|
177
|
+
if (!raycaster.ray.intersectPlane(dragPlane, planePoint))
|
|
178
|
+
return; // looking parallel to the plane
|
|
179
|
+
const newWorldPos = planePoint.sub(dragGrabOffset);
|
|
180
|
+
if (dragEntity.parent)
|
|
181
|
+
dragEntity.parent.worldToLocal(newWorldPos);
|
|
182
|
+
dragEntity.position.copy(newWorldPos);
|
|
183
|
+
selectionBox.setFromObject(dragEntity);
|
|
82
184
|
};
|
|
83
185
|
const onPointerUp = (e) => {
|
|
186
|
+
try {
|
|
187
|
+
renderer.domElement.releasePointerCapture(e.pointerId);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Already released, or never captured (e.g. this gesture never hit an
|
|
191
|
+
// entity) — nothing to do either way.
|
|
192
|
+
}
|
|
193
|
+
// A drag only really happened if the pointer actually moved — grabbing
|
|
194
|
+
// an entity and releasing without moving is a CLICK on it (case below),
|
|
195
|
+
// not a same-position no-op save.
|
|
196
|
+
if (moved && dragEntity && dragEntityId) {
|
|
197
|
+
window.parent.postMessage({
|
|
198
|
+
type: 'umicat:editor:dragEnd3d',
|
|
199
|
+
sceneId: opts.sceneId,
|
|
200
|
+
entityId: dragEntityId,
|
|
201
|
+
position: { x: dragEntity.position.x, y: dragEntity.position.y, z: dragEntity.position.z },
|
|
202
|
+
}, '*');
|
|
203
|
+
}
|
|
204
|
+
dragEntity = null;
|
|
205
|
+
dragEntityId = null;
|
|
206
|
+
dragPlane = null;
|
|
207
|
+
dragGrabOffset = null;
|
|
208
|
+
controls.enabled = true;
|
|
84
209
|
if (moved || !scene)
|
|
85
210
|
return;
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
raycaster.setFromCamera(mouse, camera);
|
|
90
|
-
// Only cast against VISIBLE entity roots. three.js's Mesh.raycast does
|
|
91
|
-
// NOT check `.visible` on its own (confirmed against the pinned three
|
|
92
|
-
// source — no such check in Raycaster.js or Object3D.raycast), and a
|
|
93
|
-
// real scene can ship an invisible collision box alongside separately-
|
|
94
|
-
// placed visible ground models — without this filter, a click that
|
|
95
|
-
// visibly lands on the rendered ground can silently return the
|
|
96
|
-
// collider's coordinates instead.
|
|
97
|
-
const targets = Array.from(entityByObject.keys()).filter((o) => o.visible !== false);
|
|
98
|
-
const hits = raycaster.intersectObjects(targets, true);
|
|
99
|
-
if (hits.length === 0)
|
|
211
|
+
// A plain click (no drag) — the existing read-a-coordinate behavior.
|
|
212
|
+
const hit = pickEntityAt(e);
|
|
213
|
+
if (!hit)
|
|
100
214
|
return;
|
|
101
|
-
const hit = hits[0];
|
|
102
|
-
let node = hit.object;
|
|
103
|
-
let entityId = null;
|
|
104
|
-
let entityRoot = null;
|
|
105
|
-
while (node) {
|
|
106
|
-
const id = entityByObject.get(node);
|
|
107
|
-
if (id) {
|
|
108
|
-
entityId = id;
|
|
109
|
-
entityRoot = node;
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
|
-
node = node.parent;
|
|
113
|
-
}
|
|
114
215
|
marker.position.copy(hit.point);
|
|
115
216
|
marker.visible = true;
|
|
116
217
|
// Frame the whole entity the click landed on, not just the point on its
|
|
117
218
|
// surface — `setFromObject` walks the root's full subtree, so a
|
|
118
219
|
// multi-mesh entity (a model with separate parts) gets one box around
|
|
119
220
|
// all of it, matching what "entityId" actually refers to.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
selectionBox.visible = false;
|
|
126
|
-
}
|
|
127
|
-
window.parent.postMessage({ type: 'umicat:editor:pickPoint3d', x: hit.point.x, y: hit.point.y, z: hit.point.z, entityId }, '*');
|
|
221
|
+
selectionBox.setFromObject(hit.entityRoot);
|
|
222
|
+
selectionBox.visible = true;
|
|
223
|
+
window.parent.postMessage({ type: 'umicat:editor:pickPoint3d', x: hit.point.x, y: hit.point.y, z: hit.point.z, entityId: hit.entityId }, '*');
|
|
128
224
|
};
|
|
129
225
|
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
|
130
226
|
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
|
131
227
|
renderer.domElement.addEventListener('pointerup', onPointerUp);
|
|
228
|
+
/**
|
|
229
|
+
* The FIRST host→iframe message this player has ever accepted.
|
|
230
|
+
*
|
|
231
|
+
* Everything until now went one way: the player reported picks and drags,
|
|
232
|
+
* and the host wrote them to disk. That works for a drag because the SDK
|
|
233
|
+
* itself already moved the object — the canvas was correct before the host
|
|
234
|
+
* heard about it. It does not work for a property edited in a panel: the
|
|
235
|
+
* host changes the file, the canvas knows nothing, and the edit is invisible
|
|
236
|
+
* until a rebuild. A property editor whose changes don't show is a property
|
|
237
|
+
* editor nobody trusts (a sibling 2D tool shipped exactly that bug — a new
|
|
238
|
+
* widget kind not wired into its live-patch path silently did nothing on
|
|
239
|
+
* canvas while the Inspector happily accepted the value).
|
|
240
|
+
*
|
|
241
|
+
* So: `patchEntity3d` in, `patchApplied3d` out. The ack matters as much as
|
|
242
|
+
* the patch — the host has ALREADY written the file by the time this runs,
|
|
243
|
+
* so a failure here means disk and canvas disagree, and the only way anyone
|
|
244
|
+
* finds out is if this says so.
|
|
245
|
+
*
|
|
246
|
+
* `patch` is an open bag deliberately: skin is the first field, and adding
|
|
247
|
+
* rotation/scale/visible later should be a new `if`, not a new message.
|
|
248
|
+
*/
|
|
249
|
+
async function onHostMessage(event) {
|
|
250
|
+
// Source-identity, not origin: the host page and this iframe are on
|
|
251
|
+
// different origins by design (a CDN preview url), same as the checks the
|
|
252
|
+
// host does on messages coming the other way.
|
|
253
|
+
if (event.source !== window.parent)
|
|
254
|
+
return;
|
|
255
|
+
const data = event.data;
|
|
256
|
+
if (!data || typeof data !== 'object' || data.type !== 'umicat:editor:patchEntity3d')
|
|
257
|
+
return;
|
|
258
|
+
const m = data;
|
|
259
|
+
const done = (error) => {
|
|
260
|
+
if (error)
|
|
261
|
+
console.warn('[umicat/editor] 3D patch failed:', m.entityId, error);
|
|
262
|
+
window.parent.postMessage({ type: 'umicat:editor:patchApplied3d', entityId: m.entityId, ok: !error, error }, '*');
|
|
263
|
+
};
|
|
264
|
+
const object = objectByEntity.get(m.entityId);
|
|
265
|
+
if (!object)
|
|
266
|
+
return done(`no entity '${m.entityId}' in the scene being shown`);
|
|
267
|
+
if (m.patch?.skin !== undefined) {
|
|
268
|
+
const authored = sceneData?.entities.find((e) => e.id === m.entityId);
|
|
269
|
+
const asset = manifest?.models?.find((x) => x.id === authored?.modelAssetId);
|
|
270
|
+
const path = asset?.skins?.[m.patch.skin];
|
|
271
|
+
if (!path) {
|
|
272
|
+
return done(`model '${authored?.modelAssetId ?? '(none)'}' declares no skin '${m.patch.skin}'. ` +
|
|
273
|
+
`It has: ${Object.keys(asset?.skins ?? {}).join(', ') || '(none)'}`);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const result = await applySkin(object, path, { assetBase: '' });
|
|
277
|
+
if (result.repainted === 0) {
|
|
278
|
+
return done(`skin '${m.patch.skin}' repainted nothing — this model has no textured material`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
return done(e instanceof Error ? e.message : String(e));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
done();
|
|
286
|
+
}
|
|
287
|
+
window.addEventListener('message', (e) => { void onHostMessage(e); });
|
|
132
288
|
// Design mode skips physics and animation entirely (loadScene3D's own
|
|
133
289
|
// designMode flag) — there are no mixers to advance and no Rapier world to
|
|
134
290
|
// step, so the loop only ever needs to render.
|
|
@@ -163,13 +319,16 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
163
319
|
if (!sceneId)
|
|
164
320
|
return; // no scene chosen yet — idle, waiting for the host
|
|
165
321
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const loaded = await loadScene3D(
|
|
322
|
+
manifest = await fetch('scenes3d/manifest.json').then((r) => r.json());
|
|
323
|
+
sceneData = await fetch(`scenes3d/${sceneId}.json`).then((r) => r.json());
|
|
324
|
+
const loaded = await loadScene3D(sceneData, manifest, { assetBase: '', designMode: true });
|
|
169
325
|
scene = loaded.scene;
|
|
170
326
|
entityByObject.clear();
|
|
171
|
-
|
|
327
|
+
objectByEntity.clear();
|
|
328
|
+
for (const [id, obj] of loaded.entities) {
|
|
172
329
|
entityByObject.set(obj, id);
|
|
330
|
+
objectByEntity.set(id, obj);
|
|
331
|
+
}
|
|
173
332
|
marker.visible = false;
|
|
174
333
|
scene.add(marker);
|
|
175
334
|
selectionBox.visible = false;
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { Input3D } from './Input3D.js';
|
|
|
10
10
|
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
|
+
export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
|
|
14
|
+
export type { SkinOptions, SkinResult } from './Skin.js';
|
|
13
15
|
export { GameAudio } from './GameAudio.js';
|
|
14
16
|
export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
|
|
15
17
|
export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { CharacterAnimator } from './CharacterAnimator.js';
|
|
|
11
11
|
export { Input3D } from './Input3D.js';
|
|
12
12
|
export { attachToSocket, findBone, boneNames } from './Sockets.js';
|
|
13
13
|
export { flashTint, updateTints, isTinted } from './Tint.js';
|
|
14
|
+
export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
|
|
14
15
|
export { GameAudio } from './GameAudio.js';
|
|
15
16
|
export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
|
|
16
17
|
export { setupRecordingListener } from './capture/RecordingManager.js';
|
package/dist/scene3d.d.ts
CHANGED
|
@@ -80,6 +80,9 @@ export interface Entity3D {
|
|
|
80
80
|
play?: string;
|
|
81
81
|
loop?: boolean;
|
|
82
82
|
};
|
|
83
|
+
/** Which skin to wear, by name from the model's `skins` — see `SkinMap`.
|
|
84
|
+
* Omit to keep whatever texture the glb itself was built with. */
|
|
85
|
+
skin?: string;
|
|
83
86
|
/** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
|
|
84
87
|
properties?: Record<string, unknown>;
|
|
85
88
|
visible?: boolean;
|
|
@@ -101,6 +104,17 @@ export interface Entity3D {
|
|
|
101
104
|
* model calls its walk cycle `Walk` fails the same way, only more quietly.
|
|
102
105
|
*/
|
|
103
106
|
export type AnimationMap = Record<string, string>;
|
|
107
|
+
/**
|
|
108
|
+
* Skin name → the texture that paints it, relative to the asset base.
|
|
109
|
+
*
|
|
110
|
+
* A character pack is one rigged body and many textures on one UV layout, so
|
|
111
|
+
* the body and its wardrobe are separate assets and the pairing is authored
|
|
112
|
+
* here rather than baked into a glb per combination. Declaring them means a
|
|
113
|
+
* scene picks a skin by name (`Entity3D.skin`) instead of a game writing load
|
|
114
|
+
* code, and an editor can offer the list — a capability only reachable from
|
|
115
|
+
* code is one most games never find.
|
|
116
|
+
*/
|
|
117
|
+
export type SkinMap = Record<string, string>;
|
|
104
118
|
/**
|
|
105
119
|
* Where a held object sits on a character, named by what it is FOR rather
|
|
106
120
|
* than by which bone it happens to hang off.
|
|
@@ -126,6 +140,8 @@ export interface ModelAsset3D {
|
|
|
126
140
|
/** Resolved by the runtime against the project's asset base url. */
|
|
127
141
|
path: string;
|
|
128
142
|
animations?: AnimationMap;
|
|
143
|
+
/** Interchangeable textures for this model — `{ "zombie": "skins/zombieA.png" }`. */
|
|
144
|
+
skins?: SkinMap;
|
|
129
145
|
/** Authoring correction applied at load: most models are not born 1 unit tall. */
|
|
130
146
|
importScale?: number;
|
|
131
147
|
/** Named attachment points — `{ "hand-right": { bone: "arm-right", ... } }`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|