@homie-rocks/studio 0.19.2 → 0.20.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.
@@ -29,12 +29,20 @@
29
29
  * reserve, marked " · AI"), an AI with a pass takes one, and a humans-only
30
30
  * server's bots are off. The game says so: `caps: ['skill', 'agents']`.
31
31
  *
32
+ * THE GAME LAB (`homie-studio lab gem-rush`): the knock is tuned in the lab. Its numbers are tunables.json (read with
33
+ * lab.tunables, so the lab's sliders move them and write kept values back), and the game tells the lab what the knock
34
+ * is doing: its phases, the bumped body's speed and distance, poses for the onion skin and the spacing arc, and preset
35
+ * views. Outside the lab every lab call is a no-op. lab.json holds the take the lab plays.
36
+ *
32
37
  * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
33
38
  */
34
39
  import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
35
40
  // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
36
41
  // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
37
42
  import { createLabels, exposePort, fitView, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
43
+ // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
44
+ import { lab } from '@homie-rocks/studio/lab';
45
+ import tuning from '../tunables.json';
38
46
 
39
47
  /* ------------------------------------------------------------------ rules */
40
48
  const W = 1600;
@@ -48,9 +56,8 @@ const BREAK_MS = 7_000;
48
56
  const GEM_COUNT = 14;
49
57
  const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
50
58
  const MAX_SLOTS = 8;
51
- const KNOCK_RANGE = 110;
52
- const KNOCK_SPEED = 950;
53
- const KNOCK_MS = 420;
59
+ /** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
60
+ const T = lab.tunables(tuning);
54
61
  const ZONE_MS = 12_000;
55
62
  /** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
56
63
  const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
@@ -69,7 +76,9 @@ interface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number
69
76
  * frame. The helper stamps the reset epoch the replica has adopted.
70
77
  */
71
78
  type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];
72
- interface Body { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number; kvx: number; kvy: number; knockUntil: number }
79
+ /** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */
80
+ interface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }
81
+ interface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }
73
82
  interface Gem { id: number; x: number; y: number }
74
83
  /** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
75
84
  interface Zone { n: number; x: number; y: number; r: number; until: number }
@@ -103,7 +112,7 @@ let predictionError = 0;
103
112
  let pickups = new Map<number, number>();
104
113
 
105
114
  /* ------------------------------------------------ this browser's avatar */
106
- const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
115
+ const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };
107
116
  /** Offline (no shell) plays as a local seat 0, with keys or touch. */
108
117
  const mySeat = (): number | null => (net.offline ? 0 : net.seat);
109
118
  /** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
@@ -127,7 +136,7 @@ const newGem = (): Gem => ({ id: ++gemSeq, x: rnd(60, W - 60), y: rnd(60, H - 60
127
136
 
128
137
  function bodyFor(slot: Slot): Body {
129
138
  const sp = spawnPoint(slot.slot);
130
- return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, knockUntil: 0 };
139
+ return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, kx: sp.x, ky: sp.y, kat: 0, knockUntil: 0 };
131
140
  }
132
141
 
133
142
  function syncBodiesFromRoster(): void {
@@ -169,6 +178,7 @@ function startRound(n: number): void {
169
178
  pickups = new Map();
170
179
  sight.clear();
171
180
  round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
181
+ if (lab.stage === 'dummy') stageDummy();
172
182
  setZone(newZone((zone?.n ?? 0) + 1));
173
183
  net.round(round);
174
184
  publishRoster();
@@ -351,25 +361,46 @@ function wave(): void {
351
361
  function hostWave(from: Body): void {
352
362
  addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });
353
363
  for (const b of bodies.values()) {
354
- if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > KNOCK_RANGE) continue;
355
- knock(b, from.x, from.y);
364
+ if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;
365
+ knock(b, from.x, from.y, from.slot);
356
366
  }
357
367
  }
358
368
  /**
359
- * Host: knock a body back. A replica's body is TAKEN for KNOCK_MS (its owner's avatar frames are ignored; the host
360
- * drives the knockback; the owner draws its body from snapshots), then the helper GIVES it back with a reset, so
361
- * the owner stands where the knockback ended. The owner cannot ignore it: the host holds the pen.
369
+ * Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes
370
+ * and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop
371
+ * (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.
372
+ * A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner
373
+ * draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.
362
374
  */
363
- function knock(b: Body, fx: number, fy: number): void {
375
+ function knock(b: Body, fx: number, fy: number, by: number): void {
364
376
  let dx = b.x - fx; let dy = b.y - fy;
365
377
  const len = Math.hypot(dx, dy);
366
378
  if (len < 1) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
367
- const until = net.now() + KNOCK_MS;
368
- if (!b.bot && b.seat !== null && b.seat === mySeat()) { me.kvx = dx * KNOCK_SPEED; me.kvy = dy * KNOCK_SPEED; me.knockUntil = until; }
369
- else { b.kvx = dx * KNOCK_SPEED; b.kvy = dy * KNOCK_SPEED; b.knockUntil = until; if (!b.bot && b.seat !== null) net.take(b.seat, KNOCK_MS); }
370
- addWave({ slot: b.slot }, true); net.send('knock', { slot: b.slot });
379
+ const at = net.now() + T.hitStopMs;
380
+ const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
381
+ o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;
382
+ if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);
383
+ labKnock(b);
384
+ const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };
385
+ addWave(d, true); net.send('knock', d);
386
+ }
387
+
388
+ /** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */
389
+ function slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {
390
+ const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;
391
+ const e = 1 - (1 - u) ** Math.max(1, T.knockEase);
392
+ const x = Math.max(R_AV, Math.min(W - R_AV, o.kx + o.kvx * T.knockDistance * e));
393
+ const y = Math.max(R_AV, Math.min(H - R_AV, o.ky + o.kvy * T.knockDistance * e));
394
+ if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }
395
+ o.x = x; o.y = y;
396
+ }
397
+ /** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */
398
+ function landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {
399
+ if (!o.kat) return;
400
+ slide(o, o.kat + T.knockMs, 0);
401
+ o.kat = 0;
371
402
  }
372
- function addWave(d: { slot: number }, isKnock = false): void {
403
+ function addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {
373
404
  const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
374
405
  // The action, for a watcher on Auto: whoever waved (a person, never a bot).
375
406
  const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
@@ -379,6 +410,7 @@ function addWave(d: { slot: number }, isKnock = false): void {
379
410
  if (!at) return;
380
411
  waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });
381
412
  if (isKnock) knocksSeen += 1; else wavesSeen += 1;
413
+ if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());
382
414
  }
383
415
 
384
416
  /* ---------------------------------------------------------------- simulate */
@@ -399,11 +431,8 @@ function stepMe(dt: number): void {
399
431
  if (seat === null) { me.has = false; return; }
400
432
  // Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
401
433
  if (!net.owned) return;
402
- if (me.knockUntil > net.now()) {
403
- me.x = Math.max(R_AV, Math.min(W - R_AV, me.x + me.kvx * dt)); me.y = Math.max(R_AV, Math.min(H - R_AV, me.y + me.kvy * dt));
404
- me.kvx *= Math.max(0, 1 - dt * 5); me.kvy *= Math.max(0, 1 - dt * 5);
405
- return;
406
- }
434
+ if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }
435
+ landed(me);
407
436
  const v = moveVector();
408
437
  integrate(me, v.x, v.y, dt);
409
438
  }
@@ -434,11 +463,7 @@ function predictMe(dt: number): void {
434
463
  me.has = true;
435
464
  }
436
465
 
437
- function stepKnocked(b: Body, dt: number): void {
438
- b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.kvx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.kvy * dt));
439
- b.kvx *= Math.max(0, 1 - dt * 5); b.kvy *= Math.max(0, 1 - dt * 5);
440
- b.vx = b.kvx; b.vy = b.kvy;
441
- }
466
+ function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
442
467
 
443
468
  /**
444
469
  * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
@@ -455,6 +480,8 @@ function stepBots(dt: number): void {
455
480
  for (const b of bodies.values()) {
456
481
  if (!b.bot) continue;
457
482
  if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
483
+ landed(b);
484
+ if (lab.stage === 'dummy') { standStill(b, dt); continue; }
458
485
  const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
459
486
  let eye = sight.get(b.slot);
460
487
  if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
@@ -465,7 +492,7 @@ function stepBots(dt: number): void {
465
492
  if (g) taken.add(g.id);
466
493
  }
467
494
  b.tx = eye.tx; b.ty = eye.ty;
468
- const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: waves at a rival in reach
495
+ const rival = nearestBody(b, T.knockRange); // AGGRESSION: waves at a rival in reach
469
496
  if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
470
497
  const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
471
498
  const len = dist || 1;
@@ -591,6 +618,144 @@ function stepReplica(dt: number): void {
591
618
  }
592
619
  }
593
620
 
621
+ /* -------------------------------------------------------------- how a bump looks */
622
+ /*
623
+ * Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the body flashes
624
+ * white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows), stops
625
+ * (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it. The
626
+ * waver's own body pushes out a little when it waves. Sparks and the camera's kick roll lab.random(): their own dice, so
627
+ * the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare the two.
628
+ */
629
+ const bumps = new Map<number, { at: number; dx: number; dy: number }>();
630
+ const pushes = new Map<number, number>();
631
+ const sparks: { x: number; y: number; vx: number; vy: number; at: number; life: number }[] = [];
632
+ let kickAt = -1e9;
633
+ function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {
634
+ const now = performance.now();
635
+ const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;
636
+ bumps.set(d.slot, { at: now, dx, dy });
637
+ const base = Math.atan2(dy, dx);
638
+ for (let i = 0; i < T.sparks; i += 1) {
639
+ const a = base + (lab.random() - 0.5) * 1.6;
640
+ const v = 420 + lab.random() * 520;
641
+ sparks.push({ x: at.x - dx * R_AV * 0.6, y: at.y - dy * R_AV * 0.6, vx: Math.cos(a) * v, vy: Math.sin(a) * v, at: now, life: 170 + lab.random() * 170 });
642
+ }
643
+ if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
644
+ // The camera kicks for whoever was in it: the body bumped, or the one who waved.
645
+ const mine = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
646
+ if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
647
+ }
648
+ /** A body's look this frame: offset, stretch along an angle, and how white it flashes. */
649
+ function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; ang: number; flash: number } {
650
+ const out = { ox: 0, oy: 0, sx: 1, sy: 1, ang: 0, flash: 0 };
651
+ const p = pushes.get(slot);
652
+ if (p !== undefined) { const u = (t - p) / 170; if (u >= 0 && u < 1) { const k = 0.14 * Math.sin(Math.PI * u); out.sx = 1 + k; out.sy = 1 + k; } }
653
+ const b = bumps.get(slot);
654
+ if (!b) return out;
655
+ const age = t - b.at;
656
+ const hs = T.hitStopMs; const end = hs + T.knockMs;
657
+ if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }
658
+ out.ang = Math.atan2(b.dy, b.dx);
659
+ out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;
660
+ let k = 0;
661
+ if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 3; out.oy = Math.cos(age * 1.3) * 3; }
662
+ else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);
663
+ else if (T.settleMs > 0 && age < end + T.settleMs) { const w = (age - end) / T.settleMs; k = -T.squash * 0.8 * Math.exp(-3 * w) * Math.sin(2.5 * Math.PI * w); }
664
+ out.sx *= 1 + k; out.sy *= 1 / (1 + k);
665
+ return out;
666
+ }
667
+ function drawSparks(t: number, scale: number): void {
668
+ ctx.lineCap = 'round';
669
+ for (let i = sparks.length - 1; i >= 0; i -= 1) {
670
+ const sp = sparks[i] as (typeof sparks)[number];
671
+ const age = t - sp.at;
672
+ if (age > sp.life) { sparks.splice(i, 1); continue; }
673
+ if (age < T.hitStopMs * 0.5) continue;
674
+ const u = age / sp.life; const s = age / 1000 * (1 - u * 0.5);
675
+ const x = sp.x + sp.vx * s; const y = sp.y + sp.vy * s;
676
+ ctx.strokeStyle = u < 0.4 ? '#ffffff' : '#ffd166'; ctx.globalAlpha = 1 - u; ctx.lineWidth = (2.5 + 4 * (1 - u)) / Math.max(0.5, scale);
677
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sp.vx * 0.022 * (1 - u), y - sp.vy * 0.022 * (1 - u)); ctx.stroke();
678
+ }
679
+ ctx.globalAlpha = 1; ctx.lineCap = 'butt';
680
+ }
681
+
682
+ /* -------------------------------------------------------------- the Game Lab */
683
+ /*
684
+ * What the lab shows of a knock (lab.json's take "knock"): the body bumped last is the subject. Every frame its phase,
685
+ * its speed and its distance from where it was hit go to the lab, and its pose feeds the onion skin and the spacing
686
+ * arc (dots far apart: fast; close together: slow). Only in the lab: outside it, labKnock and labReport never run.
687
+ */
688
+ type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
689
+ /**
690
+ * The take's stage "dummy" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step
691
+ * to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.
692
+ */
693
+ function stageDummy(): void {
694
+ const seat = mySeat();
695
+ const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);
696
+ const mine = list.find((b) => !b.bot && b.seat === seat);
697
+ const bots = list.filter((b) => b.bot);
698
+ if (mine) { mine.x = W / 2 - 160; mine.y = H / 2; hostMoved(mine); }
699
+ bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 160 + 92 : W - 180; b.y = i === 0 ? H / 2 : 150 + (i - 1) * 700; b.tx = b.x; b.ty = b.y; });
700
+ }
701
+ /** A dummy at rest: it eases to a stop wherever the last bump left it. */
702
+ function standStill(b: Body, dt: number): void {
703
+ const k = Math.min(1, dt * 5);
704
+ b.vx -= b.vx * k; b.vy -= b.vy * k;
705
+ b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
706
+ }
707
+ const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
708
+ function labKnock(b: Body): void {
709
+ if (!lab.on) return;
710
+ subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;
711
+ }
712
+ /** Where the subject is now (the host's own body is `me`), or null. */
713
+ function subjectAt(): Pose | null {
714
+ if (!subject.has) return null;
715
+ const b = bodies.get(subject.slot);
716
+ if (!b) return null;
717
+ return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
718
+ }
719
+ function labReport(dt: number): void {
720
+ const at = subjectAt();
721
+ if (!at || dt <= 0) { lab.phase(null); return; }
722
+ const age = net.now() - subject.at;
723
+ lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'px/s');
724
+ lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'px');
725
+ subject.px = at.x; subject.py = at.y;
726
+ const fx = bodyFx(subject.slot, performance.now());
727
+ lab.track('stretch', (fx.sx - 1) * 100, '%');
728
+ if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');
729
+ else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');
730
+ else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');
731
+ else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');
732
+ else lab.phase(null);
733
+ lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });
734
+ }
735
+ /** The lab's views: the game's own camera, close on the knock, the whole arena. */
736
+ const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });
737
+ lab.overlay('onion', (c, k) => {
738
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
739
+ const ghosts = lab.past<Pose>('subject', 8, 3);
740
+ ghosts.forEach((p, i) => { g.globalAlpha = 0.5 * (1 - i / ghosts.length); g.strokeStyle = '#ffad3b'; g.lineWidth = 2 / s; g.beginPath(); g.ellipse(p.x, p.y, R_AV * (p.sx ?? 1), R_AV * (p.sy ?? 1), p.a ?? 0, 0, Math.PI * 2); g.stroke(); });
741
+ g.globalAlpha = 1;
742
+ });
743
+ lab.overlay('arcs', (c, k) => {
744
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
745
+ const pts = lab.past<Pose>('subject', 90, 1);
746
+ if (pts.length < 2) return;
747
+ g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;
748
+ g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();
749
+ g.fillStyle = '#7cc4ff';
750
+ for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }
751
+ });
752
+ lab.overlay('reach', (c, k) => {
753
+ if (!me.has) return;
754
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
755
+ g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);
756
+ g.beginPath(); g.arc(me.x, me.y, T.knockRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);
757
+ });
758
+
594
759
  /* ------------------------------------------------------------------ draw */
595
760
  let dpr = 1;
596
761
  function resize(): void {
@@ -629,8 +794,10 @@ function draw(t: number): void {
629
794
  // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the
630
795
  // screen). Following nobody: the whole arena.
631
796
  const focus = net.watching ? followed : overview || !me.has ? null : me;
632
- const want = overview ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
633
- : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: Math.min(cw, ch) / (phone ? 560 : 820), focus, inset: { top: 52, bottom: 36 } });
797
+ // The Game Lab's views (outside the lab: the game's own camera, always).
798
+ const lv = labView();
799
+ const want = overview || lv?.whole ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
800
+ : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: (Math.min(cw, ch) / (phone ? 560 : 820)) * (lv?.zoom ?? 1), focus: lv?.zoom ? subjectAt() ?? focus : focus, inset: { top: 52, bottom: 36 } });
634
801
  // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
635
802
  cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
636
803
  const scale = cam.scale;
@@ -640,8 +807,10 @@ function draw(t: number): void {
640
807
  bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
641
808
  ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);
642
809
 
810
+ // The camera's kick (a bump you were in): a few px, gone in a fifth of a second.
811
+ const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;
643
812
  ctx.save();
644
- ctx.translate(cw / 2, ch / 2); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);
813
+ ctx.translate(cw / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0), ch / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0)); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);
645
814
  // arena
646
815
  ctx.strokeStyle = 'rgba(125,240,255,0.07)'; ctx.lineWidth = 1 / scale;
647
816
  ctx.beginPath();
@@ -675,10 +844,10 @@ function draw(t: number): void {
675
844
  // waves
676
845
  for (let i = waves.length - 1; i >= 0; i -= 1) {
677
846
  const w = waves[i] as (typeof waves)[number];
678
- const age = (t - w.at) / 900;
847
+ const age = (t - w.at) / T.ringMs;
679
848
  if (age > 1) { waves.splice(i, 1); continue; }
680
849
  ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;
681
- ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (KNOCK_RANGE + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
850
+ ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (T.knockRange + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
682
851
  }
683
852
 
684
853
  // avatars
@@ -702,9 +871,14 @@ function draw(t: number): void {
702
871
  const viewAt = list.find((a) => a.mine) ?? null;
703
872
  for (const a of list) {
704
873
  const colour = colourOf(a.slot, a.bot ? null : a.seat);
874
+ const fx = bodyFx(a.slot, t);
875
+ ctx.save();
876
+ ctx.translate(a.x + fx.ox, a.y + fx.oy); ctx.rotate(fx.ang); ctx.scale(fx.sx, fx.sy); ctx.rotate(-fx.ang);
705
877
  ctx.globalAlpha = a.bot ? 0.62 : 1;
706
- ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV, 0, Math.PI * 2); ctx.fill();
707
- ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
878
+ ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill();
879
+ ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(0, 0, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
880
+ if (fx.flash > 0) { ctx.globalAlpha = fx.flash; ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill(); }
881
+ ctx.restore();
708
882
  if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }
709
883
  ctx.globalAlpha = 1;
710
884
  const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
@@ -713,6 +887,8 @@ function draw(t: number): void {
713
887
  // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
714
888
  tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
715
889
  }
890
+ drawSparks(t, scale);
891
+ lab.draw(ctx, scale);
716
892
  ctx.restore();
717
893
  shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
718
894
  lastDraw = t;
@@ -797,13 +973,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
797
973
  }
798
974
 
799
975
  /* ------------------------------------------------------------------ loop */
800
- let last = performance.now();
801
976
  function frame(t: number): void {
802
- const dt = Math.min(0.05, (t - last) / 1000);
803
- last = t;
977
+ const dt = lab.time.dt(t, 0.05);
804
978
  stepMe(dt);
805
979
  if (hosting) stepHost(dt);
806
980
  else stepReplica(dt);
981
+ if (lab.on) labReport(dt);
807
982
  draw(t);
808
983
  frames += 1;
809
984
  requestAnimationFrame(frame);
@@ -0,0 +1,13 @@
1
+ {
2
+ "knockRange": { "value": 110, "min": 60, "max": 200, "step": 1, "unit": "px", "group": "Knock", "note": "How near a body must be for a wave to bump it" },
3
+ "hitStopMs": { "value": 70, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Knock", "note": "The hit lands: the bumped body holds, squashed and shaking, before it flies" },
4
+ "knockDistance": { "value": 185, "min": 40, "max": 400, "step": 5, "unit": "px", "group": "Knock", "note": "How far a bump sends a body, the same at any frame rate" },
5
+ "knockMs": { "value": 420, "min": 120, "max": 1000, "step": 10, "unit": "ms", "group": "Knock", "note": "How long the slide takes" },
6
+ "knockEase": { "value": 4, "min": 1, "max": 6, "step": 0.1, "group": "Knock", "note": "1 slides at one speed; higher leaves faster and eases into the stop" },
7
+ "squash": { "value": 0.35, "min": 0, "max": 0.8, "step": 0.01, "group": "Body", "note": "How much a flying body stretches along the hit, and squashes when it stops" },
8
+ "settleMs": { "value": 260, "min": 0, "max": 700, "step": 10, "unit": "ms", "group": "Body", "note": "The wobble after the stop (overlap)" },
9
+ "flashMs": { "value": 110, "min": 0, "max": 300, "step": 5, "unit": "ms", "group": "Hit", "note": "How long the bumped body flashes white" },
10
+ "shake": { "value": 7, "min": 0, "max": 24, "step": 0.5, "unit": "px", "group": "Hit", "note": "The camera's kick when you bump or are bumped" },
11
+ "sparks": { "value": 9, "min": 0, "max": 24, "step": 1, "group": "Hit", "note": "Sparks thrown off the hit" },
12
+ "ringMs": { "value": 900, "min": 200, "max": 2000, "step": 10, "unit": "ms", "group": "Wave", "note": "How long a wave's ring lasts" }
13
+ }
@@ -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.19.2';
2
+ export const STUDIO_VERSION_TAG = '0.20.0';