@umicat/three-sdk 0.19.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 +7 -1
- package/dist/Attachments.d.ts +38 -0
- package/dist/Attachments.js +77 -0
- package/dist/SceneLoader3D.d.ts +4 -0
- package/dist/SceneLoader3D.js +31 -3
- package/dist/editor/EditorDesignPlayer3D.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scene3d.d.ts +23 -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
|
```
|
|
@@ -73,6 +74,11 @@ Each rule is a decision:
|
|
|
73
74
|
Guessing that every model calls its walk cycle `Walk` fails silently; an
|
|
74
75
|
independent review's cross-rig retarget returned zero matched bones and zero
|
|
75
76
|
tracks, which is the same class of failure, quieter.
|
|
77
|
+
- **What a character wears is authored on the entity**
|
|
78
|
+
(`attachments: [{ modelAssetId: 'sword' }]`). Because the character already
|
|
79
|
+
says where its sockets are and the prop already says which one it belongs in,
|
|
80
|
+
naming the prop is the whole declaration — a knight in armour is data, not
|
|
81
|
+
equip code written once per game.
|
|
76
82
|
- **A prop declares which socket it belongs in** (`socket: 'hand-right'` on the
|
|
77
83
|
sword, `sockets: { 'hand-right': {...} }` on the character). Both halves are
|
|
78
84
|
properties of the MODEL, not of any game — a cap goes on a head in every game
|
|
@@ -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;
|
|
@@ -30,6 +31,9 @@ export interface LoadedScene3D {
|
|
|
30
31
|
/** Model asset id → every clip that model shipped with. A game switching
|
|
31
32
|
* between idle and walk needs the clips, not just the one playing. */
|
|
32
33
|
clips: Map<string, THREE.AnimationClip[]>;
|
|
34
|
+
/** Entity id → its props, keyed by the attachment's `id` or, failing that,
|
|
35
|
+
* the socket it went into. How a game reaches the sword it authored. */
|
|
36
|
+
attachments: Map<string, WornMap>;
|
|
33
37
|
/** Entity id → rigid body, when physics was supplied. */
|
|
34
38
|
bodies: Map<string, any>;
|
|
35
39
|
world?: any;
|
package/dist/SceneLoader3D.js
CHANGED
|
@@ -3,6 +3,7 @@ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
|
3
3
|
import { clone as cloneRigged } from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
4
4
|
import { validateScene, IDENTITY_QUAT, } from './scene3d.js';
|
|
5
5
|
import { applySkin } from './Skin.js';
|
|
6
|
+
import { applyAttachments } from './Attachments.js';
|
|
6
7
|
function toVec(v, d = 0) {
|
|
7
8
|
return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
|
|
8
9
|
}
|
|
@@ -115,6 +116,20 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
115
116
|
// a model must not download it twice.
|
|
116
117
|
const models = new Map((manifest.models ?? []).map((m) => [m.id, m]));
|
|
117
118
|
const needed = new Set(scene3d.entities.map((e) => e.modelAssetId).filter(Boolean));
|
|
119
|
+
// Props are referenced only from `attachments`, so a set built from
|
|
120
|
+
// `modelAssetId` alone leaves every sword unfetched and every attachment
|
|
121
|
+
// silently empty-handed.
|
|
122
|
+
for (const e of scene3d.entities) {
|
|
123
|
+
for (const a of e.attachments ?? []) {
|
|
124
|
+
// Checked HERE rather than at attach time so the message can name the
|
|
125
|
+
// wearer: the generic prefetch error fires first and only knows the id.
|
|
126
|
+
if (!models.has(a.modelAssetId)) {
|
|
127
|
+
throw new Error(`entity '${e.id}' attaches model '${a.modelAssetId}', which the manifest does not define. ` +
|
|
128
|
+
`It has: ${[...models.keys()].join(', ') || '(none)'}`);
|
|
129
|
+
}
|
|
130
|
+
needed.add(a.modelAssetId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
118
133
|
const loaded = new Map();
|
|
119
134
|
if (needed.size) {
|
|
120
135
|
const loader = new GLTFLoader();
|
|
@@ -136,6 +151,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
136
151
|
// a caller that awaits `loadScene3D` gets a scene that is finished — not one
|
|
137
152
|
// whose cast pops from placeholder to costume a frame or two later.
|
|
138
153
|
const skinJobs = [];
|
|
154
|
+
const attachments = new Map();
|
|
155
|
+
const attachJobs = [];
|
|
139
156
|
for (const e of scene3d.entities) {
|
|
140
157
|
let obj;
|
|
141
158
|
if (e.modelAssetId) {
|
|
@@ -209,8 +226,19 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
209
226
|
}
|
|
210
227
|
}));
|
|
211
228
|
}
|
|
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); }));
|
|
236
|
+
}
|
|
212
237
|
}
|
|
213
238
|
else {
|
|
239
|
+
if (e.attachments?.length) {
|
|
240
|
+
throw new Error(`entity '${e.id}' has attachments but no model to hang them on`);
|
|
241
|
+
}
|
|
214
242
|
obj = makePrimitive(e);
|
|
215
243
|
}
|
|
216
244
|
obj.name = e.name ?? e.id;
|
|
@@ -222,8 +250,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
222
250
|
applyTransform(obj, e);
|
|
223
251
|
entities.set(e.id, obj);
|
|
224
252
|
}
|
|
225
|
-
if (skinJobs.length)
|
|
226
|
-
await Promise.all(skinJobs);
|
|
253
|
+
if (skinJobs.length || attachJobs.length)
|
|
254
|
+
await Promise.all([...skinJobs, ...attachJobs]);
|
|
227
255
|
// Parent after every entity exists, so declaration order doesn't matter.
|
|
228
256
|
for (const e of scene3d.entities) {
|
|
229
257
|
const obj = entities.get(e.id);
|
|
@@ -345,7 +373,7 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
|
|
|
345
373
|
? entities.get(scene3d.camera.target) : undefined;
|
|
346
374
|
const want = new THREE.Vector3();
|
|
347
375
|
return {
|
|
348
|
-
scene, camera, entities, mixers, mixerFor, clips, bodies, world,
|
|
376
|
+
scene, camera, entities, mixers, mixerFor, clips, attachments, bodies, world,
|
|
349
377
|
get cameraYaw() { return camYaw; },
|
|
350
378
|
get cameraPitch() { return camPitch; },
|
|
351
379
|
orbit(dYaw, dPitch) {
|
|
@@ -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/dist/scene3d.d.ts
CHANGED
|
@@ -59,6 +59,27 @@ export interface Collider3D {
|
|
|
59
59
|
/** Offset from the entity's own origin. */
|
|
60
60
|
offset?: Vec3;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Something this entity wears or carries.
|
|
64
|
+
*
|
|
65
|
+
* The pairing that makes this short is already in the manifest from both ends
|
|
66
|
+
* — the character says where its sockets are, the prop says which one it
|
|
67
|
+
* belongs in — so `{ modelAssetId: 'sword' }` is usually the whole thing. A
|
|
68
|
+
* knight in armour becomes authored data rather than equip code written per
|
|
69
|
+
* game, which is the same reason `skin` and `animation.play` are data.
|
|
70
|
+
*/
|
|
71
|
+
export interface Attachment3D {
|
|
72
|
+
/** Manifest id of the prop. */
|
|
73
|
+
modelAssetId: string;
|
|
74
|
+
/**
|
|
75
|
+
* Which of the wearer's sockets. Omit to use the prop's own declared
|
|
76
|
+
* `socket` — override only to put something where it does not normally go
|
|
77
|
+
* (a shield strapped to the back, a torch in the off hand).
|
|
78
|
+
*/
|
|
79
|
+
socket?: string;
|
|
80
|
+
/** Optional name, so game code can find this one again to swap or drop it. */
|
|
81
|
+
id?: string;
|
|
82
|
+
}
|
|
62
83
|
export interface Entity3D {
|
|
63
84
|
/** Stable, authored, unique within the scene. */
|
|
64
85
|
id: string;
|
|
@@ -83,6 +104,8 @@ export interface Entity3D {
|
|
|
83
104
|
/** Which skin to wear, by name from the model's `skins` — see `SkinMap`.
|
|
84
105
|
* Omit to keep whatever texture the glb itself was built with. */
|
|
85
106
|
skin?: string;
|
|
107
|
+
/** Props worn or carried on this entity's sockets — see `Attachment3D`. */
|
|
108
|
+
attachments?: Attachment3D[];
|
|
86
109
|
/** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
|
|
87
110
|
properties?: Record<string, unknown>;
|
|
88
111
|
visible?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
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",
|