@crowdedkingdoms/construct 0.3.7 → 0.3.8

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.
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
+
3
+ import {
4
+ applyPoseSet,
5
+ clearModPose,
6
+ positionInModGrid,
7
+ setModPoseGrid,
8
+ stepModPose,
9
+ } from './modPose';
10
+
11
+ describe('mod pose hold', () => {
12
+ beforeEach(() => {
13
+ clearModPose();
14
+ setModPoseGrid({
15
+ low: { x: -4n, y: 0n, z: -4n },
16
+ high: { x: -1n, y: 0n, z: -1n },
17
+ });
18
+ });
19
+
20
+ it('refuses a pose outside the grid and accepts one inside', () => {
21
+ expect(positionInModGrid(0, 0, 0)).toBe(false);
22
+ expect(applyPoseSet({ x: 0, y: 0, z: 0 })).toEqual({
23
+ ok: false,
24
+ error: 'outside the player grid',
25
+ });
26
+ expect(applyPoseSet({ x: -8, y: 1, z: -8, yaw: 0.2 })).toEqual({ ok: true });
27
+ });
28
+
29
+ it('integrates velocity and stays inside the grid box', () => {
30
+ applyPoseSet({ x: -63, y: 1, z: -8, vx: -10, vy: 0, vz: 0 });
31
+ const next = stepModPose(1);
32
+ expect(next?.x).toBeCloseTo(-63.6);
33
+ });
34
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Generic body hold for a client mod. The mod sends positions; the holodeck
3
+ * hides the pill while a hold is active and restores it when the mod stops.
4
+ */
5
+ import type { PlayerCodeGridBounds } from '@crowdedkingdoms/crowdyjs';
6
+
7
+ import { CHUNK_SIZE } from '../config';
8
+ import { worldToChunk } from '../realtime/space';
9
+
10
+ export interface ModPoseHold {
11
+ x: number;
12
+ y: number;
13
+ z: number;
14
+ yaw: number;
15
+ pitch: number;
16
+ roll: number;
17
+ vx: number;
18
+ vy: number;
19
+ vz: number;
20
+ }
21
+
22
+ let hold: ModPoseHold | null = null;
23
+ let grid: PlayerCodeGridBounds | null = null;
24
+ let walker: ModPoseHold = {
25
+ x: 0,
26
+ y: 0,
27
+ z: 0,
28
+ yaw: 0,
29
+ pitch: 0,
30
+ roll: 0,
31
+ vx: 0,
32
+ vy: 0,
33
+ vz: 0,
34
+ };
35
+
36
+ export function setModPoseGrid(next: PlayerCodeGridBounds | null): void {
37
+ grid = next;
38
+ }
39
+
40
+ export function clearModPose(): void {
41
+ hold = null;
42
+ }
43
+
44
+ export function currentModPose(): ModPoseHold | null {
45
+ return hold;
46
+ }
47
+
48
+ /** Where the walker is, so a mod can take the body from that spot. */
49
+ export function noteWalkerPose(pose: Pick<ModPoseHold, 'x' | 'y' | 'z' | 'yaw' | 'pitch'>): void {
50
+ if (hold) return;
51
+ walker = { ...walker, ...pose, roll: 0, vx: 0, vy: 0, vz: 0 };
52
+ }
53
+
54
+ export function poseSnapshot(): Record<string, unknown> {
55
+ const pose = hold ?? walker;
56
+ return { held: hold !== null, ...pose };
57
+ }
58
+
59
+ export function positionInModGrid(x: number, y: number, z: number): boolean {
60
+ if (!grid) return false;
61
+ const chunk = worldToChunk({ x, y, z });
62
+ return (
63
+ chunk.x >= Number(grid.low.x) &&
64
+ chunk.x <= Number(grid.high.x) &&
65
+ chunk.y >= Number(grid.low.y) &&
66
+ chunk.y <= Number(grid.high.y) &&
67
+ chunk.z >= Number(grid.low.z) &&
68
+ chunk.z <= Number(grid.high.z)
69
+ );
70
+ }
71
+
72
+ function num(value: unknown): number {
73
+ const n = typeof value === 'number' ? value : Number(value);
74
+ return Number.isFinite(n) ? n : 0;
75
+ }
76
+
77
+ export function applyPoseSet(args: Record<string, unknown>): { ok: boolean; error?: string } {
78
+ const x = num(args.x);
79
+ const y = num(args.y);
80
+ const z = num(args.z);
81
+ if (!positionInModGrid(x, y, z)) return { ok: false, error: 'outside the player grid' };
82
+ hold = {
83
+ x,
84
+ y,
85
+ z,
86
+ yaw: num(args.yaw),
87
+ pitch: num(args.pitch),
88
+ roll: num(args.roll),
89
+ vx: num(args.vx),
90
+ vy: num(args.vy),
91
+ vz: num(args.vz),
92
+ };
93
+ return { ok: true };
94
+ }
95
+
96
+ export function stepModPose(dt: number): ModPoseHold | null {
97
+ if (!hold || !grid) return hold;
98
+ hold.x += hold.vx * dt;
99
+ hold.y += hold.vy * dt;
100
+ hold.z += hold.vz * dt;
101
+ const minX = Number(grid.low.x) * CHUNK_SIZE + 0.4;
102
+ const maxX = (Number(grid.high.x) + 1) * CHUNK_SIZE - 0.4;
103
+ const minY = Number(grid.low.y) * CHUNK_SIZE + 0.05;
104
+ const maxY = (Number(grid.high.y) + 1) * CHUNK_SIZE - 0.4;
105
+ const minZ = Number(grid.low.z) * CHUNK_SIZE + 0.4;
106
+ const maxZ = (Number(grid.high.z) + 1) * CHUNK_SIZE - 0.4;
107
+ hold.x = Math.min(maxX, Math.max(minX, hold.x));
108
+ hold.y = Math.min(maxY, Math.max(minY, hold.y));
109
+ hold.z = Math.min(maxZ, Math.max(minZ, hold.z));
110
+ return hold;
111
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ModSceneStore } from './modScene';
4
+
5
+ const catalog = {
6
+ v: 1,
7
+ revision: 1,
8
+ nodes: [
9
+ { id: 'body', kind: 'box', x: 0, y: 0, z: 0, sx: 1, sy: 1, sz: 1, visible: false },
10
+ {
11
+ id: 'pelvis',
12
+ parent: 'body',
13
+ kind: 'box',
14
+ x: 0,
15
+ y: 1,
16
+ z: 0,
17
+ sx: 0.4,
18
+ sy: 0.2,
19
+ sz: 0.3,
20
+ color: 1,
21
+ },
22
+ ],
23
+ };
24
+
25
+ describe('ModSceneStore', () => {
26
+ it('keeps one template and clones it per instance', () => {
27
+ const store = new ModSceneStore();
28
+ expect(store.setCatalog('mod', catalog)).toEqual({ ok: true, revision: 1 });
29
+ expect(store.setCatalog('mod', catalog)).toEqual({ ok: true, revision: 1, ignored: true });
30
+ store.setInstances('mod', {
31
+ instances: [
32
+ { id: 'me', template: 'body', x: 10, y: 0, z: 3, sx: 2, sy: 2, sz: 2 },
33
+ { id: 'bob', template: 'body', bindActor: 'bob-uuid' },
34
+ ],
35
+ });
36
+ const nodes = store.expand();
37
+ expect(nodes).toHaveLength(2);
38
+ expect(nodes[0]).toMatchObject({
39
+ id: 'mod/me/pelvis',
40
+ x: 10,
41
+ y: 2,
42
+ z: 3,
43
+ sy: 0.4,
44
+ });
45
+ expect(nodes[0]?.bindActor).toBeUndefined();
46
+ expect(nodes[1]).toMatchObject({
47
+ id: 'mod/bob/pelvis',
48
+ x: 0,
49
+ y: 1,
50
+ z: 0,
51
+ sy: 0.2,
52
+ bindActor: 'bob-uuid',
53
+ });
54
+ expect(store.boundActors()).toEqual(new Set(['bob-uuid']));
55
+ });
56
+
57
+ it('drops the previous geometries when the revision changes', () => {
58
+ const store = new ModSceneStore();
59
+ store.setCatalog('mod', catalog);
60
+ store.setInstances('mod', { instances: [{ id: 'me', template: 'body' }] });
61
+ expect(store.expand()).toHaveLength(1);
62
+ store.setCatalog('mod', {
63
+ v: 1,
64
+ revision: 2,
65
+ nodes: [{ id: 'body', kind: 'box', visible: false, x: 0, y: 0, z: 0 }],
66
+ });
67
+ expect(store.expand()).toEqual([]);
68
+ });
69
+
70
+ it('omits an instance whose scale is folded away', () => {
71
+ const store = new ModSceneStore();
72
+ store.setCatalog('mod', catalog);
73
+ store.setInstances('mod', {
74
+ instances: [{ id: 'me', template: 'body', sx: 0, sy: 0, sz: 0 }],
75
+ });
76
+ expect(store.expand()).toEqual([]);
77
+ expect(store.boundActors().size).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * CLIENT scene_catalog / scene_instances. A module uploads a construct.scene.v1
3
+ * library once, then a short list of roots. The page clones each template into
4
+ * flat nodes for InstanceLayer. Primitive geometries stay shared there.
5
+ * The module never receives a THREE.Mesh.
6
+ */
7
+ import {
8
+ MAX_ID_CHARS,
9
+ MIN_SIZE,
10
+ MAX_SIZE,
11
+ clamp,
12
+ parseSceneCatalog,
13
+ quatMultiply,
14
+ rotateByQuat,
15
+ type SceneCatalog,
16
+ type SceneMesh,
17
+ type SceneNode,
18
+ } from './instanceSchema';
19
+ import type { ModOverlayObject } from './modOverlay';
20
+
21
+ /** One record per body. The catalog, not this list, holds the parts. */
22
+ export const MAX_SCENE_INSTANCES = 16;
23
+ /** Expanded parts across every instance. The catalog itself stays at 64 nodes. */
24
+ export const MAX_EXPANDED_NODES = 512;
25
+ const HIDE_SCALE = 0.08;
26
+
27
+ interface SceneInstance {
28
+ id: string;
29
+ template: string;
30
+ x: number;
31
+ y: number;
32
+ z: number;
33
+ qx: number;
34
+ qy: number;
35
+ qz: number;
36
+ qw: number;
37
+ sx: number;
38
+ sy: number;
39
+ sz: number;
40
+ bindActor?: string;
41
+ }
42
+
43
+ interface SourceScene {
44
+ catalog: SceneCatalog | null;
45
+ instances: SceneInstance[];
46
+ }
47
+
48
+ interface FlatPart {
49
+ node: SceneNode;
50
+ x: number;
51
+ y: number;
52
+ z: number;
53
+ qx: number;
54
+ qy: number;
55
+ qz: number;
56
+ qw: number;
57
+ sx: number;
58
+ sy: number;
59
+ sz: number;
60
+ }
61
+
62
+ function asRecord(value: unknown): Record<string, unknown> | null {
63
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
64
+ ? (value as Record<string, unknown>)
65
+ : null;
66
+ }
67
+
68
+ function payloadOf(payload: unknown): unknown {
69
+ const rec = asRecord(payload);
70
+ if (rec && asRecord(rec.payload)) return rec.payload;
71
+ return payload;
72
+ }
73
+
74
+ function finite(value: unknown, fallback: number): number {
75
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
76
+ if (typeof value === 'string' && value.trim() !== '') {
77
+ const n = Number(value);
78
+ if (Number.isFinite(n)) return n;
79
+ }
80
+ return fallback;
81
+ }
82
+
83
+ function textId(value: unknown, fallback: string, max = MAX_ID_CHARS): string {
84
+ if (typeof value === 'string' && value.trim()) return value.trim().slice(0, max);
85
+ return fallback.slice(0, max);
86
+ }
87
+
88
+ function parseInstance(row: unknown, index: number): SceneInstance | null {
89
+ const rec = asRecord(row);
90
+ if (!rec) return null;
91
+ const template = textId(rec.template ?? rec.templateId, '');
92
+ if (!template) return null;
93
+ const bindRaw = rec.bindActor ?? rec.bind_actor ?? rec.actor;
94
+ const bind =
95
+ typeof bindRaw === 'string' && bindRaw.trim() ? bindRaw.trim().slice(0, 64) : undefined;
96
+ return {
97
+ id: textId(rec.id, `inst-${index}`, 64),
98
+ template,
99
+ x: finite(rec.x, 0),
100
+ y: finite(rec.y, 0),
101
+ z: finite(rec.z, 0),
102
+ qx: finite(rec.qx, 0),
103
+ qy: finite(rec.qy, 0),
104
+ qz: finite(rec.qz, 0),
105
+ qw: finite(rec.qw, 1),
106
+ sx: finite(rec.sx, 1),
107
+ sy: finite(rec.sy, 1),
108
+ sz: finite(rec.sz, 1),
109
+ bindActor: bind,
110
+ };
111
+ }
112
+
113
+ /** Parts under a template root, in the root's local space. The root itself is not a part. */
114
+ function templateParts(nodes: readonly SceneNode[], templateId: string): FlatPart[] {
115
+ const children = new Map<string, SceneNode[]>();
116
+ for (const node of nodes) {
117
+ if (!node.parent) continue;
118
+ const list = children.get(node.parent) ?? [];
119
+ list.push(node);
120
+ children.set(node.parent, list);
121
+ }
122
+ const out: FlatPart[] = [];
123
+ const walk = (
124
+ parentId: string,
125
+ px: number,
126
+ py: number,
127
+ pz: number,
128
+ pqx: number,
129
+ pqy: number,
130
+ pqz: number,
131
+ pqw: number,
132
+ psx: number,
133
+ psy: number,
134
+ psz: number,
135
+ ) => {
136
+ for (const child of children.get(parentId) ?? []) {
137
+ const [rx, ry, rz] = rotateByQuat(
138
+ pqx,
139
+ pqy,
140
+ pqz,
141
+ pqw,
142
+ child.x * psx,
143
+ child.y * psy,
144
+ child.z * psz,
145
+ );
146
+ const [qx, qy, qz, qw] = quatMultiply(
147
+ pqx,
148
+ pqy,
149
+ pqz,
150
+ pqw,
151
+ child.qx,
152
+ child.qy,
153
+ child.qz,
154
+ child.qw,
155
+ );
156
+ const flat: FlatPart = {
157
+ node: child,
158
+ x: px + rx,
159
+ y: py + ry,
160
+ z: pz + rz,
161
+ qx,
162
+ qy,
163
+ qz,
164
+ qw,
165
+ sx: psx * child.sx,
166
+ sy: psy * child.sy,
167
+ sz: psz * child.sz,
168
+ };
169
+ out.push(flat);
170
+ walk(child.id, flat.x, flat.y, flat.z, qx, qy, qz, qw, flat.sx, flat.sy, flat.sz);
171
+ }
172
+ };
173
+ walk(templateId, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1);
174
+ if (out.length > 0) return out;
175
+ const root = nodes.find((node) => node.id === templateId && node.visible);
176
+ if (!root) return [];
177
+ return [
178
+ {
179
+ node: root,
180
+ x: root.x,
181
+ y: root.y,
182
+ z: root.z,
183
+ qx: root.qx,
184
+ qy: root.qy,
185
+ qz: root.qz,
186
+ qw: root.qw,
187
+ sx: root.sx,
188
+ sy: root.sy,
189
+ sz: root.sz,
190
+ },
191
+ ];
192
+ }
193
+
194
+ function bake(source: string, instance: SceneInstance, part: FlatPart): ModOverlayObject {
195
+ const lx = part.x * instance.sx;
196
+ const ly = part.y * instance.sy;
197
+ const lz = part.z * instance.sz;
198
+ const [qx, qy, qz, qw] = quatMultiply(
199
+ instance.qx,
200
+ instance.qy,
201
+ instance.qz,
202
+ instance.qw,
203
+ part.qx,
204
+ part.qy,
205
+ part.qz,
206
+ part.qw,
207
+ );
208
+ let x = lx;
209
+ let y = ly;
210
+ let z = lz;
211
+ if (!instance.bindActor) {
212
+ const [rx, ry, rz] = rotateByQuat(
213
+ instance.qx,
214
+ instance.qy,
215
+ instance.qz,
216
+ instance.qw,
217
+ lx,
218
+ ly,
219
+ lz,
220
+ );
221
+ x = instance.x + rx;
222
+ y = instance.y + ry;
223
+ z = instance.z + rz;
224
+ }
225
+ const node: ModOverlayObject = {
226
+ id: `${source}/${instance.id}/${part.node.id}`,
227
+ mesh: part.node.mesh,
228
+ kind: part.node.kind,
229
+ shape: part.node.kind,
230
+ x,
231
+ y,
232
+ z,
233
+ qx,
234
+ qy,
235
+ qz,
236
+ qw,
237
+ sx: clamp(part.sx * instance.sx, MIN_SIZE, MAX_SIZE),
238
+ sy: clamp(part.sy * instance.sy, MIN_SIZE, MAX_SIZE),
239
+ sz: clamp(part.sz * instance.sz, MIN_SIZE, MAX_SIZE),
240
+ color: part.node.color,
241
+ visible: true,
242
+ bindActor: instance.bindActor,
243
+ unlit: part.node.unlit,
244
+ shading: part.node.shading,
245
+ flat: part.node.flat,
246
+ opacity: part.node.opacity,
247
+ blend: part.node.blend,
248
+ depthWrite: part.node.depthWrite,
249
+ emissive: part.node.emissive,
250
+ side: part.node.side,
251
+ fog: part.node.fog,
252
+ };
253
+ return node;
254
+ }
255
+
256
+ export class ModSceneStore {
257
+ private readonly bySource = new Map<string, SourceScene>();
258
+ private revisionValue = 0;
259
+
260
+ get revision(): number {
261
+ return this.revisionValue;
262
+ }
263
+
264
+ setCatalog(
265
+ source: string,
266
+ payload: unknown,
267
+ ): { ok: boolean; revision?: number; ignored?: boolean } {
268
+ const catalog = parseSceneCatalog(payloadOf(payload));
269
+ if (!catalog) return { ok: false };
270
+ const prev = this.bySource.get(source);
271
+ if (prev?.catalog && prev.catalog.revision === catalog.revision) {
272
+ return { ok: true, revision: catalog.revision, ignored: true };
273
+ }
274
+ this.bySource.set(source, { catalog, instances: prev?.instances ?? [] });
275
+ this.revisionValue += 1;
276
+ return { ok: true, revision: catalog.revision };
277
+ }
278
+
279
+ setInstances(source: string, payload: unknown): { ok: boolean; count: number } {
280
+ const body = asRecord(payloadOf(payload));
281
+ const list = body && Array.isArray(body.instances) ? body.instances : [];
282
+ const instances: SceneInstance[] = [];
283
+ for (const row of list) {
284
+ if (instances.length >= MAX_SCENE_INSTANCES) break;
285
+ const parsed = parseInstance(row, instances.length);
286
+ if (parsed) instances.push(parsed);
287
+ }
288
+ const prev = this.bySource.get(source);
289
+ this.bySource.set(source, { catalog: prev?.catalog ?? null, instances });
290
+ this.revisionValue += 1;
291
+ return { ok: true, count: instances.length };
292
+ }
293
+
294
+ /** Flat parts for the holodeck. A scale under 0.08 drops that whole instance. */
295
+ expand(): ModOverlayObject[] {
296
+ const out: ModOverlayObject[] = [];
297
+ for (const [source, row] of this.bySource) {
298
+ if (!row.catalog) continue;
299
+ for (const instance of row.instances) {
300
+ if (out.length >= MAX_EXPANDED_NODES) return out;
301
+ const scale = Math.max(instance.sx, instance.sy, instance.sz);
302
+ if (scale < HIDE_SCALE) continue;
303
+ for (const part of templateParts(row.catalog.nodes, instance.template)) {
304
+ if (!part.node.visible) continue;
305
+ if (out.length >= MAX_EXPANDED_NODES) return out;
306
+ out.push(bake(source, instance, part));
307
+ }
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+
313
+ meshes(): SceneMesh[] {
314
+ const out: SceneMesh[] = [];
315
+ const seen = new Set<string>();
316
+ for (const row of this.bySource.values()) {
317
+ for (const mesh of row.catalog?.meshes ?? []) {
318
+ if (seen.has(mesh.id)) continue;
319
+ seen.add(mesh.id);
320
+ out.push(mesh);
321
+ }
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** Actors whose capsule the page should hide because a template is bound to them. */
327
+ boundActors(): Set<string> {
328
+ const ids = new Set<string>();
329
+ for (const row of this.bySource.values()) {
330
+ for (const instance of row.instances) {
331
+ if (!instance.bindActor) continue;
332
+ if (Math.max(instance.sx, instance.sy, instance.sz) < HIDE_SCALE) continue;
333
+ ids.add(instance.bindActor);
334
+ }
335
+ }
336
+ return ids;
337
+ }
338
+
339
+ remove(source: string): void {
340
+ if (!this.bySource.delete(source)) return;
341
+ this.revisionValue += 1;
342
+ }
343
+
344
+ clear(): void {
345
+ if (this.bySource.size === 0) return;
346
+ this.bySource.clear();
347
+ this.revisionValue += 1;
348
+ }
349
+ }