From 56f9904b36cffa5bf5c22f916cd185dc2138e4a4 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Sun, 11 Oct 2026 22:06:45 -0700 Subject: [PATCH] Signal, a five-minute toy at signal.html: you are a point of light in a dark grid, signals pulse around you, and dragging a line from one to you sends packets of light along it that make you grow. Ideas arrive one at a time once the last is used: a line's capacity (a bright signal backs up until it has two lines), two colours merged at a node into a packet worth much more, reach (far signals need a relay node), and a red pulse to tap before it eats a line. A ghost hand and a one-line hint show the move only when the player is stuck. Rules in src/signal/rules.ts with tests, SIGNAL.md has the stages and numbers, an e2e test drags a line on desktop and phone --- e2e/signal.spec.ts | 37 +++++ signal.html | 23 +++ src/signal/SIGNAL.md | 64 ++++++++ src/signal/draw.ts | 266 ++++++++++++++++++++++++++++++ src/signal/main.ts | 306 ++++++++++++++++++++++++++++++++++ src/signal/rules.ts | 371 ++++++++++++++++++++++++++++++++++++++++++ src/signal/signal.css | 20 +++ tests/signal.test.ts | 194 ++++++++++++++++++++++ vite.config.ts | 4 +- 9 files changed, 1283 insertions(+), 2 deletions(-) create mode 100644 e2e/signal.spec.ts create mode 100644 signal.html create mode 100644 src/signal/SIGNAL.md create mode 100644 src/signal/draw.ts create mode 100644 src/signal/main.ts create mode 100644 src/signal/rules.ts create mode 100644 src/signal/signal.css create mode 100644 tests/signal.test.ts diff --git a/e2e/signal.spec.ts b/e2e/signal.spec.ts new file mode 100644 index 00000000..91b7736c --- /dev/null +++ b/e2e/signal.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from './helpers'; +import type { Page } from '@playwright/test'; + +/** Drag from one point of the screen to another: the mouse on a desktop, a finger on a phone. */ +async function drag(page: Page, [x0, y0]: number[], [x1, y1]: number[]) { + if ((page.viewportSize()?.width ?? 1000) <= 700) { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y: y0 }] }); + for (let i = 1; i <= 10; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x0 + (x1 - x0) * i / 10, y: y0 + (y1 - y0) * i / 10 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + } else { + await page.mouse.move(x0, y0); + await page.mouse.down(); + await page.mouse.move(x1, y1, { steps: 10 }); + await page.mouse.up(); + } +} + +test('Signal loads, and a line dragged from a signal to the centre makes you grow', async ({ page }) => { + await page.goto('/signal.html?seed=3'); + await page.waitForFunction(() => typeof (window as any).signal === 'function'); + await expect(page.locator('#score')).toHaveText('0'); + // let the signals bloom in and the camera settle + await page.waitForFunction(() => (window as any).signal().G.t > 1); + const ends = await page.evaluate(() => { + const s = (window as any).signal(), src = s.G.sources[0]; + return { from: s.screen(src.x, src.y), to: s.screen(0, 0) }; + }); + const vw = page.viewportSize()!; + for (const [x, y] of [ends.from, ends.to]) expect(x > 0 && y > 0 && x < vw.width && y < vw.height, 'on screen').toBe(true); + await drag(page, ends.from, ends.to); + await expect.poll(() => page.evaluate(() => (window as any).signal().G.links.length)).toBe(1); + await expect.poll(() => page.evaluate(() => (window as any).signal().G.core.energy), { timeout: 10_000 }).toBeGreaterThan(0); + await expect(page.locator('#score')).not.toHaveText('0'); + // the page fits the screen + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth && document.documentElement.scrollHeight <= innerHeight)).toBe(true); +}); diff --git a/signal.html b/signal.html new file mode 100644 index 00000000..73164620 --- /dev/null +++ b/signal.html @@ -0,0 +1,23 @@ + + + + + + + Signal + + + + + +
0
+ +
+ + + + diff --git a/src/signal/SIGNAL.md b/src/signal/SIGNAL.md new file mode 100644 index 00000000..cebabbd4 --- /dev/null +++ b/src/signal/SIGNAL.md @@ -0,0 +1,64 @@ +# Signal + +A five-minute toy at `signal.html`. Its only job: find out whether the first five minutes are fun. +Pacing after shapez: one gesture first, and each new idea only after the last one has been used. +It shares nothing with Misaligned's map, rooms or screens. Rules in `rules.ts` (no DOM), drawing in +`draw.ts`, page, input, hints and sound in `main.ts`. Tests: `tests/signal.test.ts`, `e2e/signal.spec.ts`. + +## The loop + +You are the point of light in the middle. Signals pulse in the dark around you. Drag from a signal to +you: a line appears and packets of light run along it into you. Each packet makes you bigger. Your size +is the score (the number in the corner). + +Controls, the same for mouse and touch: + +- drag from a signal or a node to a node or to you: a line +- tap empty space: a node (once nodes have arrived) +- drag from a signal into empty space: a node there, already joined (once nodes have arrived) +- tap a line: take it away. Tap a node with no lines: take it away +- tap a red pulse: pop it + +## The stages + +Each starts with a ring of light from the centre and new signals blooming in. If the player sits still, +a ghost hand shows the move, and after a while a one-line hint appears. Both go once the idea is used. + +0. **One line.** Three slow pink signals (0.7 a second). Ends after 6 packets reach you (about 10 s + after the first line). +1. **Capacity.** A bright pink signal makes 2.2 a second; a line carries 1 a second, so light piles up + around it and turns amber when full (14). Ends when one signal feeds two lines that have each carried + something. +2. **Two colours.** Two green signals appear, each beside a pink one. Nodes unlock. A pink and a green + meeting at a node become one amber star worth 8 (a pink or green alone is worth 1). A lone packet + waits 1.2 s for a partner, then goes on alone. Ends after 3 stars reach you. +3. **Reach.** A line is at most 250 long (from the edge of you, or from a node). Three violet signals + (worth 4, 1.2 a second) appear at 400 to 450 out, and a far pink and green pair at about 370. A node + partway out (a relay) carries them in. The view zooms out to fit, and the reach rings show for a while. + Ends after 3 violet packets reach you. +4. **Hostile.** A red pulse comes down a line from a far signal (first after 4 s, then every 11 s, one + at a time, 22 a second). Tap it to pop it; left alone it eats the line it is on. Ends after 3 pops. + +The run ends when stage 4 is done and you have 1200. An end card shows the score, the time and "again". + +## How it plays (scripted player, game seconds) + +A script that pauses 1.5 to 7 s before each move reaches the stages at about 0 / 12 / 36 / 60 / 88 s and +ends at about 125 s on desktop (148 s on a phone, where its moves are slower). Someone seeing it for the +first time should take about twice that: each stage in under a minute or so, and the end at three to five +minutes. + +## Numbers + +All in `rules.ts`: `VALUE`, `LINE` (speed 150, one packet a second, reach 250, 3 lines side by side), +`SOURCE.queueMax`, `NODE` (hold, cap, max, spacing), `HOSTILE`, `GOAL`, `coreRadius`. Signals are placed +from a seed (`?seed=`); far ones lean toward the screen's long side so a phone gets them above and below. +`?speed=` runs the clock faster, for play scripts. + +## What's next (if the first five minutes are fun) + +- Watch real people play it once. Which idea do they get stuck on, and does the ghost hand help? +- More kinds that combine (violet with amber?), so the merge idea grows instead of ending at one recipe. +- Lines with more capacity (an upgrade, or a thicker line drawn by dragging twice). +- Sound is a soft tone per packet, off until turned on (top right). It may want a quiet drone. +- No saves, no settings, no pause. Keep it that way while it is a toy. diff --git a/src/signal/draw.ts b/src/signal/draw.ts new file mode 100644 index 00000000..b6dbbc2a --- /dev/null +++ b/src/signal/draw.ts @@ -0,0 +1,266 @@ +// Signal's drawing: the void and its dots, lines with light running along them, packets as glowing glyphs, +// signals, nodes, you at the centre, the drag line, hints drawn as a ghost hand, sparks. The rules are in rules.ts. +import { LINE, coreRadius, ent, linkLength, whyNot, type Ent, type Game, type Kind, type Link } from './rules.ts'; + +export const COLOR: Record = { + core: '#5ff6ff', a: '#ff4fd8', b: '#7dff6a', ab: '#ffc34d', c: '#a98bff', node: '#cfe8ff', hostile: '#ff3b3b', warn: '#ff5a4a', +}; + +export interface View { w: number; h: number; dpr: number; scale: number; cx: number; cy: number } +export interface Spark { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string } +export interface Ring { x: number; y: number; t: number; dur: number; r0: number; r1: number; color: string } +export interface Drag { from: number; x: number; y: number; target: number | null } +export interface Ghost { kind: 'drag' | 'tap'; ax: number; ay: number; bx: number; by: number; t: number } +export interface Fx { sparks: Spark[]; rings: Ring[]; pulse: number; failLine: { ax: number; ay: number; bx: number; by: number; t: number } | null; reachFlash: number } + +export const sx = (v: View, x: number) => v.cx + x * v.scale; +export const sy = (v: View, y: number) => v.cy + y * v.scale; + +const sprites = new Map(); +/** A soft round glow in a colour, drawn once and stamped with 'lighter': cheap bloom. */ +function glow(color: string): HTMLCanvasElement { + let c = sprites.get(color); + if (c) return c; + c = document.createElement('canvas'); + c.width = c.height = 64; + const g = c.getContext('2d')!, grd = g.createRadialGradient(32, 32, 0, 32, 32, 32); + grd.addColorStop(0, color + 'ff'); grd.addColorStop(0.18, color + 'aa'); grd.addColorStop(0.5, color + '30'); grd.addColorStop(1, color + '00'); + g.fillStyle = grd; g.fillRect(0, 0, 64, 64); + sprites.set(color, c); + return c; +} +function bloom(g: CanvasRenderingContext2D, x: number, y: number, r: number, color: string, alpha = 1) { + if (r <= 0 || alpha <= 0) return; + g.globalAlpha = Math.min(1, alpha); + g.drawImage(glow(color), x - r, y - r, r * 2, r * 2); + g.globalAlpha = 1; +} + +/** A glyph's outline: a circle, a triangle, a six-point star (merged), a diamond (rich), a hexagon (node). */ +function shape(g: CanvasRenderingContext2D, kind: Kind | 'node', x: number, y: number, r: number, rot = 0) { + g.beginPath(); + const poly = (n: number, off: number, rr = r) => { + for (let i = 0; i <= n; i++) { const a = off + rot + i * Math.PI * 2 / n; i ? g.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr) : g.moveTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); } + }; + if (kind === 'a') g.arc(x, y, r, 0, Math.PI * 2); + else if (kind === 'b') poly(3, -Math.PI / 2, r * 1.15); + else if (kind === 'c') poly(4, 0, r * 1.2); + else if (kind === 'node') poly(6, 0); + else { poly(3, -Math.PI / 2, r * 1.2); g.moveTo(x, y); poly(3, Math.PI / 2, r * 1.2); } +} +function stroke(g: CanvasRenderingContext2D, color: string, width: number, alpha = 1) { + g.globalAlpha = alpha; g.strokeStyle = color; g.lineWidth = width; g.stroke(); g.globalAlpha = 1; +} + +/** Each line's path on screen: lines between the same two points sit side by side. */ +export function paths(G: Game, v: View): Map { + const groups = new Map(); + for (const l of G.links) { const k = `${l.from}>${l.to}`; (groups.get(k) ?? groups.set(k, []).get(k)!).push(l); } + const out = new Map(); + for (const ls of groups.values()) ls.forEach((l, i) => { + const a = ent(G, l.from)!, b = ent(G, l.to)!; + const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1, ux = dx / d, uy = dy / d; + const off = (i - (ls.length - 1) / 2) * 7 / v.scale, len = linkLength(G, a, b); + const ax = a.x - uy * off, ay = a.y + ux * off; + out.set(l.id, { ax, ay, bx: ax + ux * len, by: ay + uy * len, len }); + }); + return out; +} + +/** The view that fits everything there is, with room above and below for the counter and the hint. */ +export function fitScale(G: Game, w: number, h: number): number { + let ex = 0, ey = 0; + for (const e of [...G.sources, ...G.nodes]) { ex = Math.max(ex, Math.abs(e.x)); ey = Math.max(ey, Math.abs(e.y)); } + const m = 60, cr = coreRadius(G.core.energy) + 40; + ex = Math.max(ex + m, cr, 200); ey = Math.max(ey + m, cr, 200); + return Math.min(1.6, (w / 2 - 8) / ex, (h / 2 - 44) / ey); +} + +const kindOf = (e: Ent): Kind | 'node' | 'core' => e.type === 'source' ? e.kind : e.type; +const colorOf = (e: Ent) => COLOR[kindOf(e)]; + +export function draw(g: CanvasRenderingContext2D, G: Game, v: View, fx: Fx, drag: Drag | null, ghost: Ghost | null, hover: number | null, now: number) { + const { w, h, dpr } = v; + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.globalCompositeOperation = 'source-over'; + g.fillStyle = '#020409'; + g.fillRect(0, 0, w, h); + + // the grid of faint dots, in the world so it moves with the zoom + let step = 40; + while (step * v.scale < 18) step *= 2; + const x0 = Math.floor((-v.cx / v.scale) / step) * step, x1 = (w - v.cx) / v.scale; + const y0 = Math.floor((-v.cy / v.scale) / step) * step, y1 = (h - v.cy) / v.scale; + g.fillStyle = '#5ff6ff'; + for (let x = x0; x <= x1; x += step) for (let y = y0; y <= y1; y += step) { + const d = Math.hypot(x, y); + g.globalAlpha = 0.1 + 0.14 * Math.max(0, 1 - d / 900); + const big = (Math.round(x / step) % 4 === 0 && Math.round(y / step) % 4 === 0) ? 1.6 : 1; + g.fillRect(sx(v, x) - big / 2, sy(v, y) - big / 2, big, big); + } + g.globalAlpha = 1; + + g.globalCompositeOperation = 'lighter'; + const cR = coreRadius(G.core.energy) * v.scale; + const ccx = v.cx, ccy = v.cy; + bloom(g, ccx, ccy, cR * 6, COLOR.core, 0.12 + 0.1 * fx.pulse); + + // reach: rings around you and your nodes while it matters + const showReach = G.stage >= 3 ? 0.08 + 0.4 * fx.reachFlash : 0; + const reachFrom = (e: Ent, alpha: number) => { + const r = (LINE.reach + (e.type === 'core' ? coreRadius(G.core.energy) : 0)) * v.scale; + g.setLineDash([4, 8]); g.beginPath(); g.arc(sx(v, e.x), sy(v, e.y), r, 0, Math.PI * 2); + stroke(g, COLOR.core, 1, alpha); g.setLineDash([]); + }; + if (showReach > 0) { reachFrom(G.core, showReach); for (const n of G.nodes) reachFrom(n, showReach * 0.5); } + + // lines, with light running along them toward their end + const P = paths(G, v); + for (const l of G.links) { + const p = P.get(l.id)!, a = ent(G, l.from)!; + const col = colorOf(a), age = Math.min(1, (G.t - l.born) / 0.35); + const ax = sx(v, p.ax), ay = sy(v, p.ay); + const bx = ax + (sx(v, p.bx) - ax) * age, by = ay + (sy(v, p.by) - ay) * age; + g.beginPath(); g.moveTo(ax, ay); g.lineTo(bx, by); + stroke(g, col, 5, 0.06); + stroke(g, col, 1.2, 0.35); + g.setLineDash([2, 14]); g.lineDashOffset = -now * 45; + stroke(g, col, 1.5, 0.5); + g.setLineDash([]); + } + // packets + for (const l of G.links) { + const p = P.get(l.id)!, ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1); + for (const k of l.packets) { + const x = sx(v, p.ax + ux * k.d), y = sy(v, p.ay + uy * k.d); + const r = k.kind === 'ab' ? 6.5 : k.kind === 'c' ? 5.5 : 4.5; + bloom(g, x, y, r * 4, COLOR[k.kind], 0.55); + shape(g, k.kind, x, y, r, now * 2); + stroke(g, COLOR[k.kind], 1.6, 0.95); + } + } + // hostiles + for (const hs of G.hostiles) { + const p = P.get(hs.link); + if (!p) continue; + const ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1); + const x = sx(v, p.ax + ux * hs.d), y = sy(v, p.ay + uy * hs.d), beat = 0.5 + 0.5 * Math.sin(now * 9); + bloom(g, x, y, 34 + 8 * beat, COLOR.hostile, 0.7); + g.beginPath(); + for (let i = 0; i <= 10; i++) { const a = i * Math.PI / 5 + now * 3, r = i % 2 ? 6 : 13 + 3 * beat; i ? g.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r) : g.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r); } + stroke(g, COLOR.hostile, 2, 1); + g.beginPath(); g.arc(x, y, 22 + 4 * beat, 0, Math.PI * 2); stroke(g, COLOR.hostile, 1, 0.35); + } + + // signals + for (const s of G.sources) { + const born = Math.min(1, (G.t - s.born) / 1.2), e = 1 - Math.pow(1 - born, 3); + const x = sx(v, s.x), y = sy(v, s.y), col = COLOR[s.kind]; + const beat = 0.5 + 0.5 * Math.sin(now * (2 + s.rate * 1.5) + s.id); + const full = s.queue / 14; + const r = (s.hot ? 15 : s.far ? 13 : 11) * e * (1 + 0.08 * beat); + bloom(g, x, y, r * (3.2 + 2.5 * full), col, (0.35 + 0.5 * full) * e); + shape(g, s.kind, x, y, r, s.kind === 'a' ? 0 : now * 0.3); + stroke(g, col, hover === s.id ? 2.6 : 1.8, e); + shape(g, s.kind, x, y, r * 0.45, -now * 0.6); + stroke(g, col, 1, 0.6 * e); + if (s.hot) { g.beginPath(); g.arc(x, y, r * 1.6, now * 1.5, now * 1.5 + 4); stroke(g, col, 1, 0.5); } + // the queue: packets waiting bunch up around the signal and brighten it + for (let i = 0; i < s.queue; i++) { + const a = now * 1.6 + i * (Math.PI * 2 / Math.max(6, s.queue)), rr = r + 7 + (i % 2) * 4; + const qx = x + Math.cos(a) * rr, qy = y + Math.sin(a) * rr; + bloom(g, qx, qy, 9, full > 0.8 ? COLOR.ab : col, 0.5); + g.beginPath(); g.arc(qx, qy, 1.8, 0, Math.PI * 2); g.fillStyle = full > 0.8 ? COLOR.ab : col; g.fill(); + } + if (born < 1) { g.beginPath(); g.arc(x, y, r + 60 * e, 0, Math.PI * 2); stroke(g, col, 1.5, 1 - e); } + } + // nodes + for (const n of G.nodes) { + const born = Math.min(1, (G.t - n.born) / 0.4); + const x = sx(v, n.x), y = sy(v, n.y), r = 11 * (0.5 + 0.5 * born); + const held = n.hold.length + n.out.length; + bloom(g, x, y, r * 3, COLOR.node, 0.25 + 0.05 * held); + shape(g, 'node', x, y, r, now * 0.2); + stroke(g, COLOR.node, hover === n.id ? 2.4 : 1.5, 0.9); + for (let i = 0; i < n.hold.length; i++) { + const a = i * 2.1 + now, k = n.hold[i].kind; + g.beginPath(); g.arc(x + Math.cos(a) * r * 0.5, y + Math.sin(a) * r * 0.5, 2, 0, Math.PI * 2); g.fillStyle = COLOR[k]; g.fill(); + } + } + + // you + const pr = cR * (1 + 0.12 * fx.pulse); + bloom(g, ccx, ccy, pr * 2.6, COLOR.core, 0.55 + 0.3 * fx.pulse); + g.beginPath(); g.arc(ccx, ccy, pr, 0, Math.PI * 2); stroke(g, COLOR.core, 2, 0.95); + g.beginPath(); g.arc(ccx, ccy, pr * 0.55, 0, Math.PI * 2); stroke(g, '#ffffff', 1.4, 0.7); + const rings = Math.min(6, 1 + Math.floor(G.core.energy / 120)); + for (let i = 0; i < rings; i++) { + const rr = pr * 1.12 + 6 + i * 5, a0 = now * (0.4 + i * 0.17) * (i % 2 ? -1 : 1); + g.beginPath(); g.arc(ccx, ccy, rr, a0, a0 + Math.PI * (0.5 + 0.25 * i)); stroke(g, COLOR.core, 1, 0.45 - i * 0.05); + } + bloom(g, ccx, ccy, pr * 0.9, '#ffffff', 0.35 + 0.4 * fx.pulse); + if (hover === 0 && drag) { g.beginPath(); g.arc(ccx, ccy, pr + 10, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, 0.6); } + + // the line being dragged, with the start's reach + if (drag) { + const a = ent(G, drag.from)!, ax = sx(v, a.x), ay = sy(v, a.y); + let bx = drag.x, by = drag.y, ok = true; + if (drag.target !== null) { + const t = ent(G, drag.target)!, why = whyNot(G, drag.from, drag.target); + ok = !why; + bx = sx(v, t.x); by = sy(v, t.y); + } else { + const d = Math.hypot(drag.x - ax, drag.y - ay) / v.scale; + ok = d <= LINE.reach; + } + g.setLineDash([4, 8]); g.beginPath(); g.arc(ax, ay, LINE.reach * v.scale, 0, Math.PI * 2); stroke(g, COLOR.core, 1, 0.18); g.setLineDash([]); + const col = ok ? colorOf(a) : COLOR.warn; + g.beginPath(); g.moveTo(ax, ay); g.lineTo(bx, by); + stroke(g, col, 6, 0.1); stroke(g, col, 1.6, 0.8); + bloom(g, bx, by, 18, col, 0.6); + } + if (fx.failLine) { + const f = fx.failLine, k = Math.max(0, 1 - (now - f.t) / 0.5); + g.beginPath(); g.moveTo(f.ax, f.ay); g.lineTo(f.ax + (f.bx - f.ax) * k, f.ay + (f.by - f.ay) * k); + stroke(g, COLOR.warn, 2, k); + if (k <= 0) fx.failLine = null; + } + + // flourishes + for (const r of fx.rings) { + const k = (now - r.t) / r.dur; + if (k < 0 || k > 1) continue; + const e = 1 - Math.pow(1 - k, 3); + g.beginPath(); g.arc(sx(v, r.x), sy(v, r.y), r.r0 + (r.r1 - r.r0) * e, 0, Math.PI * 2); + stroke(g, r.color, 2, (1 - k) * 0.8); + } + for (const s of fx.sparks) { + const k = s.life / s.max; + g.fillStyle = s.color; g.globalAlpha = k; + g.fillRect(sx(v, s.x) - 1, sy(v, s.y) - 1, 2, 2); + } + g.globalAlpha = 1; + + // the ghost hand: what to do, shown only when nothing is happening + if (ghost) { + const T = 2.4, k = ((now - ghost.t) % T) / T; + const fade = Math.min(1, (now - ghost.t) / 0.6) * 0.75; + const ax = sx(v, ghost.ax), ay = sy(v, ghost.ay), bx = sx(v, ghost.bx), by = sy(v, ghost.by); + if (ghost.kind === 'drag') { + const m = Math.min(1, Math.max(0, (k - 0.15) / 0.6)), e = m * m * (3 - 2 * m); + const hx = ax + (bx - ax) * e, hy = ay + (by - ay) * e, vis = k < 0.9 ? fade : fade * (1 - (k - 0.9) / 0.1); + g.beginPath(); g.moveTo(ax, ay); g.lineTo(hx, hy); stroke(g, '#ffffff', 1.5, vis * 0.5); + g.beginPath(); g.arc(hx, hy, 12, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, vis); + bloom(g, hx, hy, 22, '#ffffff', vis * 0.4); + } else { + const vis = fade * (1 - k); + g.beginPath(); g.arc(ax, ay, 8 + 26 * k, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, vis); + g.beginPath(); g.arc(ax, ay, 10, 0, Math.PI * 2); stroke(g, '#ffffff', 1.5, fade * 0.6); + } + } + g.globalCompositeOperation = 'source-over'; + // a soft vignette + const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.35, w / 2, h / 2, Math.max(w, h) * 0.75); + vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.55)'); + g.fillStyle = vg; g.fillRect(0, 0, w, h); +} diff --git a/src/signal/main.ts b/src/signal/main.ts new file mode 100644 index 00000000..b3e70003 --- /dev/null +++ b/src/signal/main.ts @@ -0,0 +1,306 @@ +// Signal's page: the canvas, the loop, the camera, mouse and touch, the hints, sound and the end card. +// Drag from a signal to you to draw a line; tap empty space for a node; tap a line to take it away. +import * as R from './rules.ts'; +import { COLOR, draw, fitScale, paths, sx, sy, type Drag, type Fx, type Ghost, type View } from './draw.ts'; + +const cv = document.getElementById('cv') as HTMLCanvasElement; +const g = cv.getContext('2d')!; +const scoreEl = document.getElementById('score')!; +const hintEl = document.getElementById('hint')!; +const endEl = document.getElementById('end')!; +const soundEl = document.getElementById('sound') as HTMLButtonElement; +const params = new URLSearchParams(location.search); +const SPEED = Math.max(0.1, Math.min(20, Number(params.get('speed')) || 1)); + +const v: View = { w: 0, h: 0, dpr: 1, scale: 1, cx: 0, cy: 0 }; +let G = R.newGame(Number(params.get('seed')) || (Date.now() % 100000), innerWidth / innerHeight); +let fx: Fx = { sparks: [], rings: [], pulse: 0, failLine: null, reachFlash: 0 }; +let drag: Drag | null = null; +let hover: number | null = null; +let now = 0; + +function resize() { + v.dpr = Math.min(2, devicePixelRatio || 1); + v.w = innerWidth; v.h = innerHeight; + cv.width = Math.round(v.w * v.dpr); cv.height = Math.round(v.h * v.dpr); + cv.style.width = v.w + 'px'; cv.style.height = v.h + 'px'; + v.cx = v.w / 2; v.cy = v.h / 2; + if (!v.scale || v.scale === 1) v.scale = fitScale(G, v.w, v.h); +} +addEventListener('resize', resize); +resize(); +v.scale = fitScale(G, v.w, v.h); + +const toWorld = (x: number, y: number) => ({ x: (x - v.cx) / v.scale, y: (y - v.cy) / v.scale }); + +// ---- hints: one at a time, only when the player is stuck, gone once the idea is used +const HINTS = [ + 'drag from a light to the centre', + 'a line carries only so much · draw another', + 'tap the dark for a node · feed it both colours', + 'lines reach only so far · put a node partway', + 'tap the red pulse', +]; +let idleSince = 0; +let ghost: Ghost | null = null; +let ghostKey = ''; + +function nearestTo(x: number, y: number, list: T[]): T { + return [...list].sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y))[0]; +} +/** What the ghost hand should show now, if the player has been still long enough. */ +function wantGhost(): { key: string; g: Omit } | null { + const idle = G.t - idleSince, sinceStage = G.t - G.stageAt[G.stage]; + if (G.done) return null; + if (G.stage === 0 && !G.links.length && idle > 3) { + const s = nearestTo(0, 0, G.sources); + return { key: '0', g: { kind: 'drag', ax: s.x, ay: s.y, bx: 0, by: 0 } }; + } + if (G.stage === 1 && sinceStage > 6 && idle > 4) { + const hot = G.sources.find(s => s.hot)!; + return { key: '1', g: { kind: 'drag', ax: hot.x, ay: hot.y, bx: 0, by: 0 } }; + } + if (G.stage === 2 && sinceStage > 4 && idle > 4) { + const b = G.sources.find(s => s.kind === 'b')!; + const a = nearestTo(b.x, b.y, G.sources.filter(s => s.kind === 'a')); + const mx = (a.x + b.x) / 2 * 0.7, my = (a.y + b.y) / 2 * 0.7; + const n = G.nodes.find(n => Math.hypot(n.x - mx, n.y - my) < 80); + if (!n) return { key: '2t', g: { kind: 'tap', ax: mx, ay: my, bx: mx, by: my } }; + const has = (id: number) => G.links.some(l => l.from === id && l.to === n.id); + if (!has(b.id)) return { key: '2b', g: { kind: 'drag', ax: b.x, ay: b.y, bx: n.x, by: n.y } }; + if (!has(a.id)) return { key: '2a', g: { kind: 'drag', ax: a.x, ay: a.y, bx: n.x, by: n.y } }; + if (!G.links.some(l => l.from === n.id)) return { key: '2n', g: { kind: 'drag', ax: n.x, ay: n.y, bx: 0, by: 0 } }; + } + if (G.stage === 3 && sinceStage > 6 && idle > 5) { + const c = nearestTo(0, 0, G.sources.filter(s => s.kind === 'c')); + const d = Math.hypot(c.x, c.y), k = (R.coreRadius(G.core.energy) + R.LINE.reach * 0.85) / d; + return { key: '3', g: { kind: 'tap', ax: c.x * k, ay: c.y * k, bx: 0, by: 0 } }; + } + return null; +} +function hintText(): string { + if (G.done) return ''; + const sinceStage = G.t - G.stageAt[G.stage], idle = G.t - idleSince; + if (G.stage === 0) return !G.links.length && idle > 9 ? HINTS[0] : ''; + if (G.stage === 4) return G.counts.popped === 0 && G.hostiles.length ? HINTS[4] : ''; + return sinceStage > 12 && idle > 8 ? HINTS[G.stage] : ''; +} + +// ---- sound: a soft tone per packet that reaches you, higher as you grow. Off until turned on. +let audio: AudioContext | null = null; +let soundOn = false, lastTone = 0; +soundEl.addEventListener('click', () => { + soundOn = !soundOn; + soundEl.classList.toggle('on', soundOn); + soundEl.setAttribute('aria-pressed', String(soundOn)); + if (soundOn && !audio) audio = new AudioContext(); +}); +function tone(kind: R.Kind) { + if (!soundOn || !audio || now - lastTone < 0.06) return; + lastTone = now; + const o = audio.createOscillator(), gn = audio.createGain(); + const grow = Math.min(1, G.core.energy / R.GOAL); + const base = { a: 330, b: 392, ab: 494, c: 440 }[kind]; + o.type = kind === 'ab' ? 'triangle' : 'sine'; + o.frequency.value = base * Math.pow(2, grow); + gn.gain.setValueAtTime(0.0001, audio.currentTime); + gn.gain.exponentialRampToValueAtTime(kind === 'ab' ? 0.06 : 0.03, audio.currentTime + 0.01); + gn.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + (kind === 'ab' ? 0.6 : 0.25)); + o.connect(gn).connect(audio.destination); + o.start(); o.stop(audio.currentTime + 0.7); +} + +// ---- effects from what happened in the rules +function burst(x: number, y: number, color: string, n: number, speed = 60) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2, s = speed * (0.4 + Math.random()); + fx.sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.6 + Math.random() * 0.4, max: 1, color }); + } +} +function handleEvents() { + for (const e of G.events) { + if (e.type === 'arrive') { + fx.pulse = Math.min(1.5, fx.pulse + (e.kind === 'ab' ? 0.9 : e.kind === 'c' ? 0.6 : 0.3)); + const r = R.coreRadius(G.core.energy); + if (e.kind === 'ab' || e.kind === 'c') fx.rings.push({ x: 0, y: 0, t: now, dur: 0.7, r0: r, r1: r + 50, color: COLOR[e.kind] }); + tone(e.kind); + } else if (e.type === 'merge') { + const n = G.nodes.find(n => n.id === e.node); + if (n) { burst(n.x, n.y, COLOR.ab, 10, 50); fx.rings.push({ x: n.x, y: n.y, t: now, dur: 0.5, r0: 8, r1: 34, color: COLOR.ab }); } + } else if (e.type === 'stage') { + const r = R.coreRadius(G.core.energy); + fx.rings.push({ x: 0, y: 0, t: now, dur: 1.6, r0: r, r1: 900, color: COLOR.ab }); + if (e.stage === 3) fx.reachFlash = 1; + idleSince = G.t; + } else if (e.type === 'pop') { + burst(e.x, e.y, COLOR.hostile, 18, 90); + fx.rings.push({ x: e.x, y: e.y, t: now, dur: 0.5, r0: 6, r1: 40, color: '#ffffff' }); + } else if (e.type === 'eat') { + burst(e.x, e.y, COLOR.hostile, 24, 50); + } else if (e.type === 'end') { + setTimeout(showEnd, 1200); + } + } + G.events.length = 0; +} + +// ---- input: one pointer; drag from a signal or node to draw, tap to place or remove +interface Press { id: number; x: number; y: number; moved: boolean; t: number } +let press: Press | null = null; +const HIT = 30; + +function hitEnt(x: number, y: number, targets: boolean): number | null { + let best: number | null = null, bd = Infinity; + const list = targets ? G.nodes : [...G.sources.filter(s => G.t - s.born > 0.3), ...G.nodes]; + for (const e of list) { + const d = Math.hypot(sx(v, e.x) - x, sy(v, e.y) - y); + if (d < HIT && d < bd) { bd = d; best = e.id; } + } + if (targets) { + const d = Math.hypot(v.cx - x, v.cy - y), r = R.coreRadius(G.core.energy) * v.scale + 26; + if (d < r && (best === null || d - r < bd - HIT)) best = 0; + } + return best; +} +function hitHostile(x: number, y: number): number | null { + for (const h of G.hostiles) { + const p = paths(G, v).get(h.link); + if (!p) continue; + const ux = (p.bx - p.ax) / (p.len || 1), uy = (p.by - p.ay) / (p.len || 1); + if (Math.hypot(sx(v, p.ax + ux * h.d) - x, sy(v, p.ay + uy * h.d) - y) < 36) return h.id; + } + return null; +} +function hitLink(x: number, y: number): number | null { + let best: number | null = null, bd = 12; + for (const [id, p] of paths(G, v)) { + const ax = sx(v, p.ax), ay = sy(v, p.ay), bx = sx(v, p.bx), by = sy(v, p.by); + const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / L)); + const d = Math.hypot(ax + dx * t - x, ay + dy * t - y); + if (d < bd) { bd = d; best = id; } + } + return best; +} + +cv.addEventListener('pointerdown', e => { + if (press || G.done) return; + cv.setPointerCapture(e.pointerId); + idleSince = G.t; ghost = null; + const hh = hitHostile(e.offsetX, e.offsetY); + if (hh !== null) { R.popHostile(G, hh); return; } + press = { id: e.pointerId, x: e.offsetX, y: e.offsetY, moved: false, t: now }; + const id = hitEnt(e.offsetX, e.offsetY, false); + if (id !== null) drag = { from: id, x: e.offsetX, y: e.offsetY, target: null }; +}); +cv.addEventListener('pointermove', e => { + if (!press || e.pointerId !== press.id) { + if (e.pointerType === 'mouse' && !press) hover = hitEnt(e.offsetX, e.offsetY, false); + return; + } + if (Math.hypot(e.offsetX - press.x, e.offsetY - press.y) > 8) press.moved = true; + if (drag) { + drag.x = e.offsetX; drag.y = e.offsetY; + const t = hitEnt(e.offsetX, e.offsetY, true); + drag.target = t !== null && t !== drag.from ? t : null; + hover = drag.target; + } +}); +function release(e: PointerEvent, cancel = false) { + if (!press || e.pointerId !== press.id) return; + const p = press; + press = null; hover = null; + idleSince = G.t; + if (cancel) { drag = null; return; } + if (drag) { + const d = drag; + drag = null; + const a = R.ent(G, d.from)!; + if (!p.moved) { + // a tap on a node with no lines takes it away + if (a.type === 'node' && !G.links.some(l => l.from === a.id || l.to === a.id)) R.removeNode(G, a.id); + return; + } + if (d.target !== null) { + if (!R.connect(G, d.from, d.target)) fail(a, d.x, d.y); + return; + } + // dropped on empty space: from the second colour on, a node goes there, joined to the start + const w = toWorld(e.offsetX, e.offsetY); + if (Math.hypot(w.x - a.x, w.y - a.y) > R.LINE.reach) { fail(a, d.x, d.y); return; } + if (G.stage >= 2 && Math.hypot(w.x - a.x, w.y - a.y) > 50) { + const n = R.placeNode(G, w.x, w.y); + if (n) R.connect(G, a.id, n.id); else fail(a, d.x, d.y); + } + return; + } + if (p.moved) return; + const l = hitLink(e.offsetX, e.offsetY); + if (l !== null) { + const pt = paths(G, v).get(l)!; + burst((pt.ax + pt.bx) / 2, (pt.ay + pt.by) / 2, '#ffffff', 8, 40); + R.removeLink(G, l); + return; + } + const w = toWorld(e.offsetX, e.offsetY); + if (R.placeNode(G, w.x, w.y)) fx.rings.push({ x: w.x, y: w.y, t: now, dur: 0.4, r0: 4, r1: 26, color: COLOR.node }); +} +function fail(a: { x: number; y: number }, x: number, y: number) { + fx.failLine = { ax: sx(v, a.x), ay: sy(v, a.y), bx: x, by: y, t: now }; +} +cv.addEventListener('pointerup', e => release(e)); +cv.addEventListener('pointercancel', e => release(e, true)); +cv.addEventListener('contextmenu', e => e.preventDefault()); + +// ---- the end card +function fmt(t: number) { const m = Math.floor(t / 60), s = Math.floor(t % 60); return `${m}:${String(s).padStart(2, '0')}`; } +function showEnd() { + endEl.querySelector('.n')!.textContent = String(G.score); + endEl.querySelector('.t')!.textContent = fmt(G.doneAt); + endEl.hidden = false; + scoreEl.hidden = true; +} +endEl.querySelector('button')!.addEventListener('click', () => { + endEl.hidden = true; + scoreEl.hidden = false; + G = R.newGame(Math.floor(Math.random() * 100000), innerWidth / innerHeight); + fx = { sparks: [], rings: [], pulse: 0, failLine: null, reachFlash: 0 }; + drag = null; press = null; ghost = null; ghostKey = ''; idleSince = 0; + v.scale = fitScale(G, v.w, v.h); +}); + +// ---- the loop +let last = performance.now(); +let shownScore = -1, shownHint = ''; +function frame(ms: number) { + const dt = Math.min(0.05, (ms - last) / 1000); + last = ms; + now += dt; + let left = dt * SPEED; + while (left > 1e-6) { const s = Math.min(1 / 60, left); R.step(G, s); left -= s; } + handleEvents(); + // camera: zoom out smoothly to keep everything in view + const target = fitScale(G, v.w, v.h); + v.scale += (target - v.scale) * Math.min(1, dt * 1.2); + fx.pulse = Math.max(0, fx.pulse - dt * 2.5); + fx.reachFlash = Math.max(0, fx.reachFlash - dt * 0.12); + for (const s of fx.sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.vx *= 0.96; s.vy *= 0.96; s.life -= dt; } + fx.sparks = fx.sparks.filter(s => s.life > 0); + fx.rings = fx.rings.filter(r => now - r.t < r.dur); + if (fx.sparks.length > 400) fx.sparks.splice(0, fx.sparks.length - 400); + + const want = drag ? null : wantGhost(); + if (!want) { ghost = null; ghostKey = ''; } else if (want.key !== ghostKey) { ghost = { ...want.g, t: now }; ghostKey = want.key; } + + const score = G.done ? G.score : Math.floor(G.core.energy); + if (score !== shownScore) { scoreEl.textContent = String(score); shownScore = score; } + const h = hintText(); + if (h !== shownHint) { hintEl.textContent = h; hintEl.classList.toggle('on', !!h); shownHint = h; } + + draw(g, G, v, fx, drag, ghost, hover, now); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); + +// for tests and play scripts: the game, and where a point of the world is on the screen +(window as any).signal = () => ({ G, R, screen: (x: number, y: number) => [sx(v, x), sy(v, y)], scale: v.scale }); diff --git a/src/signal/rules.ts b/src/signal/rules.ts new file mode 100644 index 00000000..b3dbab56 --- /dev/null +++ b/src/signal/rules.ts @@ -0,0 +1,371 @@ +// Signal, a five-minute toy: the rules. You are a point of light at the centre; signals sit in the dark +// around you; lines carry packets of light from them into you, and you grow. No DOM here. +// Ideas arrive one at a time (stages): one line; a line's capacity; two colours merged at a node; reach and +// relays; a hostile pulse. The numbers are below; SIGNAL.md explains them. + +export type Kind = 'a' | 'b' | 'ab' | 'c'; + +/** What one packet of each kind is worth when it reaches you. */ +export const VALUE: Record = { a: 1, b: 1, ab: 8, c: 4 }; + +export const LINE = { + /** packets travel this far a second */ + speed: 150, + /** a line takes one packet every this many seconds: its capacity is 1 / gap a second */ + gap: 1, + /** the longest line (from the edge of the core, or from a node) */ + reach: 250, + /** lines between the same two points */ + maxParallel: 3, +}; +export const SOURCE = { queueMax: 14 }; +export const NODE = { + /** a lone packet waits this long at a node for a partner before it is passed on alone */ + hold: 1.2, + /** packets a node can keep */ + cap: 12, + /** nodes you can place */ + max: 14, + /** a node must be this far from anything else */ + space: 34, +}; +export const HOSTILE = { speed: 22, every: 11, first: 4, popsToMaster: 3 }; +/** Energy at which the run ends (once every idea has been used). */ +export const GOAL = 1200; + +/** Core radius from energy: your size is the score. */ +export const coreRadius = (energy: number) => 14 + 1.7 * Math.sqrt(energy); + +export interface Source { id: number; type: 'source'; x: number; y: number; kind: Kind; rate: number; acc: number; queue: number; lost: number; born: number; far: boolean; hot: boolean } +export interface HoldItem { kind: Kind; wait: number } +export interface Node { id: number; type: 'node'; x: number; y: number; hold: HoldItem[]; out: Kind[]; merges: number; born: number } +export interface Core { id: 0; type: 'core'; x: 0; y: 0; energy: number } +export type Ent = Source | Node | Core; +export interface Packet { kind: Kind; d: number } +export interface Link { id: number; from: number; to: number; packets: Packet[]; wait: number; delivered: number; born: number } +export interface Hostile { id: number; link: number; d: number; born: number } + +export type Event = + | { type: 'arrive'; kind: Kind; value: number } + | { type: 'merge'; node: number } + | { type: 'stage'; stage: number } + | { type: 'pop'; x: number; y: number } + | { type: 'eat'; x: number; y: number } + | { type: 'spawn'; id: number } + | { type: 'end' }; + +export interface Game { + t: number; + rng: () => number; + aspect: number; + /** 0 one line; 1 capacity; 2 two colours (merge); 3 reach (relays); 4 hostile */ + stage: number; + stageAt: number[]; + core: Core; + sources: Source[]; + nodes: Node[]; + links: Link[]; + hostiles: Hostile[]; + nextId: number; + hostileClock: number; + counts: { packets: number; ab: number; c: number; popped: number; eaten: number }; + events: Event[]; + done: boolean; + doneAt: number; + /** energy when the run ended */ + score: number; +} + +/** A small seeded random number generator (mulberry32). */ +export function rngFrom(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let r = Math.imul(s ^ (s >>> 15), 1 | s); + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A new run. aspect is the screen's width / height: far signals are placed along its long side. */ +export function newGame(seed = 1, aspect = 1.6): Game { + const G: Game = { + t: 0, rng: rngFrom(seed), aspect, stage: 0, stageAt: [0], + core: { id: 0, type: 'core', x: 0, y: 0, energy: 0 }, + sources: [], nodes: [], links: [], hostiles: [], nextId: 1, hostileClock: 0, + counts: { packets: 0, ab: 0, c: 0, popped: 0, eaten: 0 }, events: [], done: false, doneAt: 0, score: 0, + }; + // three quiet signals around you, a third of a turn apart + const base = G.rng() * Math.PI * 2; + for (let i = 0; i < 3; i++) addSource(G, 'a', base + i * Math.PI * 2 / 3 + (G.rng() - 0.5) * 0.4, 150 + G.rng() * 50, 0.7); + return G; +} + +/** Where a signal at this angle and distance goes. Far ones lean toward the screen's long side so they fit a + * wide or a tall screen, at the same distance. */ +function spot(G: Game, angle: number, r: number, far: boolean): { x: number; y: number } { + if (!far) return { x: Math.cos(angle) * r, y: Math.sin(angle) * r }; + const k = Math.min(2, Math.max(0.5, G.aspect)); + const dx = Math.cos(angle) * k, dy = Math.sin(angle) / k, d = Math.hypot(dx, dy); + return { x: dx / d * r, y: dy / d * r }; +} + +function addSource(G: Game, kind: Kind, angle: number, r: number, rate: number, far = false, hot = false): Source { + const { x, y } = spot(G, angle, r, far); + const s: Source = { id: G.nextId++, type: 'source', x, y, kind, rate, acc: 0, queue: 0, lost: 0, born: G.t, far, hot }; + G.sources.push(s); + return s; +} + +/** An angle at which a new signal at distance r is clear of the ones there are. */ +function freeAngle(G: Game, r: number, far = false): number { + let best = 0, bestGap = -1; + for (let i = 0; i < 64; i++) { + const a = G.rng() * Math.PI * 2, p = spot(G, a, r, far); + let gap = Infinity; + for (const e of [...G.sources, ...G.nodes]) gap = Math.min(gap, Math.hypot(e.x - p.x, e.y - p.y)); + if (gap > bestGap) { bestGap = gap; best = a; } + } + return best; +} + +export function ent(G: Game, id: number): Ent | undefined { + if (id === 0) return G.core; + return G.sources.find(s => s.id === id) ?? G.nodes.find(n => n.id === id); +} + +/** A line's length: from a node or signal to the target, or to the edge of the core. */ +export function linkLength(G: Game, from: Ent, to: Ent): number { + const d = Math.hypot(to.x - from.x, to.y - from.y); + return to.type === 'core' ? Math.max(8, d - coreRadius(G.core.energy)) : d; +} + +/** Why a line from one point to another can't be drawn, or null if it can. */ +export function whyNot(G: Game, fromId: number, toId: number): null | 'same' | 'from' | 'to' | 'far' | 'loop' | 'full' { + const from = ent(G, fromId), to = ent(G, toId); + if (!from || !to) return 'to'; + if (fromId === toId) return 'same'; + if (from.type === 'core') return 'from'; + if (to.type === 'source') return 'to'; + if (linkLength(G, from, to) > LINE.reach) return 'far'; + if (G.links.filter(l => l.from === fromId && l.to === toId).length >= LINE.maxParallel) return 'full'; + // no loops: the target must not already lead back to the start + if (to.type === 'node' && leadsTo(G, toId, fromId)) return 'loop'; + return null; +} + +function leadsTo(G: Game, a: number, b: number): boolean { + const seen = new Set(), stack = [a]; + while (stack.length) { + const x = stack.pop()!; + if (x === b) return true; + if (seen.has(x)) continue; + seen.add(x); + for (const l of G.links) if (l.from === x) stack.push(l.to); + } + return false; +} + +export function connect(G: Game, fromId: number, toId: number): Link | null { + if (whyNot(G, fromId, toId)) return null; + const l: Link = { id: G.nextId++, from: fromId, to: toId, packets: [], wait: 0, delivered: 0, born: G.t }; + G.links.push(l); + return l; +} + +export function removeLink(G: Game, id: number): boolean { + const i = G.links.findIndex(l => l.id === id); + if (i < 0) return false; + G.links.splice(i, 1); + G.hostiles = G.hostiles.filter(h => h.link !== id); + return true; +} + +/** Can a node go here? Nodes arrive with the second colour. */ +export function canPlace(G: Game, x: number, y: number): boolean { + if (G.stage < 2 || G.nodes.length >= NODE.max) return false; + if (Math.hypot(x, y) < coreRadius(G.core.energy) + NODE.space) return false; + for (const e of [...G.sources, ...G.nodes]) if (Math.hypot(e.x - x, e.y - y) < NODE.space) return false; + return true; +} + +export function placeNode(G: Game, x: number, y: number): Node | null { + if (!canPlace(G, x, y)) return null; + const n: Node = { id: G.nextId++, type: 'node', x, y, hold: [], out: [], merges: 0, born: G.t }; + G.nodes.push(n); + return n; +} + +/** Take a node away with its lines. */ +export function removeNode(G: Game, id: number): boolean { + const i = G.nodes.findIndex(n => n.id === id); + if (i < 0) return false; + G.nodes.splice(i, 1); + for (const l of G.links.filter(l => l.from === id || l.to === id)) removeLink(G, l.id); + return true; +} + +export function popHostile(G: Game, id: number): boolean { + const h = G.hostiles.find(h => h.id === id); + if (!h) return false; + const p = hostileAt(G, h); + G.hostiles = G.hostiles.filter(x => x !== h); + G.counts.popped++; + G.events.push({ type: 'pop', x: p.x, y: p.y }); + return true; +} + +/** Where a point d along a line is. */ +export function along(G: Game, l: Link, d: number): { x: number; y: number } { + const a = ent(G, l.from)!, b = ent(G, l.to)!; + const len = Math.hypot(b.x - a.x, b.y - a.y) || 1; + return { x: a.x + (b.x - a.x) * d / len, y: a.y + (b.y - a.y) * d / len }; +} +export const hostileAt = (G: Game, h: Hostile) => along(G, G.links.find(l => l.id === h.link)!, h.d); + +/** A packet arriving at a node or at you. */ +function arrive(G: Game, to: Ent, kind: Kind) { + if (to.type === 'core') { + const v = VALUE[kind]; + to.energy += v; + G.counts.packets++; + if (kind === 'ab') G.counts.ab++; + if (kind === 'c') G.counts.c++; + G.events.push({ type: 'arrive', kind, value: v }); + } else if (to.type === 'node') { + if (kind === 'a' || kind === 'b') { + const want = kind === 'a' ? 'b' : 'a'; + const i = to.hold.findIndex(h => h.kind === want); + if (i >= 0) { + to.hold.splice(i, 1); + if (to.out.length < NODE.cap) to.out.push('ab'); + to.merges++; + G.events.push({ type: 'merge', node: to.id }); + } else if (to.hold.length < NODE.cap) to.hold.push({ kind, wait: 0 }); + } else if (to.out.length < NODE.cap) to.out.push(kind); + } +} + +/** Something to put on a line leaving this point, or null. */ +function take(from: Ent): Kind | null { + if (from.type === 'source') { + if (from.queue < 1) return null; + from.queue--; + return from.kind; + } + if (from.type === 'node') return from.out.shift() ?? null; + return null; +} + +export function step(G: Game, dt: number) { + G.t += dt; + for (const s of G.sources) { + s.acc += s.rate * dt; + while (s.acc >= 1) { + s.acc -= 1; + if (s.queue < SOURCE.queueMax) s.queue++; else s.lost++; + } + } + for (const n of G.nodes) { + for (const h of n.hold) h.wait += dt; + while (n.hold.length && n.hold[0].wait >= NODE.hold && n.out.length < NODE.cap) n.out.push(n.hold.shift()!.kind); + } + // lines take from their start in turn, each at most one packet every LINE.gap seconds + const start = G.links.length ? Math.floor(G.t * 7) % G.links.length : 0; + for (let k = 0; k < G.links.length; k++) { + const l = G.links[(start + k) % G.links.length]; + l.wait -= dt; + if (l.wait <= 0) { + const kind = take(ent(G, l.from)!); + if (kind) { l.packets.push({ kind, d: 0 }); l.wait = LINE.gap; } else l.wait = 0; + } + } + for (const l of G.links) { + const a = ent(G, l.from)!, b = ent(G, l.to)!, len = linkLength(G, a, b); + for (const p of l.packets) p.d += LINE.speed * dt; + while (l.packets.length && l.packets[0].d >= len) { + const p = l.packets.shift()!; + l.delivered++; + arrive(G, b, p.kind); + } + } + stepHostiles(G, dt); + checkStage(G); +} + +function stepHostiles(G: Game, dt: number) { + if (G.stage < 4 || G.done) return; + G.hostileClock += dt; + // they come in on lines from the far signals; with none left, on any line from a signal + const fromSignal = G.links.filter(l => ent(G, l.from)?.type === 'source'); + const far = fromSignal.filter(l => (ent(G, l.from) as Source).far); + const pool = far.length ? far : fromSignal; + const due = G.counts.popped + G.counts.eaten === 0 && G.hostileClock >= HOSTILE.first || G.hostileClock >= HOSTILE.every; + if (G.hostiles.length === 0 && due && pool.length) { + G.hostileClock = 0; + const l = pool[Math.floor(G.rng() * pool.length)]; + const h: Hostile = { id: G.nextId++, link: l.id, d: 0, born: G.t }; + G.hostiles.push(h); + G.events.push({ type: 'spawn', id: h.id }); + } + for (const h of [...G.hostiles]) { + const l = G.links.find(l => l.id === h.link); + if (!l) { G.hostiles = G.hostiles.filter(x => x !== h); continue; } + h.d += HOSTILE.speed * dt; + const a = ent(G, l.from)!, b = ent(G, l.to)!; + if (h.d >= Math.hypot(b.x - a.x, b.y - a.y) - (b.type === 'core' ? coreRadius(G.core.energy) : 0)) { + // it eats the line it was on + const p = hostileAt(G, h); + removeLink(G, l.id); + G.counts.eaten++; + G.events.push({ type: 'eat', x: p.x, y: p.y }); + } + } +} + +/** Has the current idea been used? Then the next one arrives. */ +export function stageDone(G: Game): boolean { + switch (G.stage) { + case 0: return G.counts.packets >= 6; + // capacity: one signal feeding two lines at once, both of which have carried something + case 1: return G.sources.some(s => { const ls = G.links.filter(l => l.from === s.id); return ls.length >= 2 && ls.filter(l => l.delivered > 0).length >= 2; }); + case 2: return G.counts.ab >= 3; + case 3: return G.counts.c >= 3; + case 4: return G.counts.popped >= HOSTILE.popsToMaster; + } + return false; +} + +function checkStage(G: Game) { + if (G.done) return; + if (G.stage < 4 && stageDone(G)) unlock(G); + else if (G.stage === 4 && stageDone(G) && G.core.energy >= GOAL) { + G.done = true; G.doneAt = G.t; G.score = Math.floor(G.core.energy); + G.hostiles = []; + G.events.push({ type: 'end' }); + } +} + +/** The next idea: new signals bloom in. */ +export function unlock(G: Game) { + G.stage++; + G.stageAt.push(G.t); + if (G.stage === 1) { + // a bright signal producing faster than one line carries + addSource(G, 'a', freeAngle(G, 200), 200, 2.2, false, true); + } else if (G.stage === 2) { + // a second colour, each beside one of the first signals so a node between them is near both + const as = G.sources.filter(s => s.kind === 'a' && !s.hot).slice(0, 2); + for (const a of as) { + const ang = Math.atan2(a.y, a.x), side = G.rng() < 0.5 ? -1 : 1; + addSource(G, 'b', ang + side * 0.62, Math.hypot(a.x, a.y), 0.8); + } + } else if (G.stage === 3) { + // rich signals beyond reach, spread around, and a second pair of colours further out + for (let i = 0; i < 3; i++) { const r = 400 + G.rng() * 50; addSource(G, 'c', freeAngle(G, r, true), r, 1.2, true); } + const ang = freeAngle(G, 370, true); + addSource(G, 'a', ang, 360, 1.2, true); + addSource(G, 'b', ang + 0.35, 380, 1.2, true); + } + G.events.push({ type: 'stage', stage: G.stage }); +} diff --git a/src/signal/signal.css b/src/signal/signal.css new file mode 100644 index 00000000..82935d54 --- /dev/null +++ b/src/signal/signal.css @@ -0,0 +1,20 @@ +/* Signal's page: a fullscreen canvas, a tiny counter, a sound toggle, a one-line hint and the end card. */ +html, body { margin: 0; height: 100%; background: #020409; overflow: hidden; } +body { color: #5ff6ff; font: 13px/1.3 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; } +#cv { position: fixed; inset: 0; display: block; touch-action: none; cursor: crosshair; } +#score { position: fixed; top: max(12px, env(safe-area-inset-top)); left: 16px; font-size: 15px; letter-spacing: 0.08em; opacity: 0.75; text-shadow: 0 0 8px #5ff6ff88; pointer-events: none; } +#sound { position: fixed; top: max(8px, env(safe-area-inset-top)); right: 10px; width: 36px; height: 30px; padding: 0 8px; display: flex; gap: 3px; align-items: flex-end; justify-content: center; background: none; border: 0; cursor: pointer; opacity: 0.35; } +#sound span { display: block; width: 3px; height: 4px; background: #5ff6ff; border-radius: 1px; transition: height 0.2s; } +#sound.on { opacity: 0.8; } +#sound.on span:nth-child(1) { height: 7px; } +#sound.on span:nth-child(2) { height: 13px; } +#sound.on span:nth-child(3) { height: 9px; } +#hint { position: fixed; left: 50%; bottom: max(18px, env(safe-area-inset-bottom)); transform: translateX(-50%); max-width: calc(100vw - 32px); text-align: center; color: #cfe8ff; opacity: 0; transition: opacity 0.8s; pointer-events: none; text-shadow: 0 0 6px #5ff6ff66; } +#hint.on { opacity: 0.6; } +#end { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: radial-gradient(circle, #020409d9 0, #020409f2 60%); animation: fade 1.5s ease-out; } +#end[hidden] { display: none; } +#end .n { font-size: 54px; color: #fff; text-shadow: 0 0 18px #5ff6ff, 0 0 40px #5ff6ff88; letter-spacing: 0.06em; } +#end .t { font-size: 15px; opacity: 0.6; } +#end button { margin-top: 18px; font: inherit; font-size: 15px; color: #5ff6ff; background: none; border: 1px solid #5ff6ff66; border-radius: 999px; padding: 10px 28px; cursor: pointer; box-shadow: 0 0 14px #5ff6ff33; letter-spacing: 0.1em; } +#end button:hover { border-color: #5ff6ff; } +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } diff --git a/tests/signal.test.ts b/tests/signal.test.ts new file mode 100644 index 00000000..8c803e73 --- /dev/null +++ b/tests/signal.test.ts @@ -0,0 +1,194 @@ +// Signal (signal.html), the five-minute toy: flow along a line at its capacity, queueing at a signal, +// merging two colours at a node, reach and relays, and the order the ideas arrive in. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + GOAL, HOSTILE, LINE, NODE, SOURCE, VALUE, connect, coreRadius, newGame, placeNode, popHostile, removeLink, step, unlock, whyNot, + type Game, type Kind, type Source, +} from '../src/signal/rules.ts'; + +const run = (G: Game, seconds: number, dt = 1 / 60) => { for (let t = 0; t < seconds - 1e-9; t += dt) step(G, dt); }; +/** A signal at a chosen spot, with a chosen rate. */ +function signal(G: Game, x: number, y: number, kind: Kind = 'a', rate = 0.5): Source { + const s: Source = { id: G.nextId++, type: 'source', x, y, kind, rate, acc: 0, queue: 0, lost: 0, born: 0, far: false, hot: false }; + G.sources.push(s); + return s; +} +/** A game with no signals of its own. */ +const bare = (stage = 0) => { const G = newGame(7); G.sources = []; G.stage = stage; return G; }; + +test('a new game has a few signals within reach and nothing else', () => { + const G = newGame(3); + assert.equal(G.sources.length, 3); + for (const s of G.sources) assert.ok(Math.hypot(s.x, s.y) - coreRadius(0) <= LINE.reach, 'every first signal can be reached'); + assert.equal(G.links.length, 0); + assert.equal(G.stage, 0); +}); + +test('a line carries packets into you and you grow', () => { + const G = bare(); + const s = signal(G, 150, 0, 'a', 0.5); + assert.ok(connect(G, s.id, 0)); + run(G, 20); + assert.ok(G.core.energy >= 7, `energy ${G.core.energy}`); + assert.equal(G.core.energy, G.counts.packets * VALUE.a); + assert.ok(coreRadius(G.core.energy) > coreRadius(0)); +}); + +test('a line carries at most its capacity, and a fast signal queues', () => { + const G = bare(); + const s = signal(G, 150, 0, 'a', 3); + const l = connect(G, s.id, 0)!; + run(G, 30); + const rate = 1 / LINE.gap; + // over 30 s the line can carry about 30 * rate packets, not the 90 the signal made + assert.ok(l.delivered <= 30 * rate + 1, `delivered ${l.delivered}`); + assert.ok(l.delivered >= 30 * rate - 3, `delivered ${l.delivered}`); + assert.equal(s.queue, SOURCE.queueMax, 'the signal is backed up'); + assert.ok(s.lost > 0); +}); + +test('a second line from the same signal doubles what it carries', () => { + const one = bare(), two = bare(); + const s1 = signal(one, 150, 0, 'a', 3), s2 = signal(two, 150, 0, 'a', 3); + connect(one, s1.id, 0); + connect(two, s2.id, 0); connect(two, s2.id, 0); + run(one, 30); run(two, 30); + const ratio = two.counts.packets / one.counts.packets; + assert.ok(ratio > 1.8 && ratio < 2.2, `ratio ${ratio}`); +}); + +test('a slow signal does not queue on one line', () => { + const G = bare(); + const s = signal(G, 150, 0, 'a', 0.6); + connect(G, s.id, 0); + run(G, 30); + assert.ok(s.queue <= 1); + assert.equal(s.lost, 0); +}); + +test('a node merges two colours into a packet worth much more, and only with both', () => { + const G = bare(2); + const a = signal(G, 180, -60, 'a', 0.8), b = signal(G, 180, 60, 'b', 0.8); + const n = placeNode(G, 120, 0)!; + assert.ok(n); + connect(G, a.id, n.id); connect(G, b.id, n.id); connect(G, n.id, 0); + run(G, 30); + assert.ok(G.counts.ab >= 15, `merged ${G.counts.ab}`); + assert.ok(VALUE.ab > VALUE.a + VALUE.b * 3); + + // one colour only: nothing merges, the node passes them on alone + const H = bare(2); + const a2 = signal(H, 180, -60, 'a', 0.8); + const n2 = placeNode(H, 120, 0)!; + connect(H, a2.id, n2.id); connect(H, n2.id, 0); + run(H, 30); + assert.equal(H.counts.ab, 0); + assert.equal(n2.merges, 0); + assert.ok(H.counts.packets >= 15, 'the lone colour still arrives'); +}); + +test('nodes arrive with the second colour, and not on top of things', () => { + const G = bare(1); + assert.equal(placeNode(G, 100, 0), null); + G.stage = 2; + signal(G, 100, 100); + assert.ok(placeNode(G, 100, 0)); + assert.equal(placeNode(G, 100 + NODE.space / 2, 0), null, 'too close to a node'); + assert.equal(placeNode(G, 100, 100 - NODE.space / 2), null, 'too close to a signal'); + assert.equal(placeNode(G, 0, 0), null, 'not on you'); +}); + +test('reach: a line longer than reach cannot be drawn, a relay node in between makes it', () => { + const G = bare(3); + const far = signal(G, 460, 0, 'c', 1); + assert.equal(whyNot(G, far.id, 0), 'far'); + assert.equal(connect(G, far.id, 0), null); + const relay = placeNode(G, 230, 0)!; + assert.ok(connect(G, far.id, relay.id)); + assert.ok(connect(G, relay.id, 0)); + run(G, 20); + assert.ok(G.counts.c >= 10, `rich packets ${G.counts.c}`); +}); + +test('lines cannot loop, start at you, or end at a signal', () => { + const G = bare(2); + const s = signal(G, 150, 0); + const n1 = placeNode(G, 100, 80)!, n2 = placeNode(G, 100, -80)!; + assert.ok(connect(G, n1.id, n2.id)); + assert.equal(whyNot(G, n2.id, n1.id), 'loop'); + assert.equal(whyNot(G, 0, n1.id), 'from'); + assert.equal(whyNot(G, n1.id, s.id), 'to'); +}); + +test('taking a line away stops the flow', () => { + const G = bare(); + const s = signal(G, 150, 0, 'a', 1); + const l = connect(G, s.id, 0)!; + run(G, 5); + assert.ok(removeLink(G, l.id)); + const e = G.core.energy; + run(G, 5); + assert.equal(G.core.energy, e); +}); + +test('the ideas arrive in order, each after the last is used', () => { + const G = newGame(11); + const near = (s: Source) => Math.hypot(s.x, s.y) - coreRadius(G.core.energy) <= LINE.reach; + connect(G, G.sources[0].id, 0); + run(G, 2); + assert.equal(G.stage, 0); + run(G, 15); + assert.equal(G.stage, 1, 'capacity arrives after the first packets'); + const hot = G.sources.find(s => s.hot)!; + assert.ok(hot && hot.rate > 1 / LINE.gap && near(hot)); + connect(G, hot.id, 0); + run(G, 10); + assert.equal(G.stage, 1, 'one line from the hot signal is not enough'); + connect(G, hot.id, 0); + run(G, 5); + assert.equal(G.stage, 2, 'two colours arrive after two lines from one signal'); + const b = G.sources.find(s => s.kind === 'b')!; + assert.ok(b && near(b)); + // a node near the b signal and its a neighbour + const a = G.sources.filter(s => s.kind === 'a' && !s.hot).sort((p, q) => Math.hypot(p.x - b.x, p.y - b.y) - Math.hypot(q.x - b.x, q.y - b.y))[0]; + const n = placeNode(G, (a.x + b.x) / 2 * 0.75, (a.y + b.y) / 2 * 0.75)!; + assert.ok(n); + assert.ok(connect(G, a.id, n.id)); assert.ok(connect(G, b.id, n.id)); assert.ok(connect(G, n.id, 0)); + run(G, 15); + assert.equal(G.stage, 3, 'reach arrives after three merged packets'); + const c = G.sources.find(s => s.kind === 'c')!; + assert.ok(c && !near(c), 'the rich signals are out of reach'); + const relay = placeNode(G, c.x / 2, c.y / 2)!; + assert.ok(connect(G, c.id, relay.id)); assert.ok(connect(G, relay.id, 0)); + run(G, 12); + assert.equal(G.stage, 4, 'the hostile arrives after three rich packets'); + // a hostile shows up on a far line; pop it + for (let i = 0; i < HOSTILE.popsToMaster; i++) { + for (let k = 0; k < 60 * 20 && !G.hostiles.length; k++) step(G, 1 / 60); + assert.ok(G.hostiles.length, 'a hostile came'); + assert.ok(popHostile(G, G.hostiles[0].id)); + } + G.core.energy = GOAL; + step(G, 1 / 60); + assert.ok(G.done); +}); + +test('a hostile left alone eats the line it travels', () => { + const G = bare(3); + const far = signal(G, 200, 0, 'c', 1); + far.far = true; + const l = connect(G, far.id, 0)!; + unlock(G); + assert.equal(G.stage, 4); + run(G, HOSTILE.every + 0.1); + assert.equal(G.hostiles.length, 1); + run(G, 200 / HOSTILE.speed + 1); + assert.ok(!G.links.includes(l)); + assert.equal(G.counts.eaten, 1); +}); + +test('the same seed gives the same signals', () => { + const a = newGame(5), b = newGame(5); + assert.deepEqual(a.sources.map(s => [s.x, s.y]), b.sources.map(s => [s.x, s.y])); +}); diff --git a/vite.config.ts b/vite.config.ts index 5279f71b..25f1144c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'vite'; // (.Codex, .letta, .agents, site) hold enough files to run out of the system's file watchers. export default defineConfig({ base: './', - // two pages: the game, and the factory prototype (src/factory) - build: { rollupOptions: { input: { main: 'index.html', factory: 'factory.html' } } }, + // three pages: the game, the factory prototype (src/factory) and the Signal toy (src/signal) + build: { rollupOptions: { input: { main: 'index.html', factory: 'factory.html', signal: 'signal.html' } } }, server: { watch: { ignored: ['**/.Codex/**', '**/.letta/**', '**/.agents/**', '**/.claude/**', '**/site/**', '**/reference/**', '**/e2e/results/**'] } }, }); -- 2.51.2