@homie-rocks/studio 0.46.6 → 0.47.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/rules/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
1
2
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
2
3
  import { solidAt } from './collision.ts';
3
4
  import type { RayQueries, RayOptions } from './types.ts';
@@ -72,7 +73,7 @@ export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
72
73
  }
73
74
 
74
75
  /** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
75
- export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
76
+ export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; wrap?: Partial<Record<'x'|'y'|'z',true>>; tiles?: unknown; spots?: Record<string, unknown[]> } }
76
77
  let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
77
78
  /** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
78
79
  export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
@@ -159,7 +160,7 @@ export interface Room<R = unknown> extends RayQueries {
159
160
  askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
160
161
  readonly tune: Readonly<Record<string, unknown>>;
161
162
  /** The game's static map: its edges, its solid shapes and its named spots, in metres. */
162
- readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
163
+ readonly map: import('./rules.ts').GameMap;
163
164
  readonly net: Netplay;
164
165
  /** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
165
166
  pump(): void;
@@ -191,6 +192,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
191
192
  const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
192
193
  const map = compileMap(game.map, game.map.name ?? 'main');
193
194
  const spots = map.spots;
195
+ const compiledMaps = new Map<string, typeof map>();
196
+ const mapOf = (data?: import('./map-state.ts').MapRevision | null) => {
197
+ if (!data) return map;
198
+ const key = `${epoch}:${data[0]}`;
199
+ let value = compiledMaps.get(key);
200
+ if (!value) {
201
+ value = compileMap(data[1], map.name);
202
+ compiledMaps.set(key, value);
203
+ // Only replay history needs old compiled indexes; saves carry the compact data.
204
+ if (compiledMaps.size > 64) compiledMaps.delete(compiledMaps.keys().next().value!);
205
+ }
206
+ return value;
207
+ };
194
208
  const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
195
209
 
196
210
  let status: RoomStatus = 'connecting';
@@ -259,7 +273,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
259
273
  const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
260
274
  const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
261
275
  const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
262
- const distance = (a: Vec3, b: Vec3): number => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
276
+ const distance = (a: Vec3, b: Vec3): number => Math.hypot(periodicDelta(b.x,a.x,map.wrap?.x),periodicDelta(b.y,a.y,map.wrap?.y),periodicDelta(b.z,a.z,map.wrap?.z));
263
277
  const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
264
278
  function visualOffset(now: number, path?: Vec3, settled = false): Vec3 {
265
279
  if (predict.blendMs <= 0) { offsets = []; return { x: 0, y: 0, z: 0 }; }
@@ -300,9 +314,21 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
300
314
  let collisionHistory: CollisionRevision[] = [];
301
315
  let cachedCollision: CollisionRevision | undefined;
302
316
  let geometry = map as import('./math.ts').MapShapes;
317
+ const mapAt = (tick:number) => {
318
+ if (!collisionHistory.at(-1)?.[3]?.map) return map;
319
+ // Map activation and live collider projection have independent effective ticks.
320
+ // A lost pending snapshot must not postpone a map already active in its successor.
321
+ for (let i=collisionHistory.length-1;i>=0;i--) {
322
+ const metadata=collisionHistory[i][3];
323
+ if (metadata?.map && metadata.map[0]<=tick) return mapOf(metadata.map);
324
+ if (metadata?.previousMap && metadata.previousMap[0]<=tick) return mapOf(metadata.previousMap);
325
+ }
326
+ return map;
327
+ };
303
328
  const geometryAt = () => {
304
329
  const revision = revisionAt(collisionHistory, moveTick);
305
- if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
330
+ const activeMap=mapAt(moveTick);
331
+ if (revision !== cachedCollision || (geometry.staticMap??geometry)!==activeMap) { geometry = collisionMap(activeMap, revision?.[2] ?? []); cachedCollision = revision; }
306
332
  return geometry;
307
333
  };
308
334
  const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, self: () => mine?.id, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ terrain: myKind()?.terrain, shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
@@ -342,7 +368,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
342
368
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
343
369
  if (mine) {
344
370
  history.set(stepped, copyMine(mine));
345
- if (shown) offsets = [{ delta: { x: shown.x - mine.pos.x, y: shown.y - mine.pos.y, z: shown.z - mine.pos.z }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
371
+ if (shown) offsets = [{ delta: { x: periodicDelta(mine.pos.x,shown.x,map.wrap?.x), y: periodicDelta(mine.pos.y,shown.y,map.wrap?.y), z: periodicDelta(mine.pos.z,shown.z,map.wrap?.z) }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
346
372
  }
347
373
  }
348
374
  /** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
@@ -361,7 +387,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
361
387
  const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
362
388
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
363
389
  const height = kind?.height || 2 * r;
364
- out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
390
+ out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
365
391
  return out;
366
392
  }
367
393
  /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
@@ -400,7 +426,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
400
426
  judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
401
427
  get viewTick() { return viewTickNow(); },
402
428
  ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
403
- rays: (options) => raySnapshot(map, viewTargets(atTickOf(options)), dims, options, id),
429
+ rays: (options) => raySnapshot(mapAt(t), viewTargets(atTickOf(options)), dims, options, id),
404
430
  rayAll: (from, direction, max, options) => viewRay(from, direction, max, options, true),
405
431
  ghost(kindOfGhost, at, data = {}, options = {}) {
406
432
  const k = kindOf.get(kindOfGhost);
@@ -665,7 +691,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
665
691
  // Limit the new reconciliation, not an offset already being blended (which
666
692
  // can include a clock recalibration). Clipping that carried offset would
667
693
  // turn a small new error into a visible snap.
668
- const change = { x: shown.x - corrected.x - oldOffset.x, y: shown.y - corrected.y - oldOffset.y, z: shown.z - corrected.z - oldOffset.z };
694
+ const change = { x: periodicDelta(corrected.x,shown.x,map.wrap?.x) - oldOffset.x, y: periodicDelta(corrected.y,shown.y,map.wrap?.y) - oldOffset.y, z: periodicDelta(corrected.z,shown.z,map.wrap?.z) - oldOffset.z };
669
695
  const gap = Math.hypot(change.x, change.y, change.z);
670
696
  const ahead = Math.max(0, tickAt(clock()) - f.k) / tickHz;
671
697
  const cap = predict.snapM ?? myKind()!.maxSpeed * (2 * ahead + 0.1);
@@ -811,7 +837,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
811
837
  const free = -mine.vel.z / tickHz, contact = free > 1e-6 ? Math.max(0.05, Math.min(1, (mine.pos.z - next.pos.z) / free)) : 1;
812
838
  av = Math.min(1, a / contact); landed = a >= contact;
813
839
  }
814
- pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, av) }, dims);
840
+ pos = vec3({...blendPosition(mine.pos,next.pos,a,map.wrap), z:wrapPoint({x:0,y:0,z:mine.pos.z+periodicDelta(mine.pos.z,next.pos.z,map.wrap?.z)*av},map.wrap).z}, dims);
815
841
  heading = blendHeading(mine.heading, next.heading, a, dims);
816
842
  velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
817
843
  // A takeoff is airborne as soon as it rises; a landing waits for its contact.
@@ -832,14 +858,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
832
858
  // Catch-up can reach the fractional current tick before its next whole step exists.
833
859
  // Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
834
860
  const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
835
- if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
861
+ if (a && b) { const f = catchTick - t; pos = blendPosition(a.pos, b.pos, f, map.wrap); heading = blendHeading(a.heading, b.heading, f, dims);
836
862
  velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
837
863
  grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
838
864
  } else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
839
865
  }
840
866
  }
841
867
  const offset = visualOffset(clock(), pos, settled);
842
- pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
868
+ pos = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
843
869
  if (headingBlend) {
844
870
  const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
845
871
  heading = blendHeading(headingBlend.from, heading, t, dims);
@@ -859,7 +885,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
859
885
  if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
860
886
  const t = s.alpha;
861
887
  const h = blendHeading(ea.heading, eb.heading, t, dims);
862
- return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
888
+ return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
863
889
  }
864
890
  function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
865
891
  function each(kind: string, fn: (e: Entity) => void): void {
@@ -948,8 +974,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
948
974
  let before: Frame | null = null, after: Frame | null = null;
949
975
  for (const f of held2) { if (!f.ents.has(id)) continue; if (f.k <= at) before = f; if (f.k >= at && !after) after = f; }
950
976
  const a = before?.ents.get(id), b = after?.ents.get(id);
951
- if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3({ x: lerp(a.pos.x, b.pos.x, m), y: lerp(a.pos.y, b.pos.y, m), z: lerp(a.pos.z, b.pos.z, m) }, dims), vel: b.vel, known: true }; }
952
- if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s }, dims), vel: a.vel, known: false }; }
977
+ if (a && b) { if (before === after || a.r !== b.r) return { pos: b.pos, vel: b.vel, known: true }; const m = (at - before!.k) / (after!.k - before!.k); return { pos: vec3(blendPosition(a.pos, b.pos, m, map.wrap), dims), vel: b.vel, known: true }; }
978
+ if (a) { const s = Math.min(tickHz, at - before!.k) / tickHz; return { pos: vec3(wrapPoint({ x: a.pos.x + a.vel.x * s, y: a.pos.y + a.vel.y * s, z: a.pos.z + a.vel.z * s },map.wrap), dims), vel: a.vel, known: false }; }
953
979
  return b ? { pos: b.pos, vel: b.vel, known: false } : null;
954
980
  }
955
981
  const atTickOf = (options: unknown): number | undefined => { const v = options !== null && typeof options === 'object' ? (options as { atTick?: unknown }).atTick : undefined; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; };
@@ -963,7 +989,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
963
989
  }
964
990
  const viewTargets = (atTick?: number) => {
965
991
  pump();
966
- const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
992
+ const targets: QueryTarget[] = collisionTargets(collisionMap(mapAt(latest?.k??0), collisionHistory.at(-1)?.[2] ?? []));
967
993
  for(const k of schema.kinds) {
968
994
  if(k.collider || !k.radius)continue;
969
995
  each(k.name,e=>{
@@ -974,7 +1000,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
974
1000
  }
975
1001
  return targets;
976
1002
  };
977
- const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
1003
+ const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,from,direction,max,options,mine?.id,all);
978
1004
  return {
979
1005
  get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
980
1006
  get status() { return solo ? 'offline' : status; },
@@ -982,7 +1008,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
982
1008
  get me() { pump(); return meNow(); },
983
1009
  each, get,
984
1010
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
985
- rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(atTickOf(options)),dims,options,mine?.id),
1011
+ rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
986
1012
  poseAt: (id, tick) => solo ? solo.poseAt(id, tick) : poseAt(id, tick),
987
1013
  rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
988
1014
  on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
@@ -1015,7 +1041,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1015
1041
  if (nextShown && distance(shown, nextShown) > 0.00001) {
1016
1042
  const carried = visualOffset(now);
1017
1043
  // Coalesce the carried blend: analogue controls may change every frame.
1018
- offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
1044
+ offsets = [{ delta: { x: carried.x + periodicDelta(nextShown.x,shown.x,map.wrap?.x), y: carried.y + periodicDelta(nextShown.y,shown.y,map.wrap?.y), z: carried.z + periodicDelta(nextShown.z,shown.z,map.wrap?.z) },
1019
1045
  at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
1020
1046
  }
1021
1047
  }
@@ -1027,7 +1053,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1027
1053
  probe(x) { extra = { ...extra, ...x }; },
1028
1054
  ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
1029
1055
  askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
1030
- tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
1056
+ tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
1031
1057
  pump,
1032
1058
  close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
1033
1059
  };
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.46.6","sha256":"efd3ddbf72033f5d24b8107a41db2bc1a518f1d3f92963f5afd77c5c0e2f5059"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.47.0","sha256":"4e1a1a503557e68e60b55a05dc289e4e688831c429d18c8f13ad2a24c3e05315"});
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.46.6';
2
+ export const STUDIO_VERSION_TAG = '0.47.0';