@jgengine/core 0.4.0 → 0.6.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/game/defineGame.d.ts +3 -0
  3. package/dist/game/feed.d.ts +2 -0
  4. package/dist/game/feed.js +10 -8
  5. package/dist/game/playableGame.d.ts +10 -1
  6. package/dist/game/progression.d.ts +61 -0
  7. package/dist/game/progression.js +96 -0
  8. package/dist/game/quest.d.ts +39 -0
  9. package/dist/game/quest.js +195 -0
  10. package/dist/game/trade.d.ts +23 -0
  11. package/dist/game/trade.js +50 -8
  12. package/dist/game/unlocks.d.ts +9 -0
  13. package/dist/game/unlocks.js +19 -1
  14. package/dist/inventory/slotModel.d.ts +19 -0
  15. package/dist/inventory/slotModel.js +76 -0
  16. package/dist/meta/changelog.d.ts +8 -0
  17. package/dist/meta/changelog.js +43 -0
  18. package/dist/physics/physicsWorld.d.ts +136 -0
  19. package/dist/physics/physicsWorld.js +489 -0
  20. package/dist/runtime/gameContext.d.ts +2 -0
  21. package/dist/runtime/gameContext.js +3 -0
  22. package/dist/scene/assetCatalog.d.ts +13 -0
  23. package/dist/scene/entityStore.d.ts +6 -0
  24. package/dist/scene/entityStore.js +15 -1
  25. package/dist/time/simClock.d.ts +62 -0
  26. package/dist/time/simClock.js +132 -0
  27. package/dist/world/buildingIndex.d.ts +16 -0
  28. package/dist/world/buildingIndex.js +63 -0
  29. package/dist/world/buildings.d.ts +98 -0
  30. package/dist/world/buildings.js +320 -0
  31. package/dist/world/features.d.ts +105 -1
  32. package/dist/world/features.js +95 -0
  33. package/dist/world/geometry.d.ts +23 -0
  34. package/dist/world/geometry.js +75 -0
  35. package/dist/world/interiors.d.ts +35 -0
  36. package/dist/world/interiors.js +66 -0
  37. package/dist/world/placement.d.ts +30 -0
  38. package/dist/world/placement.js +20 -0
  39. package/dist/world/regions.d.ts +66 -0
  40. package/dist/world/regions.js +158 -0
  41. package/dist/world/scatter.d.ts +24 -0
  42. package/dist/world/scatter.js +126 -0
  43. package/dist/world/scatterItems.d.ts +38 -0
  44. package/dist/world/scatterItems.js +85 -0
  45. package/dist/world/terrain.d.ts +54 -0
  46. package/dist/world/terrain.js +181 -0
  47. package/dist/world/water.d.ts +27 -0
  48. package/dist/world/water.js +125 -0
  49. package/dist/world/wind.d.ts +17 -0
  50. package/dist/world/wind.js +40 -0
  51. package/package.json +5 -2
@@ -0,0 +1,489 @@
1
+ const FLAG_SLEEPING = 1;
2
+ const FLAG_STATIC = 2;
3
+ /** Grid cell containing a point, clamped into the grid. Pure — exported for tests. */
4
+ export function cellCoord(value, min, cellSize, cells) {
5
+ const raw = Math.floor((value - min) / cellSize);
6
+ return raw < 0 ? 0 : raw >= cells ? cells - 1 : raw;
7
+ }
8
+ /** Linear cell index from clamped 3D coords. Pure — exported for tests. */
9
+ export function cellIndex(cx, cy, cz, nx, ny) {
10
+ return (cz * ny + cy) * nx + cx;
11
+ }
12
+ export class PhysicsWorld {
13
+ capacity;
14
+ bounds;
15
+ gravity;
16
+ cellSize;
17
+ fixedDt;
18
+ maxSubsteps;
19
+ restitution;
20
+ restitutionThreshold;
21
+ friction;
22
+ linearDamping;
23
+ solverIterations;
24
+ correctionFactor;
25
+ slop;
26
+ sleepMove2;
27
+ sleepSteps;
28
+ wakeThreshold;
29
+ posX;
30
+ posY;
31
+ posZ;
32
+ velX;
33
+ velY;
34
+ velZ;
35
+ halfX;
36
+ halfY;
37
+ halfZ;
38
+ invMass;
39
+ flags;
40
+ /** 1 for a body that took part in a contact this frame; reset each `step()`. Render tint reads this. */
41
+ contact;
42
+ sleepTimer;
43
+ prevX;
44
+ prevY;
45
+ prevZ;
46
+ nx;
47
+ ny;
48
+ nz;
49
+ numCells;
50
+ cellStart;
51
+ cursor;
52
+ sorted;
53
+ bodyCell;
54
+ // Awake-body index: the hot loops (integrate, solver outer, bounds, sleep) walk only these,
55
+ // so per-substep work outside the grid rebuild scales with the awake count, not the total.
56
+ awakeList;
57
+ awakeSlot;
58
+ awakeCount = 0;
59
+ bodyCount = 0;
60
+ accumulator = 0;
61
+ stats = {
62
+ count: 0,
63
+ awake: 0,
64
+ sleeping: 0,
65
+ contacts: 0,
66
+ pairs: 0,
67
+ substeps: 0,
68
+ stepMs: 0,
69
+ };
70
+ constructor(config) {
71
+ this.capacity = config.capacity;
72
+ this.bounds = { min: config.bounds.min, max: config.bounds.max };
73
+ this.gravity = config.gravity ?? -20;
74
+ this.cellSize = config.cellSize ?? 1;
75
+ this.fixedDt = config.fixedDt ?? 1 / 60;
76
+ this.maxSubsteps = config.maxSubsteps ?? 4;
77
+ this.restitution = config.restitution ?? 0.2;
78
+ this.restitutionThreshold = config.restitutionThreshold ?? 1;
79
+ this.friction = config.friction ?? 0.4;
80
+ this.linearDamping = config.linearDamping ?? 0.2;
81
+ this.solverIterations = Math.max(1, config.solverIterations ?? 4);
82
+ this.correctionFactor = config.correctionFactor ?? 0.2;
83
+ this.slop = config.slop ?? 0.005;
84
+ const sleepV = config.sleepLinearVelocity ?? 0.08;
85
+ const sleepMoveEps = sleepV * this.fixedDt;
86
+ this.sleepMove2 = sleepMoveEps * sleepMoveEps;
87
+ this.sleepSteps = config.sleepThresholdSteps ?? 30;
88
+ this.wakeThreshold = config.wakeThreshold ?? 0.5;
89
+ const cap = config.capacity;
90
+ this.posX = new Float32Array(cap);
91
+ this.posY = new Float32Array(cap);
92
+ this.posZ = new Float32Array(cap);
93
+ this.velX = new Float32Array(cap);
94
+ this.velY = new Float32Array(cap);
95
+ this.velZ = new Float32Array(cap);
96
+ this.halfX = new Float32Array(cap);
97
+ this.halfY = new Float32Array(cap);
98
+ this.halfZ = new Float32Array(cap);
99
+ this.invMass = new Float32Array(cap);
100
+ this.flags = new Uint8Array(cap);
101
+ this.contact = new Uint8Array(cap);
102
+ this.sleepTimer = new Uint16Array(cap);
103
+ this.prevX = new Float32Array(cap);
104
+ this.prevY = new Float32Array(cap);
105
+ this.prevZ = new Float32Array(cap);
106
+ const span = (i) => Math.max(config.bounds.max[i] - config.bounds.min[i], this.cellSize);
107
+ this.nx = Math.max(1, Math.ceil(span(0) / this.cellSize));
108
+ this.ny = Math.max(1, Math.ceil(span(1) / this.cellSize));
109
+ this.nz = Math.max(1, Math.ceil(span(2) / this.cellSize));
110
+ this.numCells = this.nx * this.ny * this.nz;
111
+ this.cellStart = new Int32Array(this.numCells + 1);
112
+ this.cursor = new Int32Array(this.numCells);
113
+ this.sorted = new Int32Array(cap);
114
+ this.bodyCell = new Int32Array(cap);
115
+ this.awakeList = new Int32Array(cap);
116
+ this.awakeSlot = new Int32Array(cap).fill(-1);
117
+ }
118
+ addAwake(i) {
119
+ if (this.awakeSlot[i] >= 0)
120
+ return;
121
+ this.awakeSlot[i] = this.awakeCount;
122
+ this.awakeList[this.awakeCount] = i;
123
+ this.awakeCount += 1;
124
+ }
125
+ removeAwake(i) {
126
+ const slot = this.awakeSlot[i];
127
+ if (slot < 0)
128
+ return;
129
+ this.awakeCount -= 1;
130
+ const last = this.awakeList[this.awakeCount];
131
+ this.awakeList[slot] = last;
132
+ this.awakeSlot[last] = slot;
133
+ this.awakeSlot[i] = -1;
134
+ }
135
+ get count() {
136
+ return this.bodyCount;
137
+ }
138
+ get cells() {
139
+ return { nx: this.nx, ny: this.ny, nz: this.nz, total: this.numCells };
140
+ }
141
+ addBody(options) {
142
+ if (this.bodyCount >= this.capacity)
143
+ throw new Error("PhysicsWorld: capacity exceeded");
144
+ const i = this.bodyCount++;
145
+ this.posX[i] = options.position[0];
146
+ this.posY[i] = options.position[1];
147
+ this.posZ[i] = options.position[2];
148
+ const v = options.velocity;
149
+ this.velX[i] = v?.[0] ?? 0;
150
+ this.velY[i] = v?.[1] ?? 0;
151
+ this.velZ[i] = v?.[2] ?? 0;
152
+ this.halfX[i] = options.halfExtents[0];
153
+ this.halfY[i] = options.halfExtents[1];
154
+ this.halfZ[i] = options.halfExtents[2];
155
+ const isStatic = options.static === true;
156
+ const mass = options.mass ?? 1;
157
+ this.invMass[i] = isStatic || mass <= 0 ? 0 : 1 / mass;
158
+ let f = 0;
159
+ if (isStatic)
160
+ f |= FLAG_STATIC | FLAG_SLEEPING;
161
+ else if (options.asleep === true)
162
+ f |= FLAG_SLEEPING;
163
+ this.flags[i] = f;
164
+ this.sleepTimer[i] = 0;
165
+ this.awakeSlot[i] = -1;
166
+ this.bodyCell[i] = this.cellOf(i);
167
+ if ((f & FLAG_SLEEPING) === 0)
168
+ this.addAwake(i);
169
+ return i;
170
+ }
171
+ isSleeping(i) {
172
+ return (this.flags[i] & FLAG_SLEEPING) !== 0;
173
+ }
174
+ wake(i) {
175
+ if ((this.flags[i] & FLAG_STATIC) !== 0)
176
+ return;
177
+ this.flags[i] &= ~FLAG_SLEEPING;
178
+ this.sleepTimer[i] = 0;
179
+ this.prevX[i] = this.posX[i];
180
+ this.prevY[i] = this.posY[i];
181
+ this.prevZ[i] = this.posZ[i];
182
+ this.addAwake(i);
183
+ }
184
+ wakeAll() {
185
+ for (let i = 0; i < this.bodyCount; i += 1) {
186
+ if ((this.flags[i] & FLAG_STATIC) === 0) {
187
+ this.flags[i] &= ~FLAG_SLEEPING;
188
+ this.sleepTimer[i] = 0;
189
+ this.addAwake(i);
190
+ }
191
+ }
192
+ }
193
+ clear() {
194
+ this.bodyCount = 0;
195
+ this.accumulator = 0;
196
+ this.awakeCount = 0;
197
+ }
198
+ /** Advance by a real frame delta; runs whole fixed substeps and carries the remainder. */
199
+ step(frameDt) {
200
+ const start = performanceNow();
201
+ this.accumulator += frameDt;
202
+ const cap = this.maxSubsteps * this.fixedDt;
203
+ if (this.accumulator > cap)
204
+ this.accumulator = cap;
205
+ let substeps = 0;
206
+ this.stats.contacts = 0;
207
+ this.stats.pairs = 0;
208
+ this.contact.fill(0, 0, this.bodyCount);
209
+ while (this.accumulator >= this.fixedDt) {
210
+ this.substep(this.fixedDt);
211
+ this.accumulator -= this.fixedDt;
212
+ substeps += 1;
213
+ }
214
+ this.stats.count = this.bodyCount;
215
+ this.stats.awake = this.awakeCount;
216
+ this.stats.sleeping = this.bodyCount - this.awakeCount;
217
+ this.stats.substeps = substeps;
218
+ this.stats.stepMs = performanceNow() - start;
219
+ return this.stats;
220
+ }
221
+ getStats() {
222
+ return this.stats;
223
+ }
224
+ substep(dt) {
225
+ const gdt = this.gravity * dt;
226
+ const damp = this.linearDamping > 0 ? Math.max(0, 1 - this.linearDamping * dt) : 1;
227
+ const awakeBefore = this.awakeCount;
228
+ for (let a = 0; a < awakeBefore; a += 1) {
229
+ const i = this.awakeList[a];
230
+ this.prevX[i] = this.posX[i];
231
+ this.prevY[i] = this.posY[i];
232
+ this.prevZ[i] = this.posZ[i];
233
+ this.velY[i] += gdt;
234
+ if (damp !== 1) {
235
+ this.velX[i] *= damp;
236
+ this.velY[i] *= damp;
237
+ this.velZ[i] *= damp;
238
+ }
239
+ this.posX[i] += this.velX[i] * dt;
240
+ this.posY[i] += this.velY[i] * dt;
241
+ this.posZ[i] += this.velZ[i] * dt;
242
+ this.constrainBounds(i);
243
+ }
244
+ this.buildGrid();
245
+ const iterations = this.solverIterations;
246
+ for (let it = 0; it < iterations; it += 1)
247
+ this.solve(it === 0, it === iterations - 1);
248
+ for (let a = 0; a < this.awakeCount; a += 1)
249
+ this.constrainBounds(this.awakeList[a]);
250
+ this.updateSleep();
251
+ }
252
+ constrainBounds(i) {
253
+ const min = this.bounds.min;
254
+ const max = this.bounds.max;
255
+ const e = this.restitution;
256
+ const rt = this.restitutionThreshold;
257
+ const loX = min[0] + this.halfX[i];
258
+ const hiX = max[0] - this.halfX[i];
259
+ const loY = min[1] + this.halfY[i];
260
+ const hiY = max[1] - this.halfY[i];
261
+ const loZ = min[2] + this.halfZ[i];
262
+ const hiZ = max[2] - this.halfZ[i];
263
+ if (this.posX[i] < loX) {
264
+ this.posX[i] = loX;
265
+ if (this.velX[i] < 0)
266
+ this.velX[i] = -this.velX[i] < rt ? 0 : this.velX[i] * -e;
267
+ }
268
+ else if (this.posX[i] > hiX) {
269
+ this.posX[i] = hiX;
270
+ if (this.velX[i] > 0)
271
+ this.velX[i] = this.velX[i] < rt ? 0 : -this.velX[i] * e;
272
+ }
273
+ if (this.posZ[i] < loZ) {
274
+ this.posZ[i] = loZ;
275
+ if (this.velZ[i] < 0)
276
+ this.velZ[i] = -this.velZ[i] < rt ? 0 : this.velZ[i] * -e;
277
+ }
278
+ else if (this.posZ[i] > hiZ) {
279
+ this.posZ[i] = hiZ;
280
+ if (this.velZ[i] > 0)
281
+ this.velZ[i] = this.velZ[i] < rt ? 0 : -this.velZ[i] * e;
282
+ }
283
+ if (this.posY[i] < loY) {
284
+ this.posY[i] = loY;
285
+ if (this.velY[i] < 0)
286
+ this.velY[i] = -this.velY[i] < rt ? 0 : this.velY[i] * -e;
287
+ const tf = 1 - this.friction;
288
+ this.velX[i] *= tf;
289
+ this.velZ[i] *= tf;
290
+ }
291
+ else if (this.posY[i] > hiY) {
292
+ this.posY[i] = hiY;
293
+ if (this.velY[i] > 0)
294
+ this.velY[i] = this.velY[i] < rt ? 0 : -this.velY[i] * e;
295
+ }
296
+ }
297
+ buildGrid() {
298
+ const n = this.bodyCount;
299
+ const start = this.cellStart;
300
+ start.fill(0);
301
+ // Sleeping bodies never move, so their cell index is cached from addBody / their last awake
302
+ // substep — only awake bodies pay the cell recomputation each substep.
303
+ const awakeCount = this.awakeCount;
304
+ for (let a = 0; a < awakeCount; a += 1) {
305
+ const i = this.awakeList[a];
306
+ this.bodyCell[i] = this.cellOf(i);
307
+ }
308
+ for (let i = 0; i < n; i += 1)
309
+ start[this.bodyCell[i] + 1] += 1;
310
+ for (let c = 0; c < this.numCells; c += 1) {
311
+ start[c + 1] += start[c];
312
+ this.cursor[c] = start[c];
313
+ }
314
+ for (let i = 0; i < n; i += 1) {
315
+ const c = this.bodyCell[i];
316
+ this.sorted[this.cursor[c]++] = i;
317
+ }
318
+ }
319
+ cellOf(i) {
320
+ const cx = cellCoord(this.posX[i], this.bounds.min[0], this.cellSize, this.nx);
321
+ const cy = cellCoord(this.posY[i], this.bounds.min[1], this.cellSize, this.ny);
322
+ const cz = cellCoord(this.posZ[i], this.bounds.min[2], this.cellSize, this.nz);
323
+ return cellIndex(cx, cy, cz, this.nx, this.ny);
324
+ }
325
+ solve(restitutionPass, countPass) {
326
+ const nx = this.nx;
327
+ const ny = this.ny;
328
+ const nz = this.nz;
329
+ const nxny = nx * ny;
330
+ // A contact may wake a sleeping body mid-pass, appending it to the awake list; re-read the
331
+ // count each iteration so it is solved this substep too.
332
+ for (let a = 0; a < this.awakeCount; a += 1) {
333
+ const i = this.awakeList[a];
334
+ const c = this.bodyCell[i];
335
+ const cz = (c / nxny) | 0;
336
+ const cy = ((c - cz * nxny) / nx) | 0;
337
+ const cx = c - cz * nxny - cy * nx;
338
+ const z0 = cz > 0 ? cz - 1 : cz;
339
+ const z1 = cz < nz - 1 ? cz + 1 : cz;
340
+ const y0 = cy > 0 ? cy - 1 : cy;
341
+ const y1 = cy < ny - 1 ? cy + 1 : cy;
342
+ const x0 = cx > 0 ? cx - 1 : cx;
343
+ const x1 = cx < nx - 1 ? cx + 1 : cx;
344
+ for (let z = z0; z <= z1; z += 1) {
345
+ for (let y = y0; y <= y1; y += 1) {
346
+ const rowBase = (z * ny + y) * nx;
347
+ for (let x = x0; x <= x1; x += 1) {
348
+ const cell = rowBase + x;
349
+ const end = this.cellStart[cell + 1];
350
+ for (let s = this.cellStart[cell]; s < end; s += 1) {
351
+ const j = this.sorted[s];
352
+ if (j === i)
353
+ continue;
354
+ const jSleeping = (this.flags[j] & FLAG_SLEEPING) !== 0;
355
+ if (!jSleeping && j < i)
356
+ continue;
357
+ this.resolve(i, j, restitutionPass, countPass);
358
+ }
359
+ }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ resolve(i, j, restitutionPass, countPass) {
365
+ const dx = this.posX[j] - this.posX[i];
366
+ const px = this.halfX[i] + this.halfX[j] - Math.abs(dx);
367
+ if (px <= 0)
368
+ return;
369
+ const dy = this.posY[j] - this.posY[i];
370
+ const py = this.halfY[i] + this.halfY[j] - Math.abs(dy);
371
+ if (py <= 0)
372
+ return;
373
+ const dz = this.posZ[j] - this.posZ[i];
374
+ const pz = this.halfZ[i] + this.halfZ[j] - Math.abs(dz);
375
+ if (pz <= 0)
376
+ return;
377
+ if (countPass) {
378
+ this.stats.pairs += 1;
379
+ this.stats.contacts += 1;
380
+ this.contact[i] = 1;
381
+ this.contact[j] = 1;
382
+ }
383
+ let nX = 0;
384
+ let nY = 0;
385
+ let nZ = 0;
386
+ let pen;
387
+ if (px <= py && px <= pz) {
388
+ nX = dx < 0 ? -1 : 1;
389
+ pen = px;
390
+ }
391
+ else if (py <= pz) {
392
+ nY = dy < 0 ? -1 : 1;
393
+ pen = py;
394
+ }
395
+ else {
396
+ nZ = dz < 0 ? -1 : 1;
397
+ pen = pz;
398
+ }
399
+ const rvx = this.velX[j] - this.velX[i];
400
+ const rvy = this.velY[j] - this.velY[i];
401
+ const rvz = this.velZ[j] - this.velZ[i];
402
+ const rvn = rvx * nX + rvy * nY + rvz * nZ;
403
+ const imI = this.invMass[i];
404
+ let imJ = this.invMass[j];
405
+ // A sleeping neighbor only wakes on a genuine impact; a resting contact treats it as a
406
+ // static collider (imJ = 0) so a settled body can rest on the bed without stirring it awake.
407
+ if ((this.flags[j] & FLAG_SLEEPING) !== 0) {
408
+ if (-rvn > this.wakeThreshold)
409
+ this.wake(j);
410
+ else
411
+ imJ = 0;
412
+ }
413
+ const imSum = imI + imJ;
414
+ if (imSum === 0)
415
+ return;
416
+ if (rvn < 0) {
417
+ const e = restitutionPass && -rvn > this.restitutionThreshold ? this.restitution : 0;
418
+ const jn = (-(1 + e) * rvn) / imSum;
419
+ this.velX[i] -= jn * imI * nX;
420
+ this.velY[i] -= jn * imI * nY;
421
+ this.velZ[i] -= jn * imI * nZ;
422
+ this.velX[j] += jn * imJ * nX;
423
+ this.velY[j] += jn * imJ * nY;
424
+ this.velZ[j] += jn * imJ * nZ;
425
+ let tx = rvx - rvn * nX;
426
+ let ty = rvy - rvn * nY;
427
+ let tz = rvz - rvn * nZ;
428
+ const tlen = Math.sqrt(tx * tx + ty * ty + tz * tz);
429
+ if (tlen > 1e-6) {
430
+ tx /= tlen;
431
+ ty /= tlen;
432
+ tz /= tlen;
433
+ let jt = -(rvx * tx + rvy * ty + rvz * tz) / imSum;
434
+ const maxF = this.friction * jn;
435
+ if (jt > maxF)
436
+ jt = maxF;
437
+ else if (jt < -maxF)
438
+ jt = -maxF;
439
+ this.velX[i] -= jt * imI * tx;
440
+ this.velY[i] -= jt * imI * ty;
441
+ this.velZ[i] -= jt * imI * tz;
442
+ this.velX[j] += jt * imJ * tx;
443
+ this.velY[j] += jt * imJ * ty;
444
+ this.velZ[j] += jt * imJ * tz;
445
+ }
446
+ }
447
+ const corr = (Math.max(pen - this.slop, 0) / imSum) * this.correctionFactor;
448
+ this.posX[i] -= corr * imI * nX;
449
+ this.posY[i] -= corr * imI * nY;
450
+ this.posZ[i] -= corr * imI * nZ;
451
+ this.posX[j] += corr * imJ * nX;
452
+ this.posY[j] += corr * imJ * nY;
453
+ this.posZ[j] += corr * imJ * nZ;
454
+ }
455
+ updateSleep() {
456
+ // A body sleeps when it has actually stopped moving — measured by displacement over
457
+ // the substep, not instantaneous velocity, which can stay "stuck" nonzero on a body
458
+ // pinned between contacts even while its position is at rest.
459
+ const moveEps2 = this.sleepMove2;
460
+ let a = 0;
461
+ while (a < this.awakeCount) {
462
+ const i = this.awakeList[a];
463
+ const dx = this.posX[i] - this.prevX[i];
464
+ const dy = this.posY[i] - this.prevY[i];
465
+ const dz = this.posZ[i] - this.prevZ[i];
466
+ if (dx * dx + dy * dy + dz * dz <= moveEps2) {
467
+ const t = this.sleepTimer[i] + 1;
468
+ if (t >= this.sleepSteps) {
469
+ this.flags[i] |= FLAG_SLEEPING;
470
+ this.velX[i] = 0;
471
+ this.velY[i] = 0;
472
+ this.velZ[i] = 0;
473
+ this.sleepTimer[i] = 0;
474
+ this.removeAwake(i);
475
+ continue;
476
+ }
477
+ this.sleepTimer[i] = t;
478
+ }
479
+ else {
480
+ this.sleepTimer[i] = 0;
481
+ }
482
+ a += 1;
483
+ }
484
+ }
485
+ }
486
+ function performanceNow() {
487
+ const perf = globalThis.performance;
488
+ return perf !== undefined ? perf.now() : 0;
489
+ }
@@ -24,6 +24,7 @@ import { type ObjectStore } from "../scene/objectStore.js";
24
24
  import { type SpatialApi } from "../scene/spatial.js";
25
25
  import { type CycleTargetOptions } from "../scene/targeting.js";
26
26
  import { type Stats } from "../stats/statModifiers.js";
27
+ import { type SimClock } from "../time/simClock.js";
27
28
  export interface GameContextItemEntry {
28
29
  use?: string;
29
30
  weapon?: Record<string, unknown>;
@@ -153,6 +154,7 @@ export interface GameContext {
153
154
  use: GameContextItemUse;
154
155
  weapon: WeaponStats;
155
156
  };
157
+ time: SimClock;
156
158
  subscribe(listener: () => void): () => void;
157
159
  version(): number;
158
160
  }
@@ -22,10 +22,12 @@ import { createSpatialApi } from "../scene/spatial.js";
22
22
  import { createTargeting } from "../scene/targeting.js";
23
23
  import { createStats } from "../stats/statModifiers.js";
24
24
  import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
25
+ import { createSimClock } from "../time/simClock.js";
25
26
  export function createGameContext(options) {
26
27
  const { definition, content, player } = options;
27
28
  const now = options.now ?? Date.now;
28
29
  const signal = createChangeSignal();
30
+ const time = createSimClock({ config: definition.time, onChange: signal.notify });
29
31
  const entities = definition.scene;
30
32
  const objects = createObjectStore();
31
33
  entities.subscribe(signal.notify);
@@ -429,6 +431,7 @@ export function createGameContext(options) {
429
431
  },
430
432
  weapon,
431
433
  },
434
+ time,
432
435
  subscribe: signal.subscribe,
433
436
  version: signal.version,
434
437
  };
@@ -1,5 +1,18 @@
1
+ export interface ModelDims {
2
+ footprint: {
3
+ w: number;
4
+ d: number;
5
+ };
6
+ center: {
7
+ x: number;
8
+ z: number;
9
+ };
10
+ minY: number;
11
+ }
1
12
  export interface ModelAssetRef {
2
13
  url: string;
14
+ /** Measured at asset reindex: horizontal footprint, footprint center, and lowest Y in model space (pre-scale). Lets the shell auto-center and ground-snap corner-pivot kit models. */
15
+ dims?: ModelDims;
3
16
  }
4
17
  export interface AssetCatalog<TMeta extends ModelAssetRef = ModelAssetRef> {
5
18
  register(id: string, asset: TMeta): void;
@@ -16,11 +16,15 @@ export interface SceneEntity<TMeta = undefined> {
16
16
  rotationY: number;
17
17
  rotationX: number;
18
18
  rotationZ: number;
19
+ /** World units per second, derived from consecutive setPose calls that carry `dt`. Zero until the entity moves under a dt. */
20
+ velocity: EntityPosition;
19
21
  role: EntityRole;
20
22
  movement: EntityMovement;
21
23
  behaviors: readonly BehaviorDescriptor[];
22
24
  meta: TMeta;
23
25
  }
26
+ /** Ground speed (horizontal magnitude of velocity) in world units per second. Scale to km/h or mph in game code. */
27
+ export declare function groundSpeed(entity: SceneEntity<unknown>): number;
24
28
  export interface SpawnOptions<TMeta = undefined> {
25
29
  id?: string;
26
30
  position?: SpawnPositionInput;
@@ -37,6 +41,8 @@ export interface EntityPose {
37
41
  rotationY?: number;
38
42
  rotationX?: number;
39
43
  rotationZ?: number;
44
+ /** Seconds since the previous pose; when > 0 the store derives `velocity` from the position delta. Omit for teleports (velocity unchanged). */
45
+ dt?: number;
40
46
  }
41
47
  export interface EntityStore<TMeta = undefined> {
42
48
  spawn(name: string, options?: SpawnOptions<TMeta>): string;
@@ -1,4 +1,8 @@
1
1
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
2
+ /** Ground speed (horizontal magnitude of velocity) in world units per second. Scale to km/h or mph in game code. */
3
+ export function groundSpeed(entity) {
4
+ return Math.hypot(entity.velocity[0], entity.velocity[2]);
5
+ }
2
6
  function toEntityPosition(position) {
3
7
  if (position === undefined)
4
8
  return [0, 0, 0];
@@ -31,6 +35,7 @@ export function createEntityStore() {
31
35
  rotationY: options.rotationY ?? 0,
32
36
  rotationX: options.rotationX ?? 0,
33
37
  rotationZ: options.rotationZ ?? 0,
38
+ velocity: [0, 0, 0],
34
39
  role: options.role ?? "prop",
35
40
  movement: options.movement ?? {},
36
41
  behaviors: options.behaviors ?? [],
@@ -54,9 +59,18 @@ export function createEntityStore() {
54
59
  const current = store.get(id);
55
60
  if (!current)
56
61
  return false;
62
+ const position = toEntityPosition(pose.position);
63
+ const velocity = pose.dt !== undefined && pose.dt > 0
64
+ ? [
65
+ (position[0] - current.position[0]) / pose.dt,
66
+ (position[1] - current.position[1]) / pose.dt,
67
+ (position[2] - current.position[2]) / pose.dt,
68
+ ]
69
+ : current.velocity;
57
70
  store.set(id, {
58
71
  ...current,
59
- position: toEntityPosition(pose.position),
72
+ position,
73
+ velocity,
60
74
  rotationY: pose.rotationY ?? current.rotationY,
61
75
  rotationX: pose.rotationX ?? current.rotationX,
62
76
  rotationZ: pose.rotationZ ?? current.rotationZ,
@@ -0,0 +1,62 @@
1
+ export interface TimeConfig {
2
+ /** Game-seconds that elapse per real second at 1× speed. Default 1 (real time). Sims-style compression sets this high (e.g. 60 → one real second is one in-game minute). */
3
+ scale?: number;
4
+ /** Selectable play/fast-forward multipliers for the speed control. Default [1, 2, 3, 4]. Pause (0×) is always available and separate. */
5
+ speeds?: readonly number[];
6
+ /** Game-seconds in one in-world day; drives the 24-hour calendar breakdown. Default 86400 (1 game-second = 1 clock-second). */
7
+ dayLength?: number;
8
+ /** Game-seconds already elapsed at boot — start the world at 08:00 with `8 * 3600`. Default 0. */
9
+ start?: number;
10
+ /** Boot with the clock paused. Default false. */
11
+ startPaused?: boolean;
12
+ }
13
+ export interface CalendarTime {
14
+ /** Total game-seconds since the world started. */
15
+ totalSeconds: number;
16
+ /** 0-based day index. */
17
+ day: number;
18
+ hour: number;
19
+ minute: number;
20
+ second: number;
21
+ /** Progress through the current day, 0..1. */
22
+ dayFraction: number;
23
+ }
24
+ export interface ClockSnapshot {
25
+ now: number;
26
+ paused: boolean;
27
+ /** Effective multiplier applied to real time — 0 while paused. */
28
+ speed: number;
29
+ /** The non-zero multiplier `play()` resumes to. */
30
+ playSpeed: number;
31
+ scale: number;
32
+ speeds: readonly number[];
33
+ calendar: CalendarTime;
34
+ }
35
+ export interface SimClock {
36
+ /** Advance the world by a real-time delta; returns the scaled game-time delta (0 while paused) and fires any due timers. The shell calls this once per frame and passes the result to `loop.onTick`. */
37
+ advance(realDt: number): number;
38
+ now(): number;
39
+ snapshot(): ClockSnapshot;
40
+ calendar(): CalendarTime;
41
+ isPaused(): boolean;
42
+ speed(): number;
43
+ pause(): void;
44
+ play(): void;
45
+ toggle(): void;
46
+ /** Set the play multiplier; `0` (or negative) pauses, any positive value unpauses and becomes the resume speed. */
47
+ setSpeed(multiplier: number): void;
48
+ /** Step to the next configured speed (wraps); always unpauses. */
49
+ cycleSpeed(): void;
50
+ /** Run `callback` once, `seconds` of game-time from now. Returns a cancel handle. */
51
+ after(seconds: number, callback: () => void): () => void;
52
+ /** Run `callback` every `seconds` of game-time. Returns a cancel handle. */
53
+ every(seconds: number, callback: () => void): () => void;
54
+ /** Run `callback` when game-time reaches the absolute `time` (game-seconds). Returns a cancel handle. */
55
+ at(time: number, callback: () => void): () => void;
56
+ }
57
+ export interface SimClockOptions {
58
+ config?: TimeConfig;
59
+ /** Called on control changes and once per displayed in-game minute so UI can rebind; never fires per frame. */
60
+ onChange?: () => void;
61
+ }
62
+ export declare function createSimClock(options?: SimClockOptions): SimClock;