@homie-rocks/studio 0.25.0 → 0.26.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.
@@ -10,9 +10,25 @@
10
10
  * the snapshot, so every screen draws the jump, and the host decides what a swing hits by it.
11
11
  * SWING (F, a click, or the Swing button): every screen plays the swing at once; the host lands it T.windupMs later
12
12
  * (the anticipation), knocking every body in front of the swinger and within reach that is on the ground.
13
- * The hot zone (keyed state) scores double; a watcher follows any player (`net.viewSeat`, `net.spotlight`); bots play at
14
- * the room's skill dial (and jump a swing they see coming); the swing and the jump are tuned in the Game Lab
15
- * (tunables.json, lab.json). The world is in metres, a 26 x 16 m clearing, x across and y deep (y is three.js's z).
13
+ * The hot zone (keyed state) scores double; a watcher follows any player (`net.viewSeat`, `net.spotlight`); the swing
14
+ * and the jump are tuned in the Game Lab (tunables.json, lab.json). The world is in metres, a 26 x 16 m clearing, x
15
+ * across and y deep (y is three.js's z).
16
+ *
17
+ * THE BOTS never swamp a new player. They play at the room's skill dial (NETPLAY.md section 17; they jump a swing they
18
+ * see coming), with two things on top (THE BOTS, below, says exactly how):
19
+ * - In somebody's first round in the room, when nobody set the dial (no vote, no owner), they play a level easier.
20
+ * - A RUBBER BAND: a bot ahead of the best person in the room by more than its dial allows eases off (it walks,
21
+ * reacts later, swings less) and comes to race near the people, so a rival is on their screen; otherwise it plays
22
+ * at its dial. The lead it may hold grows with the dial; at Maxed there is no rubber band (a party that voted
23
+ * Maxed asked for it).
24
+ * Scores stay honest: nobody's score is ever changed, no coin is given, taken or faked; the bots just play softer.
25
+ * And a bot is always labelled a bot (an AI agent "· AI") wherever its name or score shows, the phone's board too.
26
+ *
27
+ * THE ARRIVAL: the play page shows the game's arrival card (its title, art and a progress line this game feeds with
28
+ * `net.loading`) until the game says `net.playable()`: the round's state is in and your own hero is drawn in its real
29
+ * model (THE ARRIVAL, by the HUD). So the round opens on your hero, with no title card over it. On a phone held
30
+ * upright the camera follows low and close (30 degrees, about 5.5 m across: the camera's comment says why), so every
31
+ * hero reads as a character, a mage too, and your name chip says which you are ("You · Mage").
16
32
  *
17
33
  * THE CHARACTERS are KayKit's CC0 adventurers and skeletons from the Homie starter library, made phone-sized by `game new`
18
34
  * (lib/characters.mjs): each one skinned mesh, one draw call, its joints named by the skeleton standard, and every clip
@@ -54,7 +70,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
54
70
  import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
55
71
  // Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
56
72
  import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
57
- 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';
73
+ import { createNetplay, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
58
74
  // The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
59
75
  // labels that never pile up (port/view.ts).
60
76
  import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
@@ -136,7 +152,8 @@ interface Body extends Knock { slot: number; seat: number | null; name: string;
136
152
  interface Gem { id: number; x: number; y: number }
137
153
  /** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
138
154
  interface Zone { n: number; x: number; y: number; r: number; until: number }
139
- interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number }
155
+ /** `firstRound`: each person's first whole round here (seat, round), so a promoted host eases the same round. */
156
+ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; firstRound?: [number, number][] }
140
157
 
141
158
  /* --------------------------------------------------------------- the net */
142
159
  /**
@@ -146,7 +163,8 @@ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[];
146
163
  */
147
164
  const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
148
165
  // caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
149
- const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'hero-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
166
+ // arrival 'game': the play page's arrival card stays until this game says it is playable (THE ARRIVAL, by the HUD).
167
+ const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'hero-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], arrival: 'game', checkpoint: () => checkpoint() });
150
168
  /** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
151
169
  const policy = () => net.policy;
152
170
 
@@ -267,12 +285,15 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
267
285
  const seat = mySeat();
268
286
  if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
269
287
  syncBodiesFromRoster();
288
+ firstRound = new Map();
270
289
  startRound((e.round?.n ?? 0) + 1);
290
+ welcome(seat);
271
291
  }
272
292
  // Whoever is connected now is who plays: seats that left during the gap become bots.
273
293
  const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
274
294
  const { claimed } = roster.reconcile(peers);
275
295
  syncBodiesFromRoster();
296
+ for (const s of claimed) welcome(s.seat);
276
297
  for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
277
298
  // My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
278
299
  const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
@@ -293,6 +314,7 @@ function restore(e: RoleChange<Snap, Ckpt>): void {
293
314
  gemSeq = ck.gemSeq;
294
315
  round = ck.round;
295
316
  tick = ck.tick;
317
+ firstRound = new Map(Array.isArray(ck.firstRound) ? ck.firstRound : []);
296
318
  } else {
297
319
  roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
298
320
  bodies = new Map();
@@ -318,6 +340,8 @@ function restore(e: RoleChange<Snap, Ckpt>): void {
318
340
  }
319
341
  // A round message the relay kept is at least as authoritative as the checkpoint's copy.
320
342
  if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
343
+ // Anyone already playing whose first round the checkpoint does not say is no newcomer: the dial stays exact for them.
344
+ for (const r of roster.slots) if (!r.bot && r.seat !== null && !firstRound.has(r.seat)) firstRound.set(r.seat, 0);
321
345
  if (!round) { startRound(1); return; }
322
346
  net.round(round);
323
347
  }
@@ -330,6 +354,7 @@ function checkpoint(): Ckpt {
330
354
  roster: roster.toJSON(),
331
355
  tick,
332
356
  gemSeq,
357
+ firstRound: [...firstRound],
333
358
  };
334
359
  }
335
360
 
@@ -342,6 +367,7 @@ net.on('join', (p) => {
342
367
  // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
343
368
  const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
344
369
  if (!c) return; // full: they watch
370
+ welcome(p.seat);
345
371
  syncBodiesFromRoster();
346
372
  const b = bodies.get(c.slot.slot);
347
373
  // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
@@ -352,6 +378,7 @@ net.on('join', (p) => {
352
378
  net.on('leave', (p) => {
353
379
  if (!hosting || p.seat === null) return;
354
380
  roster.release(p.seat); // their body stays, driven by a bot
381
+ firstRound.delete(p.seat); // whoever sits in this seat next is new here
355
382
  syncBodiesFromRoster();
356
383
  publishRoster();
357
384
  });
@@ -594,16 +621,104 @@ function predictMe(dt: number): void {
594
621
  function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
595
622
 
596
623
  /**
597
- * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
598
- * last noticed is where it heads, and a miss costs it that long again), aims up to 4 m off at aimNoise 1, leaves
599
- * the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
600
- * `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few.
624
+ * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17), AND NEVER RUNNING AWAY WITH A NEW PLAYER'S ROUND.
625
+ *
626
+ * The dial: a bot re-reads the world only every `reactionMs` (what it last noticed is where it heads, and a miss costs
627
+ * it that long again), aims up to 4 m off at aimNoise 1, leaves the hot zone to the people at positioning 0 and fights
628
+ * for it at 1, and swings at a rival in reach about `aggression x 0.8` times a second. Rookie is beatable by anyone,
629
+ * Maxed by few.
630
+ *
631
+ * On top of the dial, in plain words:
632
+ * A PLAYER'S FIRST ROUND. While somebody plays their first round in this room and nobody has set the dial (no vote,
633
+ * no owner: `net.policy.by` is absent), every bot plays one level easier for that round (Fair plays as Steady). A
634
+ * dial somebody set is always kept exactly.
635
+ * THE RUBBER BAND. Each bot compares its score with the best person's in the room (people only: never a bot, never
636
+ * an AI). Up to LEAD[level] points ahead of them it plays at its dial (2 at Fair, 5 at Strong; a Rookie eases off
637
+ * even a point behind them). Further ahead it eases off, the more the further (LEAD_BAND points more and it is all
638
+ * the way soft): it slows to a walk, notices things later, swings less, and races for the coins near the nearest
639
+ * person rather than the far ones. All the way soft it goes for no coin at all: it keeps near that person, a step
640
+ * up their screen, where a rival is seen (jogging after them when they run off), and goes round the coins on its
641
+ * way. The lead allowed grows with the dial; Maxed has no rubber band (a party that voted Maxed asked for the real
642
+ * thing). Within its lead, a bot always plays at its dial.
643
+ * HONEST. No score is ever changed: no coin is given, taken or faked, and a soft bot still picks up any coin it is
644
+ * knocked or walks onto, like anyone. Bots only play softer. Every bot is labelled where its name or score shows
645
+ * ("· bot"; an AI agent's name ends in "· AI"), on the phone's compact board too.
646
+ */
647
+ const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean; near?: number }>();
648
+ /**
649
+ * The lead (points over the best person) a bot may hold before it eases off, by level: Rookie (it eases off a point
650
+ * BEHIND them: beatable by anyone), Steady, Fair, Strong, Maxed (no rubber band at all).
651
+ */
652
+ const LEAD = [-1, 1, 2, 5, Infinity] as const;
653
+ /** Points past that lead over which a bot goes from its dial to all the way soft. */
654
+ const LEAD_BAND = 2;
655
+ /**
656
+ * Each seated person and the first round they play whole in this room (the round they sat down in, or the next when
657
+ * less than half of it was left). While one of them is in it, nobody set the dial and the bots play a level easier.
658
+ * Kept in the checkpoint, so a promoted host goes on easing the same round.
601
659
  */
602
- const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
660
+ let firstRound = new Map<number, number>();
661
+ /** How each bot plays now (its level this round, and how soft the rubber band has it): the `pickups` probe. */
662
+ const botPlay = new Map<number, { level: number; soft: number }>();
663
+
664
+ /** A person sat down: their first whole round here is this one, or the next if less than half of this one is left. */
665
+ function welcome(seat: number | null): void {
666
+ if (seat === null || firstRound.has(seat)) return;
667
+ const live = round?.phase === 'live';
668
+ firstRound.set(seat, !round ? 1 : live && round.endsAt - net.now() > ROUND_MS / 2 ? round.n : round.n + 1);
669
+ }
670
+ /** The people in the room: seated, not a bot, not an AI (an agent that plays its own seat is labelled · AI). */
671
+ function people(): Body[] {
672
+ return [...bodies.values()].filter((b) => !b.bot && b.seat !== null && !b.name.endsWith(AI_MARK) && !roster.slots.find((x) => x.slot === b.slot)?.agent);
673
+ }
674
+ /** Somebody is playing their first round in this room now. */
675
+ function newcomerRound(folk: Body[]): boolean {
676
+ return Boolean(round) && folk.some((b) => (firstRound.get(b.seat as number) ?? 0) >= (round as RoundInfo).n);
677
+ }
678
+ /** The dial a bot plays at this round: the room's, or a level easier in a newcomer's first round when nobody set it. */
679
+ function dialOf(slot: number, easy: boolean): Skill {
680
+ const s: Skill = net.skillOf(slot); // Fair when nobody set a dial
681
+ return easy && !net.policy.by && s.level > 1 ? skillPreset(s.level - 1, net.policy.kids) : s;
682
+ }
683
+ /** The rubber band: 0 plays at the dial, 1 all the way soft, from how far this bot leads the best person. */
684
+ function softness(b: Body, s: Skill, best: number | null): number {
685
+ const allowed = LEAD[Math.max(1, Math.min(5, s.level)) - 1] as number;
686
+ if (best === null || !Number.isFinite(allowed)) return 0;
687
+ return Math.max(0, Math.min(1, (b.score - best - allowed) / LEAD_BAND));
688
+ }
689
+ /** The nearest of `list` to `b`, or null. */
690
+ function nearestOf(list: Body[], b: Body): Body | null {
691
+ let best: Body | null = null; let bd = Infinity;
692
+ for (const o of list) { const d = Math.hypot(o.x - b.x, o.y - b.y); if (d < bd) { bd = d; best = o; } }
693
+ return best;
694
+ }
695
+ /**
696
+ * All the way soft: a spot near person `p`, 2.2 to 3 m off (just past a swing's reach) and a step up their screen
697
+ * (where even a phone held upright shows a rival), turned a little each time it looks, so it keeps round them like a
698
+ * rival waiting for a chance. It leaves the coins to them: of a few such spots, the first it can walk to without
699
+ * running over a coin.
700
+ */
701
+ function beside(b: Body, p: Body): { x: number; y: number } {
702
+ const clear = R_AV + R_GEM + 0.15;
703
+ let spot = { x: b.x, y: b.y };
704
+ for (let i = 0; i < 6; i += 1) {
705
+ let a = Math.atan2(Math.min(b.y - p.y, -0.6), b.x - p.x) + (Math.random() - 0.5) * (1.2 + i * 0.5);
706
+ a = Math.max(-Math.PI + 0.7, Math.min(-0.7, a));
707
+ const r = 2.2 + Math.random() * 0.8;
708
+ spot = bound(p.x + Math.cos(a) * r, p.y + Math.sin(a) * r);
709
+ const dx = spot.x - b.x; const dy = spot.y - b.y; const len2 = dx * dx + dy * dy || 1;
710
+ // The nearest point of the walk to each coin: none closer than a pickup.
711
+ if (!gems.some((g) => { const u = Math.max(0, Math.min(1, ((g.x - b.x) * dx + (g.y - b.y) * dy) / len2)); return Math.hypot(b.x + dx * u - g.x, b.y + dy * u - g.y) < clear; })) break;
712
+ }
713
+ return spot;
714
+ }
603
715
 
604
716
  function stepBots(dt: number): void {
605
717
  const taken = new Set<number>();
606
718
  const now = net.now();
719
+ const folk = people();
720
+ const best = folk.length ? Math.max(...folk.map((p) => p.score)) : null;
721
+ const easy = newcomerRound(folk);
607
722
  for (const b of bodies.values()) {
608
723
  if (!b.bot) continue;
609
724
  fall(b, dt);
@@ -612,52 +727,89 @@ function stepBots(dt: number): void {
612
727
  if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
613
728
  landed(b);
614
729
  if (lab.stage === 'dummy' || lab.stage === 'jump') { standStill(b, dt); continue; }
615
- const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
730
+ const s = dialOf(b.slot, easy);
731
+ const soft = softness(b, s, best); // THE RUBBER BAND
732
+ botPlay.set(b.slot, { level: s.level, soft });
616
733
  let eye = sight.get(b.slot);
617
- if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
618
- const g = pickGem(b, taken, s); // POSITIONING
619
- const miss = 4 * s.aimNoise; // AIM NOISE: up to 4 m off at 1
620
- eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
734
+ // REACTION TIME: later when soft (it notices coins late); all the way soft it only keeps up with its person.
735
+ if (!eye || now - eye.at >= s.reactionMs * (soft >= 1 ? 1 : 1 + 2.5 * soft)) {
736
+ const near = soft > 0 ? nearestOf(folk, b) : null;
737
+ let g: Gem | null = null;
738
+ let tx = b.tx; let ty = b.ty;
739
+ if (near && soft >= 1) { const at = beside(b, near); tx = at.x; ty = at.y; } // all the way soft: no coin, near them
740
+ else {
741
+ g = pickGem(b, taken, s, near, soft); // POSITIONING (soft: the coins near the nearest person)
742
+ const miss = 4 * s.aimNoise; // AIM NOISE: up to 4 m off at 1
743
+ if (g) { tx = g.x + (Math.random() - 0.5) * miss; ty = g.y + (Math.random() - 0.5) * miss; }
744
+ }
745
+ eye = { at: now, tx, ty, ...(near && soft >= 1 ? { near: near.slot } : {}) };
621
746
  sight.set(b.slot, eye);
622
747
  if (g) taken.add(g.id);
623
748
  }
624
749
  b.tx = eye.tx; b.ty = eye.ty;
625
- const rival = nearestBody(b, T.knockRange * 0.9); // AGGRESSION: swings at a rival in reach, turning to it
626
- if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) { b.fa = Math.atan2(rival.x - b.x, rival.y - b.y); startSwing(b); }
750
+ const rival = nearestBody(b, T.knockRange * 0.9); // AGGRESSION: swings at a rival in reach, turning to it (less when soft)
751
+ if (rival && round?.phase === 'live' && Math.random() < s.aggression * (1 - 0.75 * soft) * 0.8 * dt) { b.fa = Math.atan2(rival.x - b.x, rival.y - b.y); startSwing(b); }
627
752
  const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
628
753
  const len = dist || 1;
629
754
  // Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
630
755
  if (dist < 0.12 && !eye.arrived) { eye.arrived = true; eye.at = now; }
631
- const speed = dist < 0.12 ? 0 : BOT_SPEED;
756
+ // Soft: down to a walk; all the way soft it jogs back to its person when they have run off (a rival in view).
757
+ const kept = eye.near !== undefined ? bodies.get(eye.near) : undefined;
758
+ const far = kept ? Math.hypot(kept.x - b.x, kept.y - b.y) > 3.5 : false;
759
+ const speed = dist < 0.12 ? 0 : BOT_SPEED * (far ? 1 : 1 - 0.5 * soft);
632
760
  const k = Math.min(1, dt * 5);
633
761
  b.vx += ((dx / len) * speed - b.vx) * k;
634
762
  b.vy += ((dy / len) * speed - b.vy) * k;
763
+ // All the way soft it steps round a coin in its way (turning a little, or a lot, or it waits): it leaves them to
764
+ // the people. It still takes one it is knocked onto, or one that lands under it: the rules' pickups are anyone's.
765
+ if (kept) sidestep(b, dt);
635
766
  const p = bound(b.x + b.vx * dt, b.y + b.vy * dt);
636
767
  b.x = p.x; b.y = p.y;
637
768
  faceOf(b, now);
638
769
  }
639
770
  }
771
+ /** A soft bot's step that would touch a coin turns aside (60, then 120 degrees, either way), or it stands this frame. */
772
+ function sidestep(b: Body, dt: number): void {
773
+ const reach = R_AV + R_GEM + 0.06;
774
+ const look = Math.max(0.25, Math.hypot(b.vx, b.vy) * dt * 4); // a few frames ahead
775
+ const touches = (vx: number, vy: number): boolean => {
776
+ const sp = Math.hypot(vx, vy) || 1; const x = b.x + (vx / sp) * look; const y = b.y + (vy / sp) * look;
777
+ return gems.some((g) => Math.hypot(g.x - x, g.y - y) < reach && Math.hypot(g.x - x, g.y - y) < Math.hypot(g.x - b.x, g.y - b.y));
778
+ };
779
+ if (Math.hypot(b.vx, b.vy) < 0.05 || !touches(b.vx, b.vy)) return;
780
+ for (const a of [1.05, -1.05, 2.1, -2.1]) {
781
+ const c = Math.cos(a); const sn = Math.sin(a);
782
+ const vx = b.vx * c - b.vy * sn; const vy = b.vx * sn + b.vy * c;
783
+ if (!touches(vx, vy)) { b.vx = vx; b.vy = vy; return; }
784
+ }
785
+ b.vx = 0; b.vy = 0;
786
+ }
640
787
  /**
641
788
  * A bot sees a swing start near it: it may JUMP it, after its reaction time (half of it: a swing is loud), the more
642
- * likely the better its aim at the dial (a Rookie is caught, a Maxed bot hops most of them).
789
+ * likely the better its aim at the dial (a Rookie is caught, a Maxed bot hops most of them; a soft one less often).
643
790
  */
644
791
  const dodgeAt = new Map<number, number>();
645
792
  function botsSee(from: Body): void {
793
+ const easy = newcomerRound(people());
646
794
  for (const b of bodies.values()) {
647
795
  if (!b.bot || b === from || lab.stage === 'dummy' || lab.stage === 'jump') continue;
648
796
  if (Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange + 0.6) continue;
649
- const s: Skill = net.skillOf(b.slot);
650
- if (Math.random() < 0.15 + 0.6 * (1 - s.aimNoise)) dodgeAt.set(b.slot, net.now() + Math.max(40, s.reactionMs * 0.5 - 60));
797
+ const s = dialOf(b.slot, easy);
798
+ const soft = botPlay.get(b.slot)?.soft ?? 0;
799
+ if (Math.random() < (0.15 + 0.6 * (1 - s.aimNoise)) * (1 - 0.5 * soft)) dodgeAt.set(b.slot, net.now() + Math.max(40, s.reactionMs * 0.5 - 60));
651
800
  }
652
801
  }
653
802
 
654
- /** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
655
- function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
803
+ /**
804
+ * Positioning: 0 leaves the hot zone to the people, 1 fights for it. A soft bot (`pull` 0 to 1) weighs how far each
805
+ * coin is from the person `near` it too, so it races for theirs, where they can see it and beat it to them.
806
+ */
807
+ function pickGem(b: Body, taken: Set<number>, s: Skill, near: Body | null = null, pull = 0): Gem | null {
656
808
  let best: Gem | null = null; let bd = Infinity;
657
809
  for (const g of gems) {
658
810
  if (taken.has(g.id)) continue;
659
811
  const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
660
- const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
812
+ const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1) + (near ? 2 * pull * Math.hypot(g.x - near.x, g.y - near.y) : 0);
661
813
  if (d < bd) { bd = d; best = g; }
662
814
  }
663
815
  return best;
@@ -1011,6 +1163,74 @@ function loadFonts(): void {
1011
1163
  }
1012
1164
  loadFonts();
1013
1165
 
1166
+ /* ------------------------------------------------------------------ the characters, asked for first */
1167
+ /*
1168
+ * THE CHARACTERS, your own first: the play page's arrival card waits for your own hero's real model (THE ARRIVAL,
1169
+ * below), and your seat (so your hero's kind) is known at the welcome, so the moment it comes your own hero is asked for
1170
+ * alone, followed by its skeleton's clip library; then the other heroes. The bots' skeletons (drawn as stand-ins until
1171
+ * they are in) come after the heroes, or after 4 s at most, so on a slow phone they never share the line with your own
1172
+ * hero. A watcher asks for every hero at once. About 0.67 MB for the heroes and their clips, 0.47 MB for the skeletons
1173
+ * and theirs, 0.15 MB for the meadow's props: the same files as ever, in this order.
1174
+ */
1175
+ const models = createModels();
1176
+ /**
1177
+ * The heroes people play (one a seat, in turn) and the skeletons bots play (one a slot): KayKit's adventurers and
1178
+ * skeletons, one rig family, so one clip library each and one look. Each is drawn HERO_M tall (`game new` made them so).
1179
+ */
1180
+ const HEROES = ['knight', 'barbarian', 'mage', 'rogue', 'rogue-hooded'] as const;
1181
+ const SKELETONS = ['skeleton-minion', 'skeleton-warrior', 'skeleton-rogue', 'skeleton-mage'] as const;
1182
+ const HERO_BUDGET = { triangles: 8000, texturePx: 1024, materials: 2, bytes: 1536 * 1024 };
1183
+ const heroUrl = (kind: string): string => `./models/${kind}.glb`;
1184
+ /** Models that are not there (refused or missing): drawn as stand-ins. */
1185
+ const standIns = new Set<string>();
1186
+ /** THE ARRIVAL's progress line: the files asked for while the game loads, and how many have come (or failed). */
1187
+ const loads = { all: 0, done: 0, heroes: 0, heroesDone: 0, said: 0 };
1188
+ function sayLoading(): void {
1189
+ loads.said = Math.max(loads.said, loads.all ? loads.done / loads.all : 0);
1190
+ net.loading(loads.said, loads.heroesDone < loads.heroes ? 'the heroes' : 'the clearing');
1191
+ }
1192
+ /** One more file on the line (`hero`: a character or its clips); the function it returns says it came or failed. */
1193
+ function expect(hero: boolean): () => void {
1194
+ loads.all += 1; if (hero) loads.heroes += 1;
1195
+ let settled = false;
1196
+ return () => { if (settled) return; settled = true; loads.done += 1; if (hero) loads.heroesDone += 1; sayLoading(); };
1197
+ }
1198
+ function track<T>(p: Promise<T>, hero: boolean): Promise<T> { const done = expect(hero); p.then(done, done); return p; }
1199
+ /** Each kind's model and its clip library, loaded once (loadCharacter finds both in hand); copies are made per body. */
1200
+ const heroLoads = new Map<string, Promise<boolean>>();
1201
+ const clipLoads = new Map<string, Promise<unknown>>();
1202
+ let heroesIn = (): void => {};
1203
+ const afterHeroes = new Promise<void>((done) => { heroesIn = done; setTimeout(done, 4000); });
1204
+ function loadHero(kind: string): Promise<boolean> {
1205
+ let p = heroLoads.get(kind);
1206
+ if (!p) {
1207
+ const url = heroUrl(kind);
1208
+ const came = expect(true);
1209
+ const go = (): Promise<boolean> => models.load(url, { budget: HERO_BUDGET }).then((m) => {
1210
+ came();
1211
+ // Its skeleton's clip library at once (the model names it), at loadCharacter's own address and budget.
1212
+ const named = (m.scene.userData?.homie as { anims?: string } | undefined)?.anims;
1213
+ if (!named) return true;
1214
+ const href = new URL(named, new URL(url, location.href)).href;
1215
+ let clips = clipLoads.get(href);
1216
+ if (!clips) { clips = track(models.load(href, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } }), true).catch(() => null); clipLoads.set(href, clips); }
1217
+ return clips.then(() => true);
1218
+ }, () => { came(); standIns.add(url); return false; });
1219
+ p = (SKELETONS as readonly string[]).includes(kind) ? afterHeroes.then(go) : go();
1220
+ heroLoads.set(kind, p);
1221
+ }
1222
+ return p;
1223
+ }
1224
+ /** Every hero in (your own first, at the welcome): the skeletons wait for it, and so does the Game Lab. */
1225
+ const allHeroes: Promise<boolean[]> = net.ready.then(() => {
1226
+ const seat = mySeat();
1227
+ const own = seat !== null && !net.watching ? (HEROES[seat % HEROES.length] as string) : null;
1228
+ return (own ? loadHero(own) : Promise.resolve(true)).then(() => Promise.all(HEROES.map(loadHero)));
1229
+ });
1230
+ void allHeroes.then(() => heroesIn());
1231
+ sayLoading();
1232
+ for (const k of SKELETONS) void loadHero(k);
1233
+
1014
1234
  /* ------------------------------------------------------------------ renderer, scene, light */
1015
1235
  const renderer = new WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
1016
1236
  // Flat low-poly colours read true without a film curve (the style board renders the same way).
@@ -1143,18 +1363,14 @@ function facets(tris: number[][], colours: string[]): BufferGeometry {
1143
1363
  * refused file) is drawn as a stand-in made here in the same colours, or left out when it is only a flower or a tuft of
1144
1364
  * grass: a round never waits on, or breaks for, a model.
1145
1365
  */
1146
- const models = createModels();
1147
- /**
1148
- * The heroes people play (one a seat, in turn) and the skeletons bots play (one a slot): KayKit's adventurers and
1149
- * skeletons, one rig family, so one clip library each and one look. Each is drawn HERO_M tall (`game new` made them so).
1150
- */
1151
- const HEROES = ['knight', 'barbarian', 'mage', 'rogue', 'rogue-hooded'] as const;
1152
- const SKELETONS = ['skeleton-minion', 'skeleton-warrior', 'skeleton-rogue', 'skeleton-mage'] as const;
1366
+ // `models`, HEROES and SKELETONS are above (the characters, asked for first).
1153
1367
  /** The verb a hero's swing plays: a mage casts; everyone else strikes. */
1154
1368
  const HERO_ACTION: Record<string, string> = { mage: 'cast', 'skeleton-mage': 'cast' };
1369
+ /** What your own name chip says you are ("You · Mage"): which hero is yours reads at a glance, even from above. */
1370
+ const HERO_NAME: Record<string, string> = { knight: 'Knight', barbarian: 'Barbarian', mage: 'Mage', rogue: 'Rogue', 'rogue-hooded': 'Rogue' };
1371
+ const heroName = (kind: string): string => HERO_NAME[kind] ?? kind.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
1155
1372
  const HERO_M = 1.45;
1156
1373
  const heroKind = (slot: number, seat: number | null, bot: boolean): string => (bot ? SKELETONS[slot % SKELETONS.length] : HEROES[(seat ?? slot) % HEROES.length]) as string;
1157
- const heroUrl = (kind: string): string => `./models/${kind}.glb`;
1158
1374
 
1159
1375
  type RGB = [number, number, number];
1160
1376
  const PAINT = {
@@ -1222,11 +1438,10 @@ const DRESSING: (Spec & { count: number; where: Where; foot: number; shadow: num
1222
1438
  /** A model loaded once, repainted and measured: `fit` scales it to its height, `size` is its box at that height. */
1223
1439
  interface Ready { scene: Object3D; fit: number; size: Vector3 }
1224
1440
  const ready = new Map<string, Promise<Ready | null>>();
1225
- const standIns = new Set<string>();
1226
1441
  function prepare(spec: Pick<Spec, 'url' | 'm' | 'swap' | 'tint' | 'px' | 'wide'>): Promise<Ready | null> {
1227
1442
  let p = ready.get(spec.url);
1228
1443
  if (!p) {
1229
- p = models.load(spec.url).then((m) => {
1444
+ p = track(models.load(spec.url), false).then((m) => {
1230
1445
  if (spec.swap) repaint(m.scene, spec.swap, spec.px ? { maxPx: spec.px } : undefined);
1231
1446
  const size = new Box3().setFromObject(m.scene).getSize(new Vector3());
1232
1447
  const along = spec.wide ? Math.max(size.x, size.z) : size.y;
@@ -1561,13 +1776,6 @@ interface Hero {
1561
1776
  px: number; py: number; ph: number; speed: number; seen: number; yaw: number; held: string | null;
1562
1777
  }
1563
1778
  const heroes = new Map<number, Hero>();
1564
- /** Each kind's character, loaded once (its model, its clips); copies are made per body. */
1565
- const heroLoads = new Map<string, Promise<boolean>>();
1566
- function loadHero(kind: string): Promise<boolean> {
1567
- let p = heroLoads.get(kind);
1568
- if (!p) { p = models.load(heroUrl(kind), { budget: { triangles: 8000, texturePx: 1024, materials: 2, bytes: 1536 * 1024 } }).then(() => true, () => { standIns.add(heroUrl(kind)); return false; }); heroLoads.set(kind, p); }
1569
- return p;
1570
- }
1571
1779
  function heroFor(slot: number, kind: string, colour: string): Hero {
1572
1780
  let h = heroes.get(slot);
1573
1781
  if (h && h.kind === kind) return h;
@@ -1643,7 +1851,8 @@ function poseHero(h: Hero, at: { x: number; y: number; h: number }, t: number, d
1643
1851
  c.update(dt);
1644
1852
  }
1645
1853
 
1646
- void Promise.all([...[...HEROES, ...SKELETONS].map((k) => loadHero(k)), prepare(GEM), ...(FENCED ? [prepare(FENCE)] : []), prepare(CHEST), prepare(TORCH), prepare(BARREL), prepare(CRATE), ...DRESSING.map(prepare)]).then(() => lab.ready());
1854
+ // The Game Lab starts its take once everything is in (the heroes in their order above: never all at once at boot).
1855
+ void Promise.all([allHeroes, ...SKELETONS.map((k) => loadHero(k)), prepare(GEM), ...(FENCED ? [prepare(FENCE)] : []), prepare(CHEST), prepare(TORCH), prepare(BARREL), prepare(CRATE), ...DRESSING.map(prepare)]).then(() => lab.ready());
1647
1856
 
1648
1857
  /* ------------------------------------------------------------------ the Game Lab's overlays, as lines on the meadow */
1649
1858
  const pen = (() => {
@@ -1703,12 +1912,14 @@ const HALF = (CAM.fov * Math.PI) / 360;
1703
1912
  /*
1704
1913
  * A phone held upright sees the ground through a slit: its width is the short side. Followed from style.json's pitch
1705
1914
  * and distance it shows a long strip of mostly empty grass with a small hero in it. So, like the port kit's fitView
1706
- * (fill a phone held upright and follow the player), it frames the action instead: closer (about 7 m across at the
1707
- * player, a hero a fifth of the screen's width) at 38 to 44 degrees (any steeper and a hero is the top of its hat),
1708
- * the player in the lower half and what is around and ahead of it filling the rest, the woods' edge at the top. No
1709
- * tall dressing stands on the camera's side, so nothing comes between the camera and its hero.
1915
+ * (fill a phone held upright and follow the player), it frames the action instead: closer (about 5.5 m across at the
1916
+ * player, a hero a quarter of the screen's width) and LOWER, 28 to 32 degrees (8 under style.json's pitch): from the
1917
+ * 40 it once was, a hero is the top of its hat (a mage's brim hides the whole mage), and its legs, so its run, never
1918
+ * show. At 30 the face, the body and the legs read on every kind, and the far side of the clearing and the woods fill
1919
+ * the top of the screen, where the rivals ahead are. The player stands in the lower half. No tall dressing stands on
1920
+ * the camera's side, so nothing comes between the camera and its hero.
1710
1921
  */
1711
- const UPRIGHT_PITCH = Math.max((38 * Math.PI) / 180, Math.min((44 * Math.PI) / 180, PITCH0 + (2 * Math.PI) / 180));
1922
+ const UPRIGHT_PITCH = Math.max((28 * Math.PI) / 180, Math.min((32 * Math.PI) / 180, PITCH0 - (8 * Math.PI) / 180));
1712
1923
  let PITCH = PITCH0;
1713
1924
  const MARGIN = EDGE + 3; // how far past the arena's near side and ends the camera may look: the path, the fence, the flowers
1714
1925
  // Past its far side it may look into the woods (the trees stand 3 to 18 m out): the clearing reads as a place.
@@ -1726,10 +1937,10 @@ function setPitch(p: number): void {
1726
1937
  }
1727
1938
  setPitch(PITCH0);
1728
1939
  function followDistance(cw: number, ch: number, phone: boolean, upright = false): number {
1729
- // style.json's distance scales the view (22 is these spans); an upright phone's stays near 5.5 m whatever it says,
1730
- // or a close camera would show one hero and nothing round it.
1940
+ // style.json's distance scales the view (22 is these spans); an upright phone's stays between 5.5 and 8 m whatever
1941
+ // it says, or a close camera would show one hero and nothing round it.
1731
1942
  const k = (CAM.distance ?? 22) / 22;
1732
- const span = upright ? 7 * Math.max(0.9, Math.min(1.3, k)) : (phone ? 9.5 : 13.5) * k;
1943
+ const span = upright ? 6.1 * Math.max(0.9, Math.min(1.3, k)) : (phone ? 9.5 : 13.5) * k;
1733
1944
  return span / (2 * Math.tan(HALF) * Math.min(1, cw / ch));
1734
1945
  }
1735
1946
  /** How far away the whole arena fits; `turned`: seen from its side, its 32 m running up the screen (a tall screen). */
@@ -1763,7 +1974,7 @@ function aimAt(focus: { x: number; y: number } | null, d: number, cw: number, ch
1763
1974
  if (turned) return { x: W - middleOf(W, d), y: H / 2 };
1764
1975
  // An upright phone follows its player everywhere (past the edge it sees the fence and the woods), looking a little
1765
1976
  // ahead so the player stands in the lower half and what is coming fills the top.
1766
- if (upright && focus) return { x: focus.x, y: focus.y - d * 0.18 };
1977
+ if (upright && focus) return { x: focus.x, y: focus.y - d * 0.27 };
1767
1978
  const loY = FAR_K * d - MARGIN_FAR; const hiY = H + MARGIN - NEAR_K * d;
1768
1979
  let y: number;
1769
1980
  if (whole || loY > hiY) y = middleOf(H, d);
@@ -1835,7 +2046,6 @@ const INTRO_MS = 2600;
1835
2046
  const INTRO_CLOSE = 0.38;
1836
2047
  const INTRO_PITCH = (24 * Math.PI) / 180;
1837
2048
  let introAt = 0;
1838
- let focusAt = 0;
1839
2049
  const v3 = new Vector3();
1840
2050
  /** A point on the meadow (rules x, y, h metres up) on the screen, CSS px. */
1841
2051
  function onScreen(x: number, y: number, h: number, cw: number, ch: number): { x: number; y: number; ok: boolean } {
@@ -1870,21 +2080,20 @@ function draw(t: number, dt: number): void {
1870
2080
  let want: { x: number; y: number; dist: number; lift?: number } = { ...aim, dist };
1871
2081
  if (focus && !net.watching && !lab.on) {
1872
2082
  // It starts with a live round (a seat taken during the results waits for the next one, under the results card)
1873
- // and the hero's own model (never its stand-in, unless the model is still not in after three seconds).
1874
- if (!focusAt) focusAt = t;
1875
- const dressed = [...heroes.values()].some((h) => h.mine && h.char) || t - focusAt > 3000;
1876
- if (!introAt && round?.phase === 'live' && dressed) introAt = t;
2083
+ // the moment the game is playable: the arrival card lifts on the hero's own model (its stand-in only when the model
2084
+ // is still not in after about five seconds), so the close-up is the first thing the player sees.
2085
+ if (!introAt && round?.phase === 'live' && playableAt) introAt = t;
1877
2086
  const u = introAt ? Math.min(1, (t - introAt) / INTRO_MS) : 1;
1878
2087
  if (u < 1) {
1879
2088
  const e = u < 0.35 ? 0 : (u - 0.35) / 0.65; const k = e * e * (3 - 2 * e);
1880
- // Looking at the hero's chest, so its face is under the title card, never behind it.
2089
+ // Looking at the hero's chest: the whole hero, head to feet, in the middle of the screen.
1881
2090
  const fy = focus.y - 0.2;
1882
2091
  want = { x: focus.x + (aim.x - focus.x) * k, y: fy + (aim.y - fy) * k, dist: dist * (INTRO_CLOSE + (1 - INTRO_CLOSE) * k), lift: HERO_M * 0.55 * (1 - k) };
1883
2092
  // Lower too, at first: its face, not the top of its hat.
1884
2093
  setPitch(PITCH + (INTRO_PITCH - PITCH) * (1 - k));
1885
2094
  if (u === 0 || !view3.dist) Object.assign(view3, want, { yaw: 0 });
1886
2095
  }
1887
- } else if (!focus) { introAt = 0; focusAt = 0; }
2096
+ } else if (!focus) introAt = 0;
1888
2097
  placeCamera({ ...want, yaw: turned ? -Math.PI / 2 : 0 }, dt, kick);
1889
2098
  flames.forEach((f, i) => { const k = 1 + 0.14 * Math.sin(t / 95 + i * 2) + 0.06 * Math.sin(t / 37 + i); f.children[0]?.scale.set(1, k, 1); f.children[1]?.scale.set(1, 2 - k, 1); });
1890
2099
 
@@ -2013,15 +2222,25 @@ function draw(t: number, dt: number): void {
2013
2222
 
2014
2223
  if (lab.on) { pen.begin(); lab.draw(pen); pen.end(); }
2015
2224
  renderer.render(scene, camera);
2225
+ arrival(t);
2016
2226
 
2017
2227
  // ---- the HUD, over the world
2018
2228
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2019
2229
  ctx.clearRect(0, 0, cw, ch);
2020
- // the hot zone's "×2", on the screen over its middle
2230
+ // the hot zone's "×2", on the screen over its middle; over its far or near edge instead when the hero whose view
2231
+ // this is stands under its middle (the mark never covers your own hero)
2021
2232
  zoneMark = null;
2022
2233
  if (zone) {
2023
- const z = onScreen(zone.x, zone.y, 0, cw, ch);
2024
2234
  const size = phone ? 20 : 24;
2235
+ const own = list.find((a) => a.mine);
2236
+ const covers = (p: { x: number; y: number }): boolean => {
2237
+ if (!own) return false;
2238
+ const head = onScreen(own.x, own.y, HERO_M + own.h, cw, ch); const foot = onScreen(own.x, own.y, 0, cw, ch);
2239
+ const r = Math.max(14, Math.abs(onScreen(own.x + 0.5, own.y, 0, cw, ch).x - foot.x));
2240
+ return Math.abs(p.x - foot.x) < r + size && p.y > head.y - size && p.y < foot.y + size;
2241
+ };
2242
+ let z = onScreen(zone.x, zone.y, 0, cw, ch);
2243
+ for (const dy of [-0.72, 0.72]) { if (!covers(z)) break; z = onScreen(zone.x, zone.y + dy * zone.r, 0, cw, ch); }
2025
2244
  if (z.ok) {
2026
2245
  ctx.font = `700 ${size}px ${FONT_DISPLAY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round'; ctx.lineWidth = 4;
2027
2246
  ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.strokeText('×2', z.x, z.y); ctx.fillStyle = PAL.accent; ctx.fillText('×2', z.x, z.y);
@@ -2036,7 +2255,7 @@ function draw(t: number, dt: number): void {
2036
2255
  const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
2037
2256
  const viewAt = list.find((a) => a.mine) ?? null;
2038
2257
  for (const a of list) {
2039
- const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
2258
+ const text = a.mine && !net.watching ? `You · ${heroName(heroKind(a.slot, a.seat, a.bot))}` : label(a.name, a.bot);
2040
2259
  const head = onScreen(a.x, a.y, HERO_M + 0.12 + a.h, cw, ch);
2041
2260
  const mid = onScreen(a.x, a.y, HERO_M * 0.45 + a.h, cw, ch);
2042
2261
  const side = onScreen(a.x + R_AV, a.y, HERO_M * 0.45 + a.h, cw, ch);
@@ -2134,45 +2353,29 @@ function chip(x: number, y: number, w: number, h: number, alpha = 0.88): void {
2134
2353
  }
2135
2354
 
2136
2355
  /**
2137
- * The game's own first screen: its name and one line, on a card in the palette, for the first few seconds (it never
2138
- * waits for a press and never takes one: the round is already on underneath). game.json's name and the first sentence
2139
- * of its blurb.
2356
+ * THE ARRIVAL (NETPLAY.md section 21; createNetplay's `arrival: 'game'`). Until this game says it is playable, the play
2357
+ * page shows its arrival card: the game's title and art, and a progress line this game feeds (`net.loading`: the
2358
+ * characters first, "the heroes", then "the clearing"). It is playable once the round's state is in (a host has its
2359
+ * round; anyone else has drawn a snapshot) and the hero whose view this is stands there in its real model, drawn this
2360
+ * frame; or, when its model is still not in after about five seconds, with its stand-in. A watcher's overview waits for
2361
+ * every hero in view instead. Then `net.playable()`, once, and the hero intro starts under the lifting card.
2362
+ * The game drew its own title card at a round's start before the page had one: the arrival card is that title now, so
2363
+ * the round opens on the hero, with nothing over it. performance marks `hero:model` (your hero's real model first drawn)
2364
+ * and `hero:playable` say when, for a harness.
2140
2365
  */
2141
2366
  const BOOT_AT = performance.now();
2142
- const TITLE = String((gameFile as { name?: string }).name ?? document.title).slice(0, 40);
2143
- const TAGLINE = String((gameFile as { blurb?: string }).blurb ?? '').split(/(?<=[.!?])\s/)[0]?.slice(0, 90) ?? '';
2144
- function titleCard(cw: number, ch: number, phone: boolean): void {
2145
- // From the moment the round is on screen (the hero intro's start), short, high up: the heroes stay in view under it.
2146
- const from = introAt || (net.watching ? BOOT_AT + 1500 : 0);
2147
- if (!from) return;
2148
- const since = performance.now() - from;
2149
- // Never in the Game Lab: a take is the first seconds of a round, and the card would cover the move it plays.
2150
- if (since > 3200 || lab.on || round?.phase === 'over') return;
2151
- const a = since < 2400 ? Math.min(1, since / 250) : 1 - (since - 2400) / 800;
2152
- const w = Math.min(cw - 32, phone ? 320 : 440);
2153
- ctx.save();
2154
- ctx.globalAlpha = Math.max(0, a);
2155
- ctx.font = `700 ${phone ? 14 : 17}px ${FONT_BODY}`;
2156
- // A phone's card is the title alone: the close-up hero under it keeps the screen.
2157
- const words = phone ? [] : TAGLINE.split(' '); const lines: string[] = []; let line = '';
2158
- for (const word of words) { const next = line ? `${line} ${word}` : word; if (ctx.measureText(next).width > w - 40 && line) { lines.push(line); line = word; } else line = next; }
2159
- if (line) lines.push(line);
2160
- const titleSize = phone ? 30 : 44; const lineH = phone ? 19 : 24;
2161
- const h = (phone ? 22 : 34) + titleSize + (lines.length ? 14 + lines.length * lineH : 0) + (phone ? 14 : 26);
2162
- const x = (cw - w) / 2; const y = phone ? 64 : Math.max(64, ch * 0.08);
2163
- ctx.fillStyle = mixHex(PAPER, '#ffffff', 0.35);
2164
- ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 24; ctx.shadowOffsetY = 8;
2165
- ctx.beginPath();
2166
- const rr = (ctx as CanvasRenderingContext2D & { roundRect?: (x: number, y: number, w: number, h: number, r: number) => void }).roundRect;
2167
- if (rr) rr.call(ctx, x, y, w, h, 18); else ctx.rect(x, y, w, h);
2168
- ctx.fill();
2169
- ctx.shadowColor = 'transparent';
2170
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
2171
- ctx.fillStyle = HOT; ctx.font = `700 ${titleSize}px ${FONT_DISPLAY}`;
2172
- ctx.fillText(TITLE, cw / 2, y + (phone ? 20 : 30) + titleSize / 2);
2173
- ctx.fillStyle = TEXT; ctx.font = `600 ${phone ? 14 : 17}px ${FONT_BODY}`;
2174
- lines.forEach((l, i) => ctx.fillText(l, cw / 2, y + 30 + titleSize + 14 + lineH * i + lineH / 2));
2175
- ctx.restore();
2367
+ let playableAt = 0;
2368
+ let modelAt = 0;
2369
+ function arrival(t: number): void {
2370
+ const view = [...heroes.values()].find((h) => h.mine) ?? null;
2371
+ if (!modelAt && view?.char && !net.watching) { modelAt = t; try { performance.mark('hero:model'); } catch { /* old browser */ } }
2372
+ if (playableAt) return;
2373
+ const stateIn = Boolean(round) && (hosting || drawn.size > 0);
2374
+ const dressed = view ? Boolean(view.char) : heroes.size > 0 && [...heroes.values()].every((h) => h.char);
2375
+ if (!stateIn || (!dressed && performance.now() - BOOT_AT < 5000)) return;
2376
+ playableAt = t;
2377
+ try { performance.mark('hero:playable'); } catch { /* old browser */ }
2378
+ net.playable();
2176
2379
  }
2177
2380
 
2178
2381
  function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat: number | null; name: string; bot: boolean; mine: boolean }[]): void {
@@ -2197,14 +2400,18 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2197
2400
  const scores = new Map<number, number>();
2198
2401
  if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);
2199
2402
  else for (const d of drawn.values()) scores.set(d.slot, d.score);
2200
- // A phone's board is compact (the top three, names without " · bot": a bot's dot is hollow), so a game's own HUD at
2201
- // the top left (a basket, a timer) has room beside it.
2403
+ // A phone's board is compact (the top three, a short name and a small "bot" or "AI" tag after it; a bot's dot is
2404
+ // hollow), so a game's own HUD at the top left (a basket, a timer) has room beside it. Every bot and AI says so.
2202
2405
  const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 3 : 6);
2406
+ const TAG_FONT = `800 10px ${FONT_BODY}`;
2407
+ ctx.font = TAG_FONT;
2408
+ const marks = rows.map((a) => (!phone || (a.mine && !net.watching) ? '' : a.name.endsWith(AI_MARK) ? 'AI' : a.bot ? 'bot' : ''));
2409
+ const markW = marks.map((m) => (m ? ctx.measureText(m).width + 10 : 0));
2203
2410
  ctx.font = `700 ${phone ? 13 : 16}px ${FONT_BODY}`;
2204
2411
  const rowH = phone ? 20 : 25;
2205
2412
  const fit = (t: string, max: number): string => { let x = t; while (x.length > 3 && ctx.measureText(x).width > max) x = `${x.slice(0, -2)}…`; return x; };
2206
- const texts = rows.map((a) => (a.mine && !net.watching ? 'You' : phone ? fit(a.name.replace(AI_MARK, '').trim(), 104) : label(a.name, a.bot)));
2207
- const nameW = Math.max(0, ...texts.map((x) => ctx.measureText(x).width));
2413
+ const texts = rows.map((a, i) => (a.mine && !net.watching ? 'You' : phone ? fit(a.name.replace(AI_MARK, '').trim(), 104 - (markW[i] ? (markW[i] as number) + 5 : 0)) : label(a.name, a.bot)));
2414
+ const nameW = Math.max(0, ...texts.map((x, i) => ctx.measureText(x).width + (markW[i] ? (markW[i] as number) + 5 : 0)));
2208
2415
  const boardW = nameW + (phone ? 56 : 74);
2209
2416
  // Together: the room's total heads the board, which moves down by a row so it stays under the room buttons.
2210
2417
  const board = top + 18 + 44 - rowH / 2 + (TOGETHER ? rowH + 6 : 0);
@@ -2228,6 +2435,14 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2228
2435
  ctx.textAlign = 'left';
2229
2436
  ctx.fillStyle = a.mine ? TEXT : mixHex(TEXT, PAPER, a.bot ? 0.42 : 0.15);
2230
2437
  ctx.fillText(texts[i] as string, cw - pad - boardW + 26, y + 1);
2438
+ const mark = marks[i];
2439
+ if (mark) {
2440
+ // The tag: a small pill after the name, the HUD's ink on its paper.
2441
+ const mx = cw - pad - boardW + 26 + ctx.measureText(texts[i] as string).width + 5; const mw = markW[i] as number;
2442
+ ctx.fillStyle = mixHex(TEXT, PAPER, 0.8); ctx.beginPath(); ctx.roundRect(mx, y - 7, mw, 14, 7); ctx.fill();
2443
+ ctx.font = TAG_FONT; ctx.fillStyle = mixHex(TEXT, PAPER, 0.15); ctx.fillText(mark, mx + 5, y + 1);
2444
+ ctx.font = `700 ${phone ? 13 : 16}px ${FONT_BODY}`;
2445
+ }
2231
2446
  ctx.textAlign = 'right';
2232
2447
  ctx.fillStyle = a.mine ? HOT : TEXT;
2233
2448
  ctx.fillText(String(scores.get(a.slot) ?? 0), cw - pad - 12, y + 1);
@@ -2266,13 +2481,15 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
2266
2481
  chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
2267
2482
  ctx.fillStyle = HOT; ctx.fillText(text, cw / 2, ch - pad - 10);
2268
2483
  }
2269
- titleCard(cw, ch, phone);
2270
- if (frames < 480 && mySeat() !== null) {
2484
+ // The controls, for the first eight seconds of play (from the moment the arrival card lifts), above the touch
2485
+ // buttons when there are some, never under them.
2486
+ if (playableAt && performance.now() - playableAt < 8000 && mySeat() !== null && !net.watching) {
2271
2487
  ctx.textAlign = 'center'; ctx.font = `700 ${phone ? 14 : 16}px ${FONT_BODY}`;
2272
2488
  const text = phone ? 'Drag to move · Jump and Swing on the right' : 'WASD to move · Space jumps · F or click swings';
2273
2489
  const tw = ctx.measureText(text).width;
2274
- chip(cw / 2 - tw / 2 - 12, ch - pad - 56, tw + 24, 26, 0.6);
2275
- ctx.fillStyle = TEXT; ctx.fillText(text, cw / 2, ch - pad - 42);
2490
+ const y = ch - pad - 56 - (touched ? (phone ? 96 : 110) : 0);
2491
+ chip(cw / 2 - tw / 2 - 12, y, tw + 24, 26, 0.6);
2492
+ ctx.fillStyle = TEXT; ctx.fillText(text, cw / 2, y + 14);
2276
2493
  }
2277
2494
  ctx.textBaseline = 'alphabetic';
2278
2495
  }
@@ -2303,8 +2520,18 @@ net.expose({
2303
2520
  },
2304
2521
  frames: () => frames,
2305
2522
  scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
2306
- /** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
2307
- pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
2523
+ /**
2524
+ * Host: gems each slot picked up this round, the round's age, and how each bot plays: the room's dial (`dial`), the
2525
+ * level it plays at this round (`level`: a level easier in a newcomer's first round) and the rubber band (`soft`, 0
2526
+ * at its dial to 1 all the way soft). `newcomer`: somebody is in their first round here.
2527
+ */
2528
+ pickups: () => (hosting ? {
2529
+ round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null,
2530
+ newcomer: newcomerRound(people()),
2531
+ slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, dial: b.bot ? net.skillOf(b.slot).level : null, level: b.bot ? (botPlay.get(b.slot)?.level ?? net.skillOf(b.slot).level) : null, soft: b.bot ? +(botPlay.get(b.slot)?.soft ?? 0).toFixed(2) : null })),
2532
+ } : null),
2533
+ /** Where the coins are now (rules metres): what a harness's coin-chasing player steers by. */
2534
+ gems: () => (hosting ? gems.map((g) => ({ id: g.id, x: g.x, y: g.y })) : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }))),
2308
2535
  /** Host: when each bot last looked (its reaction clock). */
2309
2536
  sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
2310
2537
  waves: () => wavesSeen,