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