@homie-rocks/studio 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -1
- package/README.md +9 -5
- package/lib/perf.mjs +15 -7
- package/lib/site.mjs +14 -3
- package/lib/template-history.json +78 -0
- package/netplay/NETPLAY.md +38 -2
- package/netplay/netplay.ts +58 -3
- package/package.json +1 -1
- package/site/SITE.md +19 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/src/main.ts +336 -109
- package/worker/arrival.mjs +207 -0
- package/worker/pages.mjs +23 -5
- package/worker/site.mjs +26 -4
- package/worker/version.mjs +1 -1
|
@@ -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`);
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
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
|
-
|
|
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
|
|
650
|
-
|
|
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
|
-
/**
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1707
|
-
* player, a hero a
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
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((
|
|
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
|
|
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 ?
|
|
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.
|
|
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
|
-
//
|
|
1874
|
-
|
|
1875
|
-
|
|
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,
|
|
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)
|
|
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 ?
|
|
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
|
-
*
|
|
2138
|
-
*
|
|
2139
|
-
*
|
|
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
|
-
|
|
2143
|
-
|
|
2144
|
-
function
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
if (
|
|
2148
|
-
const
|
|
2149
|
-
|
|
2150
|
-
if (
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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,
|
|
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
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
2275
|
-
|
|
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
|
-
/**
|
|
2307
|
-
|
|
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,
|