@umicat/three-sdk 0.17.3 → 0.19.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 +12 -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.js +74 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scene3d.d.ts +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,18 @@ 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
|
+
- **A prop declares which socket it belongs in** (`socket: 'hand-right'` on the
|
|
77
|
+
sword, `sockets: { 'hand-right': {...} }` on the character). Both halves are
|
|
78
|
+
properties of the MODEL, not of any game — a cap goes on a head in every game
|
|
79
|
+
there has ever been — so a game that owns a character pack does not also have
|
|
80
|
+
to own a lookup table saying a cap is headwear.
|
|
81
|
+
- **Skins are declared per asset and chosen per entity**
|
|
82
|
+
(`skins: { zombie: 'skins/zombieA.png' }`, then `skin: 'zombie'`). A body and
|
|
83
|
+
its wardrobe are separate files, so N bodies × M skins costs N + M assets
|
|
84
|
+
rather than N × M baked glb files. It is authored data rather than a call a
|
|
85
|
+
game makes, for the same reason the clip map is: a capability only reachable
|
|
86
|
+
from code is one most games never find, and an editor cannot offer a list it
|
|
87
|
+
cannot see.
|
|
76
88
|
|
|
77
89
|
`validateScene` refuses duplicate ids, dangling parents, entities that would
|
|
78
90
|
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
@@ -70,6 +71,12 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
70
71
|
}
|
|
71
72
|
let scene = null;
|
|
72
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;
|
|
73
80
|
const resize = () => {
|
|
74
81
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
75
82
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
@@ -218,6 +225,66 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
218
225
|
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
|
219
226
|
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
|
220
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); });
|
|
221
288
|
// Design mode skips physics and animation entirely (loadScene3D's own
|
|
222
289
|
// designMode flag) — there are no mixers to advance and no Rapier world to
|
|
223
290
|
// step, so the loop only ever needs to render.
|
|
@@ -252,13 +319,16 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
|
|
|
252
319
|
if (!sceneId)
|
|
253
320
|
return; // no scene chosen yet — idle, waiting for the host
|
|
254
321
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
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 });
|
|
258
325
|
scene = loaded.scene;
|
|
259
326
|
entityByObject.clear();
|
|
260
|
-
|
|
327
|
+
objectByEntity.clear();
|
|
328
|
+
for (const [id, obj] of loaded.entities) {
|
|
261
329
|
entityByObject.set(obj, id);
|
|
330
|
+
objectByEntity.set(id, obj);
|
|
331
|
+
}
|
|
262
332
|
marker.visible = false;
|
|
263
333
|
scene.add(marker);
|
|
264
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,19 @@ 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;
|
|
145
|
+
/**
|
|
146
|
+
* For a model that is WORN rather than worn-on: which of a character's
|
|
147
|
+
* `sockets` it belongs in — `"head"` for a cap, `"hand-right"` for a sword.
|
|
148
|
+
*
|
|
149
|
+
* The pairing is a property of the prop, not of any game: a cap goes on a
|
|
150
|
+
* head in every game that has ever existed. Without it, "attach this cap"
|
|
151
|
+
* needs a lookup table written per game, and twelve games write twelve
|
|
152
|
+
* slightly different ones — the same argument as the socket offsets living
|
|
153
|
+
* on the character (see `Socket3D`), from the other end.
|
|
154
|
+
*/
|
|
155
|
+
socket?: string;
|
|
129
156
|
/** Authoring correction applied at load: most models are not born 1 unit tall. */
|
|
130
157
|
importScale?: number;
|
|
131
158
|
/** 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.19.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",
|