diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5e462ea4..57181a3e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { Suspense, useEffect, useLayoutEffect, useState, type ReactNode } from "react"; import { Link, Redirect, Route, Switch, useLocation, useSearch } from "wouter"; +import { Signpost } from "lucide-react"; import { LoginPage } from "./auth/Login"; import { SignupPage, VerifyEmailPage } from "./auth/Signup"; import { ForgotPasswordPage, ResetPasswordPage } from "./auth/ForgotPassword"; @@ -7,9 +8,9 @@ import { InvitePage, OnboardPage } from "./auth/InviteOnboard"; import { PrivacyPage, TermsPage } from "./legal/LegalPages"; import { useApp } from "./state"; import { forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav"; -import { AuthLayout } from "./auth/AuthLayout"; import { PendingShell } from "./shell/PendingShell"; import { ConnectionRecovery } from "./shell/ConnectionRecovery"; +import { StreetScene } from "./shell/StreetScene"; import { lazyComponent } from "./shell/lazyComponent"; import { usePageTitle } from "./shell/pageTitle"; @@ -128,13 +129,12 @@ function SignInFirst({ requested }: { requested: string | null }) { function NotFoundPage() { return ( - -
-

This page isn’t here.

-

The link may be mistyped, or the page may have moved.

- Go to Home -
-
+ + ); } diff --git a/apps/web/src/shell/ConnectionRecovery.tsx b/apps/web/src/shell/ConnectionRecovery.tsx index 74222fa6..8988fce6 100644 --- a/apps/web/src/shell/ConnectionRecovery.tsx +++ b/apps/web/src/shell/ConnectionRecovery.tsx @@ -1,11 +1,14 @@ import { useEffect, useRef, useState } from "react"; import { WifiOff } from "lucide-react"; -import "./connection-recovery.css"; +import { StreetScene } from "./StreetScene"; export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise; signOut: () => Promise }) { const [offline, setOffline] = useState(() => !navigator.onLine); const [busy, setBusy] = useState(false); const [attempted, setAttempted] = useState(false); + // The connection came back during a run: say so, and let the run finish. + const [backOnline, setBackOnline] = useState(false); + const playing = useRef(false); const inFlight = useRef(false); const retryRef = useRef(retry); retryRef.current = retry; @@ -15,6 +18,7 @@ export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise Promise { - const online = () => { setOffline(false); void reconnect(); }; - const offline = () => setOffline(true); + const online = () => { + setOffline(false); + if (playing.current) setBackOnline(true); else void reconnect(); + }; + const offline = () => { setOffline(true); setBackOnline(false); }; window.addEventListener("online", online); window.addEventListener("offline", offline); return () => { @@ -34,18 +41,17 @@ export function ConnectionRecovery({ retry, signOut }: { retry: () => Promise - commune. -
-
- ; +
{attempted && !busy ? "Still unable to connect. Please try again shortly." : ""}
+ + ; } diff --git a/apps/web/src/shell/StreetScene.tsx b/apps/web/src/shell/StreetScene.tsx new file mode 100644 index 00000000..de545725 --- /dev/null +++ b/apps/web/src/shell/StreetScene.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { advance, BEST_KEY, CAUSES, drawStreet, jump, layoutFor, metres, newRun, readPalette, release, STREET_UNITS, UNIT, type Hole, type Layout, type Pose } from "./streetRunner"; +import "./street-scene.css"; + +const readBest = () => { try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch { return 0; } }; +const saveBest = (best: number) => { try { localStorage.setItem(BEST_KEY, String(best)); } catch { /* private window */ } }; +const JUMP_KEYS = [" ", "ArrowUp", "w", "W"]; +// Room between the message and the roofs for the hint. +const HINT_ROOM = 64; + +/** + * A street that runs edge to edge across the top of a recovery screen, with + * its message (children) in the sky. Tapping the street, Space or the up arrow + * plays Street runner; it needs no network and plays no sound. A run pauses + * whenever the page is hidden or loses focus. + */ +export function StreetScene({ label, children, onPlayingChange }: { label: string; children: ReactNode; onPlayingChange?: (playing: boolean) => void }) { + const sceneRef = useRef(null); + const canvasRef = useRef(null); + const messageRef = useRef(null); + const hintRef = useRef(null); + const run = useRef(newRun()); + const pose = useRef("ready"); + const layout = useRef(layoutFor(720, 480)); + const hole = useRef(null); + const frame = useRef(0); + const last = useRef(0); + const endedAt = useRef(0); + const [shown, setShown] = useState("ready"); + const [distance, setDistance] = useState(0); + const [best, setBest] = useState(readBest); + const [hint, setHint] = useState("Press Space or tap the street to run"); + const [announcement, setAnnouncement] = useState(""); + const playingChange = useRef(onPlayingChange); + playingChange.current = onPlayingChange; + + const draw = useCallback(() => { + const scene = sceneRef.current, ctx = canvasRef.current?.getContext("2d"); + if (!scene || !ctx) return; + drawStreet(ctx, layout.current, run.current, pose.current, readPalette(scene), hole.current); + }, []); + + const setPose = useCallback((next: Pose) => { + const wasPlaying = pose.current === "running" || pose.current === "paused"; + pose.current = next; + setShown(next); + const playing = next === "running" || next === "paused"; + if (playing !== wasPlaying) playingChange.current?.(playing); + }, []); + + // Size the canvas to the scene, keeping clear sky under the message. + const fit = useCallback(() => { + const scene = sceneRef.current, canvas = canvasRef.current, message = messageRef.current; + if (!scene || !canvas || !message) return; + const s = scene.getBoundingClientRect(), m = message.getBoundingClientRect(); + const need = Math.ceil(m.bottom - s.top + HINT_ROOM + STREET_UNITS * UNIT); + if (scene.style.minHeight !== `${need}px`) scene.style.minHeight = `${need}px`; + const next = layoutFor(scene.clientWidth, scene.clientHeight); + layout.current = next; + hole.current = { x: Math.floor((m.left - s.left - 8) / UNIT), y: Math.floor((m.top - s.top - 8) / UNIT), w: Math.ceil((m.width + 16) / UNIT), h: Math.ceil((m.height + 16) / UNIT) }; + canvas.width = next.width * UNIT; + canvas.height = next.height * UNIT; + canvas.style.width = `${next.width * UNIT}px`; + canvas.style.height = `${next.height * UNIT}px`; + const hintEl = hintRef.current; + if (hintEl) hintEl.style.top = `${Math.max(0, (next.row - 46) * UNIT - hintEl.offsetHeight - 10)}px`; + draw(); + }, [draw]); + + const step = useCallback((time: number) => { + if (pose.current !== "running") return; + const dt = Math.min((time - last.current) / 1000, 0.05); + last.current = time; + const hit = advance(run.current, layout.current, dt); + const m = metres(run.current.distance); + setDistance(m); + if (hit) { + endedAt.current = performance.now(); + setPose("over"); + const record = m > readBest(); + if (record) { saveBest(m); setBest(m); } + const said = `${CAUSES[hit]} at ${m} m.${record && m > 0 ? " A new best." : ""}`; + setHint(`${said} Space or tap to run again.`); + setAnnouncement(said); + draw(); + return; + } + draw(); + frame.current = requestAnimationFrame(step); + }, [draw, setPose]); + + const start = useCallback(() => { + cancelAnimationFrame(frame.current); + run.current = newRun(); + last.current = performance.now(); + setDistance(0); + setHint(""); + setAnnouncement("Running. Press Space to jump."); + setPose("running"); + frame.current = requestAnimationFrame(step); + }, [setPose, step]); + + const pause = useCallback(() => { + if (pose.current !== "running") return; + cancelAnimationFrame(frame.current); + setPose("paused"); + setHint("Paused. Space or tap to carry on."); + setAnnouncement("Paused."); + draw(); + }, [draw, setPose]); + + const resume = useCallback(() => { + if (pose.current !== "paused") return; + last.current = performance.now(); + setHint(""); + setPose("running"); + frame.current = requestAnimationFrame(step); + }, [setPose, step]); + + const press = useCallback(() => { + const now = pose.current; + if (now === "ready" || (now === "over" && performance.now() - endedAt.current > 450)) start(); + else if (now === "paused") resume(); + else if (now === "running") jump(run.current); + }, [resume, start]); + + useLayoutEffect(() => { + fit(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => fit()); + if (sceneRef.current) observer.observe(sceneRef.current); + if (messageRef.current) observer.observe(messageRef.current); + return () => observer.disconnect(); + }, [fit]); + + // The hint moves with the roofs when its text changes length. + useLayoutEffect(() => { fit(); }, [hint, fit]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const onControl = event.target instanceof Element && event.target.closest("button, a, input, textarea, select"); + if (JUMP_KEYS.includes(event.key) && !onControl) { + event.preventDefault(); + if (!event.repeat) press(); + } else if (["Escape", "p", "P"].includes(event.key)) { + if (pose.current === "running") pause(); else if (pose.current === "paused") resume(); + } + }; + const onKeyUp = (event: KeyboardEvent) => { if (JUMP_KEYS.includes(event.key)) release(run.current); }; + const onHidden = () => { if (document.hidden) pause(); }; + document.addEventListener("keydown", onKey); + document.addEventListener("keyup", onKeyUp); + document.addEventListener("visibilitychange", onHidden); + window.addEventListener("blur", pause); + return () => { + document.removeEventListener("keydown", onKey); + document.removeEventListener("keyup", onKeyUp); + document.removeEventListener("visibilitychange", onHidden); + window.removeEventListener("blur", pause); + cancelAnimationFrame(frame.current); + }; + }, [press, pause, resume]); + + const playing = shown === "running" || shown === "paused"; + return
+
{ + if ((event.target as Element).closest("button, a, .c-street-message")) return; + event.preventDefault(); + press(); + }} + onPointerUp={() => release(run.current)} + > +
+
+

Space, ↑ or a tap to jump. Let go early for a small hop. Stay low when a pigeon swoops.

+

No sound. Playing uses no data and doesn’t touch your account.

+
+

{announcement}

+
; +} diff --git a/apps/web/src/shell/street-scene.css b/apps/web/src/shell/street-scene.css new file mode 100644 index 00000000..9ff370ee --- /dev/null +++ b/apps/web/src/shell/street-scene.css @@ -0,0 +1,97 @@ +/* The connection and missing-page screens: a street scene edge to edge + across the top, its message in the sky (see StreetScene.tsx). In the + screens layer, like the other recovery screens. */ +@layer screens { +.c-street { + min-height: 100dvh; + background: var(--paper); + color: var(--ink); + /* The scene's colours, read by the canvas. */ + --street-sky: #c6e3f2; + --street-cloud: #f5fbff; + --street-sun: #e4c477; + --street-house: #faf5e9; + --street-house-alt: #efe3cf; + --street-roof: #687c6e; + --street-window: #c8d9d8; + --street-frame: var(--street-house); + --street-door: var(--sage); + --street-wood: #bb9768; + --street-leaf: #6d9877; + --street-pave: #ddd6c8; + --street-kerb: #b8b0a0; + --street-joint: #cfc7b7; + --street-bin: #4a5560; + --street-bin-lid: #323a44; + --street-puddle: #9fc1d3; + --street-skin: var(--street-wood); + --street-hair: var(--navy); + --street-top: var(--sage); + --street-legs: var(--navy); + --street-bird: var(--ink-soft); + --street-brick: #c68055; + --street-lamp: #5a6660; + --street-light: #f4e3a1; + --street-box: #4f7a63; + --street-glass: var(--sage-soft); + /* Text on the sky: the secondary grey is darkened to stay readable there. */ + --street-ink-soft: #3f4c5e; + --street-chip: rgba(250, 245, 233, 0.92); +} +.c-street-scene { + position: relative; + overflow: hidden; + height: clamp(440px, 58dvh, 700px); + background: var(--street-sky); + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + user-select: none; + -webkit-user-select: none; +} +.c-street-canvas { position: absolute; left: 0; bottom: 0; display: block; image-rendering: pixelated; } +.c-street-top { + position: absolute; inset: 0 0 auto 0; + display: flex; justify-content: space-between; align-items: center; gap: 16px; + padding: 22px clamp(16px, 5vw, 56px); +} +.c-street-top .a-wordmark { color: var(--navy); font-size: 30px; } +.c-street-score { display: flex; align-items: center; gap: 14px; font-size: 13px; color: var(--street-ink-soft); font-variant-numeric: tabular-nums; } +.c-street-score b { color: var(--ink); font-weight: 550; } +.c-street-pause { + height: 32px; padding: 0 10px; border-radius: var(--radius-control); + border: 1px solid var(--border-strong); background: var(--street-chip); color: var(--ink); + font: inherit; font-size: 12.5px; font-weight: 550; cursor: pointer; +} +.c-street-message { + position: absolute; + left: clamp(16px, 5vw, 56px); + top: clamp(80px, 13dvh, 128px); + width: min(420px, calc(100% - 32px)); + cursor: auto; + user-select: text; + -webkit-user-select: text; +} +.c-street-icon { color: var(--street-ink-soft); display: block; margin-bottom: 14px; } +.c-street-message h1 { font-size: clamp(22px, 2.4vw, 28px); line-height: 1.2; font-weight: 550; letter-spacing: -.5px; margin: 0 0 10px; color: var(--ink); text-wrap: balance; } +.c-street-message p { font-size: 14px; line-height: 1.6; color: var(--street-ink-soft); margin: 0 0 20px; max-width: 38ch; } +.c-street-message .a-btn { border-radius: var(--radius-control); min-height: 40px; text-decoration: none; } +.c-street-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.c-street-feedback { min-height: 22px; padding-top: 10px; font-size: 12px; line-height: 1.5; color: var(--street-ink-soft); } +.c-street-hint { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); + width: max-content; max-width: calc(100% - 32px); margin: 0; + padding: 7px 12px; border-radius: var(--radius-control); + background: var(--street-chip); color: var(--ink); + font-size: 13px; line-height: 1.4; text-align: center; pointer-events: none; +} +.c-street-help { max-width: 640px; margin: 0 auto; padding: 28px 16px; display: grid; gap: 6px; text-align: center; font-size: 13px; color: var(--ink-soft); } +.c-street-help p { margin: 0; } +@media (max-width: 600px) { + .c-street-scene { height: clamp(480px, 64dvh, 640px); } + .c-street-top { padding-top: 18px; } + .c-street-top .a-wordmark { font-size: 26px; } + .c-street-message { top: 66px; } + .c-street-icon { display: none; } +} +} diff --git a/apps/web/src/shell/streetRunner.ts b/apps/web/src/shell/streetRunner.ts new file mode 100644 index 00000000..f8066420 --- /dev/null +++ b/apps/web/src/shell/streetRunner.ts @@ -0,0 +1,195 @@ +// Street runner: the small game on the connection and missing-page screens. +// Everything here runs without a network. Sizes and positions are in scene +// units; one unit is UNIT CSS pixels, drawn as a solid block. + +export const UNIT = 3; +/** The houses, front walls and pavement at the bottom of the scene, in units. */ +export const STREET_UNITS = 78; +/** The run's best distance on this device. Nothing else is stored. */ +export const BEST_KEY = "commune.streetRunnerBest"; + +export type Obstacle = { type: "bin" | "bins" | "puddle" | "box" | "bird"; x: number; w: number; h: number; extra: number }; +export type Run = { + distance: number; + speed: number; + /** Height of the runner's feet above the pavement. */ + lift: number; + rise: number; + obstacles: Obstacle[]; + gap: number; + time: number; +}; +export type Layout = { width: number; height: number; ground: number; row: number; runnerX: number }; + +const SIZES: Record = { bin: [9, 14], bins: [19, 14], box: [12, 12], puddle: [16, 0], bird: [11, 6] }; +export const CAUSES: Record = { + bin: "Walked into a wheelie bin", + bins: "Two bins was one too many", + puddle: "Splashed through a puddle", + box: "Tripped on the recycling", + bird: "Bumped into a pigeon", +}; +const JUMP = 158, HOP = 70, GRAVITY = 560; + +export const metres = (distance: number) => Math.floor(distance / 10); + +export function layoutFor(widthPx: number, heightPx: number): Layout { + const width = Math.ceil(widthPx / UNIT), height = Math.ceil(heightPx / UNIT); + const ground = height - 14; + return { width, height, ground, row: ground - 20, runnerX: Math.max(22, Math.min(48, Math.round(width * 0.1))) }; +} + +export function newRun(): Run { + return { distance: 0, speed: 70, lift: 0, rise: 0, obstacles: [], gap: 110, time: 0 }; +} + +export function jump(run: Run) { + if (run.lift === 0 && run.rise === 0) run.rise = JUMP; +} + +/** Letting go early cuts the jump short, for a small hop. */ +export function release(run: Run) { + if (run.rise > HOP) run.rise = HOP; +} + +function spawn(run: Run, layout: Layout, random: () => number) { + const pool: Obstacle["type"][] = ["bin", "bin", "puddle", "box"]; + if (run.speed > 92) pool.push("bins"); + if (metres(run.distance) > 220) pool.push("bird", "bird"); + const type = pool[Math.floor(random() * pool.length)]; + const [w, h] = SIZES[type]; + run.obstacles.push({ type, w, h, x: layout.width + 6, extra: type === "bird" ? 22 : 0 }); + // Always room to react at the current speed, plus a little more after two bins. + run.gap = run.speed * 0.62 + w + 26 + random() * 70 + (type === "bins" ? 24 : 0); +} + +export function hits(run: Run, layout: Layout, o: Obstacle): boolean { + const left = layout.runnerX + 2, right = layout.runnerX + 8; + const top = layout.ground - 15 - run.lift, bottom = layout.ground - run.lift; + if (o.x + o.w < left || o.x > right) return false; + if (o.type === "puddle") return run.lift < 0.5 && o.x + 3 < right && o.x + o.w - 3 > left; + // Pigeons fly just above head height: running under them is safe, jumping is not. + if (o.type === "bird") { const y = layout.ground - 24; return top < y + 6 && bottom > y + 1; } + return bottom > layout.ground - o.h + 1; +} + +/** Moves the run on by dt seconds. Returns what the runner hit, if anything. */ +export function advance(run: Run, layout: Layout, dt: number, random: () => number = Math.random): Obstacle["type"] | null { + run.time += dt; + run.speed = Math.min(170, 70 + run.distance * 0.012); + const dx = run.speed * dt; + run.distance += dx; + run.gap -= dx; + if (run.lift > 0 || run.rise > 0) { + run.lift += run.rise * dt; + run.rise -= GRAVITY * dt; + if (run.lift <= 0) { run.lift = 0; run.rise = 0; } + } + for (const o of run.obstacles) o.x -= dx + o.extra * dt; + run.obstacles = run.obstacles.filter((o) => o.x + o.w > -10); + if (run.gap <= 0) spawn(run, layout, random); + return run.obstacles.find((o) => hits(run, layout, o))?.type ?? null; +} + +export const PALETTE = [ + "sky", "cloud", "sun", "house", "house-alt", "roof", "window", "frame", "door", "wood", "leaf", + "pave", "kerb", "joint", "bin", "bin-lid", "puddle", "skin", "hair", "top", "legs", "bird", "brick", "lamp", "light", "box", "glass", +] as const; +export type Palette = Record<(typeof PALETTE)[number], string>; + +/** The scene's colours, from the --street-* tokens on its element. */ +export function readPalette(element: Element): Palette { + const style = getComputedStyle(element); + return Object.fromEntries(PALETTE.map((name) => [name, style.getPropertyValue(`--street-${name}`).trim()])) as Palette; +} + +/** A rectangle in units that nothing in the sky is drawn behind (the message). */ +export type Hole = { x: number; y: number; w: number; h: number }; +export type Pose = "ready" | "running" | "paused" | "over"; + +export function drawStreet(ctx: CanvasRenderingContext2D, layout: Layout, run: Run, pose: Pose, P: Palette, hole: Hole | null) { + const { width: W, height: H, ground: G, row: ROW, runnerX: X } = layout; + const d = run.distance; + const r = (x: number, y: number, w: number, h: number, colour: string) => { + ctx.fillStyle = colour; + ctx.fillRect(Math.round(x * UNIT), Math.round(y * UNIT), Math.round(w * UNIT), Math.round(h * UNIT)); + }; + + // Sky, with the sun and clouds kept clear of the message. + r(0, 0, W, ROW, P.sky); + ctx.save(); + if (hole) { + ctx.beginPath(); + ctx.rect(0, 0, W * UNIT, H * UNIT); + ctx.rect(hole.x * UNIT, hole.y * UNIT, hole.w * UNIT, hole.h * UNIT); + ctx.clip("evenodd"); + } + const sx = W - 34; + let sy = 26; + if (hole && sx < hole.x + hole.w && sy < hole.y + hole.h) sy = hole.y + hole.h + 6; + r(sx + 2, sy, 6, 10, P.sun); r(sx, sy + 2, 10, 6, P.sun); + const span = W + 60, drift = (d * 0.08) % span; + for (let i = 0; i < Math.ceil(W / 70) + 1; i++) { + const x = ((i * 83 + 20 - drift) % span + span) % span - 30, y = 14 + (i * 29) % Math.max(20, ROW - 70); + r(x, y, 16, 3, P.cloud); r(x + 3, y - 2, 9, 2, P.cloud); + } + ctx.restore(); + + // A terrace of houses with lamp posts, slower than the pavement. + const mod = 44, off = (d * 0.35) % (mod * 3), top = ROW - 30; + for (let i = -1; i < Math.ceil(W / mod) + 3; i++) { + const x = i * mod - off, k = ((i % 3) + 3) % 3; + r(x, top, mod, 30, k === 1 ? P["house-alt"] : P.house); + for (let s = 0; s < 4; s++) r(x + s * 2, top - 2 - s * 2, mod - s * 4, 2, P.roof); + r(x + 30, top - 12, 5, 6, P.roof); r(x + 29, top - 13, 7, 2, P.legs); + for (const wx of [6, 26]) { r(x + wx, top + 4, 10, 9, P.roof); r(x + wx + 1, top + 5, 8, 7, P.window); r(x + wx + 4, top + 5, 1, 7, P.frame); } + r(x + 6, top + 17, 10, 9, P.roof); r(x + 7, top + 18, 8, 7, P.window); + r(x + 26, top + 15, 9, 15, P.door); r(x + 33, top + 22, 1, 1, P.sun); + if (k === 2) { r(x + 18, top + 22, 6, 8, P.wood); r(x + 17, top + 17, 8, 6, P.leaf); } + } + const lamps = (d * 0.35) % 132; + for (let i = 0; i < Math.ceil(W / 132) + 2; i++) { + const x = 66 + i * 132 - lamps; + r(x, ROW - 30, 2, 36, P.lamp); r(x - 2, ROW - 32, 6, 3, P.lamp); r(x - 1, ROW - 29, 4, 2, P.light); + } + + // Front walls with hedges, then the pavement. + const hedges = (d * 0.6) % 44; + r(0, ROW, W, 5, P.kerb); + for (let x = -hedges; x < W; x += 44) r(x + 4, ROW - 2, 22, 4, P.leaf); + r(0, ROW + 5, W, H - ROW - 5, P.pave); + for (let x = -(d % 16); x < W; x += 16) r(x, ROW + 5, 1, H - ROW - 5, P.joint); + r(0, ROW + 13, W, 1, P.joint); r(0, H - 3, W, 3, P.kerb); + + for (const o of run.obstacles) { + const x = o.x; + if (o.type === "bin" || o.type === "bins") { + for (let i = 0; i < (o.type === "bins" ? 2 : 1); i++) { + const bx = x + i * 10; + r(bx, G - 13, 9, 12, P.bin); r(bx - 1, G - 14, 11, 2, P["bin-lid"]); r(bx + 1, G - 1, 3, 1, P.legs); r(bx + 6, G - 1, 2, 1, P.legs); r(bx + 3, G - 10, 3, 1, P["bin-lid"]); + } + } else if (o.type === "puddle") { + r(x, G + 2, 16, 2, P.puddle); r(x + 3, G + 1, 10, 1, P.puddle); r(x + 5, G + 2, 3, 1, P.cloud); + } else if (o.type === "box") { + r(x, G - 7, 12, 7, P.box); r(x + 1, G - 11, 2, 5, P.glass); r(x + 5, G - 10, 2, 4, P.brick); r(x + 8, G - 12, 2, 6, P.glass); r(x, G - 7, 12, 1, P["bin-lid"]); + } else { + const y = G - 24; + r(x + 2, y + 2, 7, 3, P.bird); r(x, y + 2, 3, 2, P.bird); r(x + 9, y + 1, 2, 2, P.bird); r(x + 10, y + 2, 1, 1, P.sun); + if (Math.floor(run.time * 8) % 2) r(x + 3, y - 1, 4, 3, P.bird); else r(x + 3, y + 5, 4, 2, P.bird); + } + } + + // The runner, with a bag of shopping. + if (pose === "over") { + const y = G - 6; + r(X - 2, y, 5, 5, P.skin); r(X - 2, y, 5, 2, P.hair); r(X + 3, y + 1, 7, 4, P.top); r(X + 10, y + 2, 6, 3, P.legs); r(X + 5, y - 2, 3, 3, P.wood); + return; + } + const t = G - 16 - run.lift; + r(X + 3, t, 5, 5, P.skin); r(X + 3, t, 5, 2, P.hair); r(X + 7, t + 2, 1, 1, P.legs); + r(X + 2, t + 5, 6, 6, P.top); + if (run.lift > 0) { r(X + 1, t + 6, 2, 3, P.top); r(X + 8, t + 5, 2, 3, P.top); r(X + 2, t + 11, 6, 3, P.legs); r(X + 6, t + 13, 3, 2, P.legs); } + else if (pose !== "running") { r(X + 3, t + 11, 2, 5, P.legs); r(X + 6, t + 11, 2, 5, P.legs); r(X + 8, t + 7, 3, 4, P.wood); } + else if (Math.floor(run.time * 10) % 2) { r(X + 1, t + 6, 2, 4, P.top); r(X + 2, t + 11, 2, 5, P.legs); r(X + 6, t + 11, 3, 3, P.legs); r(X + 8, t + 13, 2, 2, P.legs); r(X + 8, t + 7, 3, 4, P.wood); } + else { r(X + 7, t + 6, 2, 4, P.top); r(X + 6, t + 11, 2, 5, P.legs); r(X + 2, t + 11, 3, 3, P.legs); r(X, t + 13, 2, 2, P.legs); r(X - 1, t + 7, 3, 4, P.wood); } +} diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts index 19a2b413..873bcf01 100644 --- a/apps/web/tests/setup.ts +++ b/apps/web/tests/setup.ts @@ -6,3 +6,9 @@ import { beforeEach } from "vitest"; beforeEach(() => { if (typeof localStorage !== "undefined") localStorage.clear(); }); + +// jsdom has no canvas. Scenes that draw (the street runner) draw nothing in +// tests, without jsdom's "not implemented" message on every render. +if (typeof HTMLCanvasElement !== "undefined") { + HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext; +} diff --git a/apps/web/tests/street-runner.test.tsx b/apps/web/tests/street-runner.test.tsx new file mode 100644 index 00000000..420e9c39 --- /dev/null +++ b/apps/web/tests/street-runner.test.tsx @@ -0,0 +1,102 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { advance, hits, jump, layoutFor, metres, newRun, type Obstacle } from "../src/shell/streetRunner"; +import { StreetScene } from "../src/shell/StreetScene"; +import { ConnectionRecovery } from "../src/shell/ConnectionRecovery"; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +const layout = layoutFor(720, 480); +const obstacle = (type: Obstacle["type"], x: number): Obstacle => ({ type, x, w: type === "puddle" ? 16 : type === "bird" ? 11 : 9, h: type === "bird" ? 6 : type === "puddle" ? 0 : 14, extra: 0 }); + +describe("street runner", () => { + it("ends a run that never jumps at the first bin", () => { + const run = newRun(); + let hit = null; + for (let i = 0; i < 600 && !hit; i++) hit = advance(run, layout, 1 / 60, () => 0); + expect(hit).toBe("bin"); + expect(metres(run.distance)).toBeGreaterThan(5); + }); + + it("clears bin after bin with well-timed jumps", () => { + const run = newRun(); + let hit = null; + for (let i = 0; i < 60 * 20 && !hit; i++) { + const next = run.obstacles.find((o) => o.x + o.w > layout.runnerX); + if (next && next.x - (layout.runnerX + 8) < run.speed * 0.1) jump(run); + hit = advance(run, layout, 1 / 60, () => 0); + } + expect(hit).toBeNull(); + expect(metres(run.distance)).toBeGreaterThan(150); + }); + + it("is safe under a low pigeon but not when jumping into it", () => { + const run = newRun(); + const bird = obstacle("bird", layout.runnerX + 2); + expect(hits(run, layout, bird)).toBe(false); + run.lift = 12; + expect(hits(run, layout, bird)).toBe(true); + }); + + it("splashes through a puddle only on the ground", () => { + const run = newRun(); + const puddle = obstacle("puddle", layout.runnerX - 4); + expect(hits(run, layout, puddle)).toBe(true); + run.lift = 2; + expect(hits(run, layout, puddle)).toBe(false); + }); + + it("always leaves room to react before the next obstacle", () => { + const run = newRun(); + let previous: Obstacle | undefined; + for (let i = 0; i < 60 * 30; i++) { + run.lift = 50; // flying over everything, to see every gap + advance(run, layout, 1 / 60, Math.random); + const latest = run.obstacles[run.obstacles.length - 1]; + if (latest && latest !== previous && previous) { + expect(latest.x - (previous.x + previous.w)).toBeGreaterThanOrEqual(run.speed * 0.6); + } + previous = latest; + } + }); +}); + +describe("street scene", () => { + it("starts with Space, pauses with Escape and when the page is hidden", () => { + render(

We couldn’t connect

); + expect(screen.getByText("Press Space or tap the street to run")).toBeTruthy(); + expect(screen.getByText("Best 0 m")).toBeTruthy(); + fireEvent.keyDown(document.body, { key: " " }); + expect(screen.queryByText("Press Space or tap the street to run")).toBeNull(); + expect(screen.getByRole("button", { name: "Pause" })).toBeTruthy(); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(screen.getByText("Paused. Space or tap to carry on.")).toBeTruthy(); + fireEvent.keyDown(document.body, { key: " " }); + expect(screen.getByRole("button", { name: "Pause" })).toBeTruthy(); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + fireEvent(document, new Event("visibilitychange")); + expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy(); + }); + + it("leaves Space to a focused button", () => { + render(); + const button = screen.getByRole("button", { name: "Try again" }); + button.focus(); + fireEvent.keyDown(button, { key: " " }); + expect(screen.getByText("Press Space or tap the street to run")).toBeTruthy(); + }); + + it("keeps a run going when the connection returns, and opens Commune when asked", async () => { + const online = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); + const retry = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.keyDown(document.body, { key: " " }); + online.mockReturnValue(true); + fireEvent(window, new Event("online")); + expect(screen.getByRole("heading", { name: "You’re back online" })).toBeTruthy(); + expect(retry).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Pause" })).toBeTruthy(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Open Commune" })); }); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a5493d76..ad8fe711 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -134,6 +134,16 @@ for a handoff, not a production release. seconds). Tests that draw the signed-in app call `preloadAll()` first. `?native=1` shows the phone layout at any width; the `.is-native` rules serve it. +- From 11 October, for review: Street runner. The connection screen + (`shell/ConnectionRecovery.tsx`) and the missing page (`NotFoundPage` in + `App.tsx`) show a street scene across the top of the screen with their + message in its sky (`shell/StreetScene.tsx`); tapping the street or Space + plays a small runner game (`shell/streetRunner.ts`, tested in + `tests/street-runner.test.tsx`). It is in the first download so it works + offline, plays no sound, pauses when the page is hidden, and keeps only the + best distance in local storage. If the connection returns mid-run the + screen says so and waits for Open Commune instead of reconnecting. The + "something went wrong" card (`shell/Recovery.tsx`) has no game. - The website is `web/` (the editorial homepage); the earlier v2 site is in `web-backup/`. Launch names `website` (3004) and `website-backup` (3003). - No push, deployment, hosted migration or provider change has been made. Do