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