@homie-rocks/studio 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,18 +36,27 @@
36
36
  * done. The BRAIN is the server's (the Table's Workers AI, the owner's key, or the owner's own Claude in the seat); with
37
37
  * none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
38
38
  * never from a model; "Quiet AI" hides them.
39
+ *
40
+ * THE GAME LAB (`homie-studio lab ember-vale`): the strike is tuned in the lab. Its numbers are tunables.json (read
41
+ * with lab.tunables: the lab's sliders move them and write kept values back), and the game tells the lab what a strike
42
+ * is doing: its phases, the struck slime's hit points, squash and push, poses for the onion skin and arcs, and views.
43
+ * The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
44
+ * every lab call is a no-op.
39
45
  */
40
46
  import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
41
47
  import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
42
48
  import vocabulary from '../agents.json';
49
+ // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
50
+ import { lab } from '@homie-rocks/studio/lab';
51
+ import tuning from '../tunables.json';
43
52
 
44
53
  /* ------------------------------------------------------------------ rules */
45
54
  const W = 1600;
46
55
  const H = 1000;
47
56
  const R = 22;
48
57
  const SPEED = 300;
49
- const STRIKE_RANGE = 96;
50
- const STRIKE_MS = 380;
58
+ /** The strike's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
59
+ const T = lab.tunables(tuning);
51
60
  const DOWN_MS = 4000;
52
61
  const DOWN = 1;
53
62
  const STRIKING = 2;
@@ -62,7 +71,8 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
62
71
  /** The hero: the one thing that lasts. Kept in the save key 'hero'. */
63
72
  interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
64
73
  interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
65
- interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
74
+ /** A slime; while it is struck: where its push starts (kx, ky), along (kdx, kdy), from when (kat, after the hit-stop). */
75
+ interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number; kx?: number; ky?: number; kdx?: number; kdy?: number; kat?: number }
66
76
  type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
67
77
 
68
78
  /* ------------------------------------------------------------------ the save */
@@ -290,10 +300,15 @@ function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
290
300
 
291
301
  function strike(b: Body, now: number): void {
292
302
  b.atkAt = now; b.strikeAt = now;
303
+ const hits: Slime[] = [];
293
304
  for (const s of slimes) {
294
- if (s.hp <= 0 || Math.hypot(s.x - b.x, s.y - b.y) > STRIKE_RANGE + s.size * 10) continue;
305
+ if (s.hp <= 0 || Math.hypot(s.x - b.x, s.y - b.y) > T.strikeRange + s.size * 10) continue;
295
306
  s.hp -= damageOf(b.level);
296
307
  s.lastHit = b.slot;
308
+ // The hit lands, then pushes: the slime holds for the hit-stop, then eases back along the strike.
309
+ const d = Math.hypot(s.x - b.x, s.y - b.y) || 1;
310
+ s.kx = s.x; s.ky = s.y; s.kdx = (s.x - b.x) / d; s.kdy = (s.y - b.y) / d; s.kat = now + T.hitStopMs;
311
+ hits.push(s);
297
312
  if (s.hp <= 0) {
298
313
  const xp = s.size === 3 ? 60 : 6 * s.size;
299
314
  const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
@@ -304,6 +319,7 @@ function strike(b: Body, now: number): void {
304
319
  if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
305
320
  }
306
321
  }
322
+ if (lab.on && b.seat !== null && b.seat === room.mySeat()) labStrike(b, now, hits);
307
323
  slimes = slimes.filter((s) => s.hp > 0);
308
324
  }
309
325
 
@@ -324,6 +340,7 @@ function stepHost(dt: number): void {
324
340
  let wants = false;
325
341
  const guide = b.bot && b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null;
326
342
  if (guide) wants = stepGuide(b, guide, room.skillOf(b), dt, now);
343
+ else if (b.bot && lab.stage === 'dummy') wants = false; // the lab's training stage: bots stand by
327
344
  else if (b.bot) {
328
345
  // Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
329
346
  // room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
@@ -341,7 +358,7 @@ function stepHost(dt: number): void {
341
358
  if (!t && Math.hypot(b.tx - b.x, b.ty - b.y) < 30) { b.tx = 200 + Math.random() * (W - 400); b.ty = 150 + Math.random() * (H - 300); }
342
359
  const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
343
360
  if (dist > 60) { b.x += (dx / dist) * SPEED * 0.75 * dt; b.y += (dy / dist) * SPEED * 0.75 * dt; b.face = Math.atan2(dy, dx); }
344
- wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 1400 - 900 * s.aggression);
361
+ wants = Boolean(t && dist < T.strikeRange && now - b.atkAt > 1400 - 900 * s.aggression);
345
362
  } else if (b.seat === myS) {
346
363
  b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
347
364
  wants = me.strikes > 0; me.strikes = 0;
@@ -356,8 +373,8 @@ function stepHost(dt: number): void {
356
373
  wants = Boolean(room.presses(b)['strike']);
357
374
  }
358
375
  b.hp = Math.min(b.hp, b.maxHp);
359
- b.flags = now - b.strikeAt < 200 ? STRIKING : 0;
360
- if (wants && live && now - b.atkAt > STRIKE_MS) strike(b, now);
376
+ b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
377
+ if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
361
378
  }
362
379
  if (live) stepSlimes(dt, now);
363
380
  // Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
@@ -445,7 +462,7 @@ function stepGuide(b: Body, g: Goal, s: Skill, dt: number, now: number): boolean
445
462
  }
446
463
  const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
447
464
  if (dist > (hunt ? 50 : 24)) { const v = Math.min(SPEED * 0.85, dist / Math.max(dt, 1e-3)); b.x = clamp(b.x + (dx / dist) * v * dt, R, W - R); b.y = clamp(b.y + (dy / dist) * v * dt, R, H - R); b.face = Math.atan2(dy, dx); }
448
- return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < STRIKE_RANGE + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
465
+ return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < T.strikeRange + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
449
466
  }
450
467
 
451
468
  /** The quests open now: a slime hunt always, the King Slime when it is out or close to coming, a big slime when one is. */
@@ -465,7 +482,20 @@ function nearestSlime(x: number, y: number): Slime | null {
465
482
  return best;
466
483
  }
467
484
 
485
+ /** A struck slime: held through the hit-stop, then eased back along the strike. True while it is (it does not chase). */
486
+ function pushed(s: Slime, now: number): boolean {
487
+ if (s.kat === undefined || s.kx === undefined || s.ky === undefined) return false;
488
+ if (now < s.kat) return true;
489
+ const u = Math.min(1, (now - s.kat) / Math.max(1, T.pushMs));
490
+ const e = 1 - (1 - u) ** 3;
491
+ s.x = clamp(s.kx + (s.kdx ?? 0) * T.slimePush * e, 20, W - 20); s.y = clamp(s.ky + (s.kdy ?? 0) * T.slimePush * e, 20, H - 20);
492
+ // The push ends exactly where it should, at any frame rate; then the slime chases again.
493
+ if (u >= 1) { delete s.kat; return false; }
494
+ return true;
495
+ }
468
496
  function stepSlimes(dt: number, now: number): void {
497
+ const struck = new Set(slimes.filter((s) => pushed(s, now)).map((s) => s.id));
498
+ if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
469
499
  const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
470
500
  const people = bodies.filter((b) => !b.bot).length;
471
501
  if (now >= spawnAt && slimes.length < 6 + people * 2) {
@@ -484,6 +514,7 @@ function stepSlimes(dt: number, now: number): void {
484
514
  // Slimes hunt heroes: the nearest person, and a bot only when no person is up.
485
515
  const prey = bodies.some((b) => !b.bot) ? bodies.filter((b) => !b.bot) : bodies;
486
516
  for (const s of slimes) {
517
+ if (struck.has(s.id)) continue;
487
518
  let target: Body | null = null; let bd = Infinity;
488
519
  for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
489
520
  const speed = s.size === 3 ? 70 : 95 - s.size * 10;
@@ -504,13 +535,24 @@ function stepSlimes(dt: number, now: number): void {
504
535
  const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
505
536
  // A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
506
537
  document.body.classList.toggle('touch', input.touch.enabled);
538
+ /** A strike's lunge: my hero steps T.lunge along its facing over 90 ms, easing out (owner movement, under top speed). */
539
+ const lunge = { at: -1e9, face: 0, done: 1 };
540
+ function stepLunge(): void {
541
+ if (lunge.done >= 1) return;
542
+ const u = Math.min(1, (performance.now() - lunge.at) / 90);
543
+ const e = 1 - (1 - u) ** 2;
544
+ const d = (e - lunge.done) * T.lunge;
545
+ lunge.done = e;
546
+ me.x = clamp(me.x + Math.cos(lunge.face) * d, R, W - R); me.y = clamp(me.y + Math.sin(lunge.face) * d, R, H - R);
547
+ }
507
548
  function stepMe(dt: number): void {
508
549
  const b = room.hosting ? room.mine() : null;
509
550
  const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
510
551
  const typing = !ui.idle();
511
552
  const m = typing || isDown ? { x: 0, y: 0 } : input.move();
512
553
  if (Math.hypot(m.x, m.y) > 0.05) { me.face = Math.atan2(m.y, m.x); me.x = clamp(me.x + m.x * SPEED * dt, R, W - R); me.y = clamp(me.y + m.y * SPEED * dt, R, H - R); me.has = true; }
513
- if (!typing && input.pressed('strike')) { if (room.hosting) me.strikes += 1; else room.press('strike'); me.flashAt = performance.now(); }
554
+ if (!typing && input.pressed('strike')) { if (room.hosting) me.strikes += 1; else room.press('strike'); me.flashAt = performance.now(); if (!isDown) { lunge.at = performance.now(); lunge.face = me.face; lunge.done = 0; } }
555
+ if (!isDown) stepLunge();
514
556
  room.input([q(me.x, 0), q(me.y, 0), q(me.face, 2), myLevel()]);
515
557
  }
516
558
  function myViewBody(): Body | null { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot) ?? null; }
@@ -534,6 +576,131 @@ let bannerText = ''; let bannerUntil = 0;
534
576
  function banner(t: string): void { bannerText = t; bannerUntil = performance.now() + 2600; }
535
577
  const SLIME = ['#7ad35a', '#e0a83a', '#c74bd8'];
536
578
 
579
+ /*
580
+ * HOW A STRIKE LOOKS, on every screen: the room's fast world says each slime's hit points, so any browser sees a hit as
581
+ * a drop in them (and a kill as a slime gone with a strike beside it). A hit: the slime freezes white for the hit-stop,
582
+ * squashed against the strike, then is pushed back (the host moves it) and wobbles like jelly until it settles
583
+ * (overlap), and its damage pops up. A kill bursts. A strike is a swipe that sweeps around the hero and trails off
584
+ * (follow-through); a strike of mine that lands kicks my camera. Droplets and the kick roll lab.random(): their own dice.
585
+ */
586
+ const seen = new Map<number, { x: number; y: number; hp: number; size: number; hitAt: number; dx: number; dy: number }>();
587
+ const pops: { x: number; y: number; size: number; at: number }[] = [];
588
+ const drops: { x: number; y: number; vx: number; vy: number; at: number; life: number; colour: string }[] = [];
589
+ const numbers: { x: number; y: number; text: string; at: number }[] = [];
590
+ const swings = new Map<number, { at: number; face: number }>();
591
+ let kickAt = -1e9;
592
+ /** The striking body nearest a point (a hit's direction comes from it), or null. */
593
+ function striker(x: number, y: number, bodies: Body[]): Body | null {
594
+ let best: Body | null = null; let bd = Infinity;
595
+ for (const b of bodies) {
596
+ const mineNow = !b.bot && b.seat === room.mySeat() && performance.now() - me.flashAt < T.flashMs;
597
+ if (!(b.flags & STRIKING) && !mineNow) continue;
598
+ const d = Math.hypot(b.x - x, b.y - y);
599
+ if (d < bd && d < T.strikeRange + 60) { bd = d; best = b; }
600
+ }
601
+ return best;
602
+ }
603
+ function watchSlimes(t: number, bodies: Body[]): void {
604
+ const rows = room.fast() ?? [];
605
+ const here = new Set<number>();
606
+ for (const [id, x, y, hp, size] of rows) {
607
+ here.add(id);
608
+ const s = seen.get(id);
609
+ if (!s) { seen.set(id, { x, y, hp, size, hitAt: -1e9, dx: 0, dy: 0 }); continue; }
610
+ if (hp < s.hp) {
611
+ const by = striker(x, y, bodies);
612
+ const d = by ? Math.hypot(x - by.x, y - by.y) || 1 : 1;
613
+ s.hitAt = t; s.dx = by ? (x - by.x) / d : 0; s.dy = by ? (y - by.y) / d : -1;
614
+ numbers.push({ x: x + ((numbers.length % 3) - 1) * 16, y: y - (12 + size * 10), text: `-${Math.round(s.hp - hp)}`, at: t });
615
+ if (by && !by.bot && by.seat === room.mySeat()) kickAt = t;
616
+ }
617
+ s.x = x; s.y = y; s.hp = hp; s.size = size;
618
+ }
619
+ for (const [id, s] of seen) {
620
+ if (here.has(id)) continue;
621
+ seen.delete(id);
622
+ const by = striker(s.x, s.y, bodies);
623
+ if (!by) continue; // gone without a strike: the night ended
624
+ pops.push({ x: s.x, y: s.y, size: s.size, at: t });
625
+ numbers.push({ x: s.x, y: s.y - (12 + s.size * 10), text: `-${Math.round(s.hp)}`, at: t });
626
+ if (!by.bot && by.seat === room.mySeat()) kickAt = t;
627
+ for (let i = 0; i < 10; i += 1) { const a = lab.random() * Math.PI * 2; const v = 120 + lab.random() * 260; drops.push({ x: s.x, y: s.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 80, at: t, life: 260 + lab.random() * 200, colour: SLIME[s.size - 1] ?? '#7ad35a' }); }
628
+ }
629
+ if (drops.length > 200) drops.splice(0, drops.length - 200);
630
+ }
631
+ /** A slime's look this frame: offset, squash (along x and y) and how white it flashes. */
632
+ function slimeFx(id: number, t: number): { ox: number; oy: number; sx: number; sy: number; flash: number } {
633
+ const s = seen.get(id);
634
+ const out = { ox: 0, oy: 0, sx: 1, sy: 1, flash: 0 };
635
+ if (!s) return out;
636
+ const age = t - s.hitAt;
637
+ if (age < 0 || age > T.hitStopMs + T.wobbleMs) return out;
638
+ out.flash = Math.max(0, 1 - age / Math.max(1, T.hitStopMs + 40));
639
+ if (age < T.hitStopMs) {
640
+ // Squashed against the strike, shaking.
641
+ const k = T.wobble; const ax = Math.abs(s.dx); const ay = Math.abs(s.dy);
642
+ out.sx = 1 - k * 0.5 * ax + k * 0.3 * ay; out.sy = 1 - k * 0.5 * ay + k * 0.3 * ax;
643
+ out.ox = Math.sin(age * 1.1) * 2; out.oy = Math.cos(age * 1.7) * 2;
644
+ return out;
645
+ }
646
+ const w = (age - T.hitStopMs) / Math.max(1, T.wobbleMs);
647
+ const k = T.wobble * Math.exp(-4 * w) * Math.cos(w * Math.PI * 5);
648
+ out.sx = 1 + k * 0.7; out.sy = 1 - k * 0.7;
649
+ return out;
650
+ }
651
+ /** A strike's swipe: a crescent sweeping across the hero's front, its smear trailing off. */
652
+ function drawSwing(x: number, y: number, face: number, age: number): void {
653
+ const total = T.swingMs + 120;
654
+ if (age < 0 || age > total) return;
655
+ const u = Math.min(1, age / Math.max(1, T.swingMs));
656
+ const lead = face - Math.PI * 0.7 + Math.PI * 1.25 * (1 - (1 - u) ** 3);
657
+ const tail = lead - Math.PI * (0.2 + 0.45 * (1 - u));
658
+ const fade = age > T.swingMs ? 1 - (age - T.swingMs) / 120 : 1;
659
+ const r = T.strikeRange * 0.82;
660
+ ctx.save();
661
+ ctx.globalAlpha = 0.85 * fade;
662
+ ctx.strokeStyle = '#ffcf6e'; ctx.lineCap = 'round';
663
+ for (let i = 0; i < 6; i += 1) {
664
+ // The smear: thick and bright at the lead, thin and faint behind it.
665
+ const a0 = tail + ((lead - tail) * i) / 6; const a1 = tail + ((lead - tail) * (i + 1)) / 6;
666
+ ctx.globalAlpha = 0.85 * fade * ((i + 1) / 6) ** 1.5;
667
+ ctx.lineWidth = 3 + 9 * ((i + 1) / 6);
668
+ ctx.beginPath(); ctx.arc(x, y, r, a0, a1); ctx.stroke();
669
+ }
670
+ ctx.globalAlpha = fade; ctx.fillStyle = '#fff6dc';
671
+ ctx.beginPath(); ctx.arc(x + Math.cos(lead) * r, y + Math.sin(lead) * r, 5, 0, Math.PI * 2); ctx.fill();
672
+ ctx.restore();
673
+ }
674
+ function drawBursts(t: number): void {
675
+ for (let i = pops.length - 1; i >= 0; i -= 1) {
676
+ const p = pops[i]!; const age = t - p.at; const u = age / 240;
677
+ if (u >= 1) { pops.splice(i, 1); continue; }
678
+ const r = (12 + p.size * 10) * (1 + 0.5 * (1 - (1 - u) ** 2));
679
+ ctx.globalAlpha = 1 - u; ctx.strokeStyle = SLIME[p.size - 1] ?? '#7ad35a'; ctx.lineWidth = 6 * (1 - u) + 1;
680
+ ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.stroke();
681
+ }
682
+ for (let i = drops.length - 1; i >= 0; i -= 1) {
683
+ const d = drops[i]!; const age = (t - d.at) / 1000;
684
+ if (age * 1000 > d.life) { drops.splice(i, 1); continue; }
685
+ const x = d.x + d.vx * age; const y = d.y + d.vy * age + 420 * age * age;
686
+ ctx.globalAlpha = 1 - (age * 1000) / d.life; ctx.fillStyle = d.colour;
687
+ ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill();
688
+ }
689
+ ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.lineJoin = 'round';
690
+ for (let i = numbers.length - 1; i >= 0; i -= 1) {
691
+ const n = numbers[i]!; const age = t - n.at;
692
+ if (age > 700) { numbers.splice(i, 1); continue; }
693
+ const u = Math.min(1, age / 260); const rise = 34 * (1 - (1 - u) ** 3);
694
+ const pop = age < 120 ? 1.35 - 0.35 * (age / 120) : 1;
695
+ ctx.save(); ctx.translate(n.x, n.y - 8 - rise); ctx.scale(pop, pop);
696
+ ctx.globalAlpha = age > 450 ? 1 - (age - 450) / 250 : 1;
697
+ ctx.strokeStyle = 'rgba(11,15,10,.9)'; ctx.lineWidth = 5; ctx.strokeText(n.text, 0, 0);
698
+ ctx.fillStyle = '#fff3d6'; ctx.fillText(n.text, 0, 0);
699
+ ctx.restore();
700
+ }
701
+ ctx.globalAlpha = 1;
702
+ }
703
+
537
704
  /*
538
705
  * The camera (port/view.ts). Where the whole vale fits at a size that reads (a computer, a TV) it all shows, as it
539
706
  * always did. Where it would be small (a phone, above all held upright, where the vale was a strip a quarter of the
@@ -552,7 +719,9 @@ function aim(): Fit {
552
719
  const v = lookOnly ? room.viewBody() : null;
553
720
  const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
554
721
  const phone = Math.min(vw, vh) <= 540;
555
- return fitView({ world: { w: W, h: H }, screen: { w: vw, h: vh }, readable: READABLE, zoom: Math.max(READABLE, Math.min(vw, vh) / (phone ? 560 : 820)), focus, whole: lookOnly && !v, inset: INSET });
722
+ // The Game Lab's views (outside the lab: the game's own camera, always).
723
+ const lv = labView();
724
+ return fitView({ world: { w: W, h: H }, screen: { w: vw, h: vh }, readable: lv?.zoom ? 0 : READABLE, zoom: Math.max(READABLE, Math.min(vw, vh) / (phone ? 560 : 820)) * (lv?.zoom ?? 1), focus, whole: Boolean(lv?.whole) || (lookOnly && !v), inset: INSET });
556
725
  }
557
726
 
558
727
  function draw(t: number): void {
@@ -565,7 +734,10 @@ function draw(t: number): void {
565
734
  ctx.setTransform(1, 0, 0, 1, 0, 0);
566
735
  ctx.fillStyle = '#0b0f0a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
567
736
  ctx.save();
568
- ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * (vw / 2 - cam.x * s), dpr * (vh / 2 - cam.y * s));
737
+ // The camera's kick when a strike of mine lands: a few px, gone in a fifth of a second.
738
+ const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;
739
+ const kx = kick ? (lab.random() - 0.5) * 2 * kick : 0; const ky = kick ? (lab.random() - 0.5) * 2 * kick : 0;
740
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * (vw / 2 - cam.x * s + kx), dpr * (vh / 2 - cam.y * s + ky));
569
741
  // The vale: moss, and embers that drift.
570
742
  ctx.fillStyle = '#16210f'; ctx.fillRect(0, 0, W, H);
571
743
  for (let i = 0; i < 70; i++) {
@@ -574,12 +746,19 @@ function draw(t: number): void {
574
746
  ctx.beginPath(); ctx.arc(x, H - y, i % 5 ? 26 : 2.5, 0, Math.PI * 2); ctx.fill();
575
747
  }
576
748
  const fast = room.fast() ?? [];
577
- for (const [, x, y, hp, size] of fast) {
749
+ const everyone = room.view();
750
+ watchSlimes(t, everyone);
751
+ for (const [id, x, y, hp, size] of fast) {
578
752
  const r = 12 + size * 10;
753
+ const fx = slimeFx(id, t);
754
+ ctx.save();
755
+ ctx.translate(x + fx.ox, y + fx.oy); ctx.scale(fx.sx, fx.sy);
579
756
  ctx.fillStyle = SLIME[size - 1] ?? '#7ad35a';
580
- ctx.beginPath(); ctx.ellipse(x, y, r, r * (0.78 + Math.sin(t / 180 + x) * 0.06), 0, 0, Math.PI * 2); ctx.fill();
581
- ctx.fillStyle = '#0b0f0a'; ctx.fillRect(x - r * 0.4, y - r * 0.2, 4, 4); ctx.fillRect(x + r * 0.3, y - r * 0.2, 4, 4);
582
- if (size === 3) { ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x - 14, y - r - 12, 28, 8); }
757
+ ctx.beginPath(); ctx.ellipse(0, 0, r, r * (0.78 + Math.sin(t / 180 + x) * 0.06), 0, 0, Math.PI * 2); ctx.fill();
758
+ if (fx.flash > 0) { ctx.globalAlpha = fx.flash; ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.8, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }
759
+ ctx.fillStyle = '#0b0f0a'; ctx.fillRect(-r * 0.4, -r * 0.2, 4, 4); ctx.fillRect(r * 0.3, -r * 0.2, 4, 4);
760
+ if (size === 3) { ctx.fillStyle = '#ffcf6e'; ctx.fillRect(-14, -r - 12, 28, 8); }
761
+ ctx.restore();
583
762
  const max = size === 3 ? 160 : 18 * size;
584
763
  ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
585
764
  ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
@@ -601,7 +780,12 @@ function draw(t: number): void {
601
780
  const x = local ? me.x : b.x; const y = local ? me.y : b.y;
602
781
  const isDown = Boolean(b.flags & DOWN);
603
782
  ctx.globalAlpha = isDown ? 0.35 : 1;
604
- if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
783
+ // A strike: the swipe starts on the press (my own), or when the room says this body strikes (anyone's).
784
+ const striking = Boolean(b.flags & STRIKING) || (self && performance.now() - me.flashAt < T.flashMs);
785
+ const sw = swings.get(b.slot);
786
+ if (striking && (!sw || t - sw.at > T.strikeMs * 0.9)) swings.set(b.slot, { at: self && performance.now() - me.flashAt < T.flashMs ? t - (performance.now() - me.flashAt) : t, face: self ? me.face : b.face });
787
+ const swing = swings.get(b.slot);
788
+ if (swing) drawSwing(x, y, swing.face, t - swing.at);
605
789
  const isGuide = (net.slots ?? []).some((x) => x.slot === b.slot && x.agent?.role === 'guide');
606
790
  ctx.fillStyle = self ? '#ffcf6e' : isGuide ? '#7fd8c8' : b.bot ? '#8aa0b8' : '#f3ead2';
607
791
  ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
@@ -622,6 +806,8 @@ function draw(t: number): void {
622
806
  // The bubble's tail stops just above the guide's own name, so the name keeps its spot while it speaks.
623
807
  if (line && line.until > performance.now()) said.push({ x: sx(x), y: body.top - 3 - fs * 1.2 - 12, head: body.top, text: line.text });
624
808
  }
809
+ drawBursts(t);
810
+ lab.draw(ctx, s);
625
811
  ctx.restore();
626
812
  // Speech bubbles first (labels keep clear of them), then the names; your own last, on a dark chip.
627
813
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -848,16 +1034,82 @@ function paintAsks(): void {
848
1034
  }
849
1035
  setInterval(paintAsks, 250);
850
1036
 
1037
+ /* ------------------------------------------------------------------ the Game Lab */
1038
+ /*
1039
+ * What the lab shows of a strike (lab.json's take "strike"): my hero's last strike, and the slime nearest it then.
1040
+ * Every frame: the strike's phase, the slime's hit points, how far it was pushed and how squashed it is (Today: it
1041
+ * never reacts), and poses for the onion skin and the arc. Only in the lab: outside it these never run.
1042
+ */
1043
+ type Pose = { x: number; y: number; r: number };
1044
+ const subject = { id: -1, at: 0, x0: 0, y0: 0, has: false, hit: false };
1045
+ function labStrike(b: Body, now: number, hits: Slime[]): void {
1046
+ const s = hits[0] ?? nearestSlime(b.x, b.y);
1047
+ subject.at = now; subject.has = true; subject.hit = hits.length > 0;
1048
+ if (s) { subject.id = s.id; subject.x0 = s.kx ?? s.x; subject.y0 = s.ky ?? s.y; }
1049
+ }
1050
+ /** The subject slime as drawn (the fast world's row), or null once it is gone. */
1051
+ function subjectSlime(): { x: number; y: number; hp: number; size: number } | null {
1052
+ const row = (room.fast() ?? []).find((r) => r[0] === subject.id);
1053
+ return row ? { x: row[1], y: row[2], hp: row[3], size: row[4] } : null;
1054
+ }
1055
+ function labReport(): void {
1056
+ if (!subject.has) { lab.phase(null); return; }
1057
+ const age = room.net.now() - subject.at;
1058
+ const sl = subjectSlime();
1059
+ lab.track('slime hp', sl ? sl.hp : 0, 'hp');
1060
+ lab.track('slime push', sl ? Math.hypot(sl.x - subject.x0, sl.y - subject.y0) : 0, 'px');
1061
+ const fx = slimeFx(subject.id, performance.now());
1062
+ lab.track('slime squash', (fx.sx - 1) * 100, '%');
1063
+ const hit = subject.hit;
1064
+ if (hit && age < T.hitStopMs) lab.phase('IMPACT', 'The hit lands: the slime freezes white');
1065
+ else if (age < (hit ? T.hitStopMs : 0) + T.swingMs) lab.phase(hit ? 'FOLLOW' : 'SWING', hit ? 'The swipe carries through; the slime is pushed back' : 'A swipe at nothing');
1066
+ else if (age < T.strikeMs) lab.phase('RECOVER', 'The slime wobbles; ready to strike again');
1067
+ else if (sl && age < T.hitStopMs + T.wobbleMs) lab.phase('SETTLE', 'Overlap: the wobble dies away');
1068
+ else lab.phase(null);
1069
+ if (sl) lab.pose('slime', { x: sl.x, y: sl.y, r: 12 + sl.size * 10 });
1070
+ }
1071
+ /** The lab's training stage: the hero at camp facing right, a big slime a step away, the bots off to the side. */
1072
+ let staged = false;
1073
+ function stageDummy(): void {
1074
+ staged = true;
1075
+ me.x = W / 2 - 70; me.y = H / 2; me.face = 0; me.has = true;
1076
+ let i = 0;
1077
+ for (const b of room.bodies.values()) if (b.bot) { b.x = 200 + (i % 2) * 1200; b.y = 160 + Math.floor(i / 2) * 680; b.tx = b.x; b.ty = b.y; i += 1; room.moved(b); }
1078
+ slimes = [{ id: ++slimeSeq, x: W / 2 + 30, y: H / 2, hp: 36, maxHp: 36, size: 2, hitAt: 0, lastHit: -1 }];
1079
+ }
1080
+ const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.2 }, whole: { whole: true } });
1081
+ lab.overlay('onion', (c, k) => {
1082
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
1083
+ const ghosts = lab.past<Pose>('slime', 8, 3);
1084
+ ghosts.forEach((p, i) => { g.globalAlpha = 0.5 * (1 - i / ghosts.length); g.strokeStyle = '#ffad3b'; g.lineWidth = 2 / s; g.beginPath(); g.arc(p.x, p.y, p.r, 0, Math.PI * 2); g.stroke(); });
1085
+ g.globalAlpha = 1;
1086
+ });
1087
+ lab.overlay('arcs', (c, k) => {
1088
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
1089
+ const pts = lab.past<Pose>('slime', 90, 1);
1090
+ if (pts.length < 2) return;
1091
+ g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;
1092
+ g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();
1093
+ g.fillStyle = '#7cc4ff';
1094
+ for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }
1095
+ });
1096
+ lab.overlay('reach', (c, k) => {
1097
+ if (!me.has) return;
1098
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
1099
+ g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);
1100
+ g.beginPath(); g.arc(me.x, me.y, T.strikeRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);
1101
+ });
1102
+
851
1103
  /* ------------------------------------------------------------------ loop */
852
- let last = performance.now();
853
1104
  let frames = 0;
854
1105
  function frame(t: number): void {
855
- const dt = Math.min(0.05, (t - last) / 1000);
856
- last = t;
1106
+ const dt = lab.time.dt(t, 0.05);
1107
+ if (lab.stage === 'dummy' && !staged && room.hosting && room.round?.phase === 'live') stageDummy();
857
1108
  stepMe(dt);
858
1109
  keepNames(t);
859
1110
  if (room.hosting) stepHost(dt);
860
1111
  draw(t);
1112
+ if (lab.on) labReport();
861
1113
  frames += 1;
862
1114
  requestAnimationFrame(frame);
863
1115
  }
@@ -0,0 +1,13 @@
1
+ {
2
+ "strikeRange": { "value": 96, "min": 50, "max": 180, "step": 1, "unit": "px", "group": "Strike", "note": "How far a strike reaches (a slime's size adds to it)" },
3
+ "strikeMs": { "value": 380, "min": 150, "max": 900, "step": 10, "unit": "ms", "group": "Strike", "note": "The shortest time between two strikes" },
4
+ "flashMs": { "value": 200, "min": 60, "max": 500, "step": 10, "unit": "ms", "group": "Strike", "note": "How long a strike shows on every screen" },
5
+ "swingMs": { "value": 150, "min": 40, "max": 400, "step": 5, "unit": "ms", "group": "Strike", "note": "The swipe around the hero, and its smear as it carries through" },
6
+ "hitStopMs": { "value": 60, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Hit", "note": "The hit lands: the slime freezes, white, before it is pushed" },
7
+ "slimePush": { "value": 14, "min": 0, "max": 80, "step": 1, "unit": "px", "group": "Hit", "note": "How far a strike pushes a slime back" },
8
+ "lunge": { "value": 14, "min": 0, "max": 40, "step": 1, "unit": "px", "group": "Strike", "note": "The hero steps into each strike (as far as the push, so a combo stays in reach)" },
9
+ "pushMs": { "value": 220, "min": 60, "max": 600, "step": 10, "unit": "ms", "group": "Hit", "note": "How long the push takes (it eases out)" },
10
+ "wobble": { "value": 0.3, "min": 0, "max": 0.7, "step": 0.01, "group": "Hit", "note": "How much a struck slime squashes and wobbles" },
11
+ "wobbleMs": { "value": 420, "min": 100, "max": 1000, "step": 10, "unit": "ms", "group": "Hit", "note": "How long the wobble takes to die away (overlap)" },
12
+ "shake": { "value": 4, "min": 0, "max": 16, "step": 0.5, "unit": "px", "group": "Hit", "note": "The camera's kick when your strike lands" }
13
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "v": 1,
3
+ "default": "knock",
4
+ "takes": {
5
+ "knock": {
6
+ "note": "Bump the dummy beside you",
7
+ "seconds": 2.2,
8
+ "seed": 7,
9
+ "stage": "dummy",
10
+ "device": "desk",
11
+ "fps": 60,
12
+ "view": "close",
13
+ "track": "speed",
14
+ "inputs": [
15
+ { "at": 0.5, "key": "Space", "hold": 0.1 }
16
+ ]
17
+ }
18
+ }
19
+ }