From 44aee55edf8800ef2774eeca4ae9f37c69d5c000 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:50:10 +0000 Subject: [PATCH] Put a small runner game on the connection and missing pages The connection screen and the missing page now show a street scene edge to edge across the top, with their message, Try again and Sign out (or Go to Home) in its sky. Tapping the street, Space or the up arrow plays Street runner: jump the bins, puddles and recycling, stay low for pigeons. It ships in the first download so it works offline, plays no sound, pauses when the page is hidden or loses focus, and stores only the best distance on the device. If the connection returns during a run, the screen says so and waits for Open Commune instead of reconnecting under the player. With no run in progress it reconnects straight away, as before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/App.tsx | 16 +- apps/web/src/shell/ConnectionRecovery.tsx | 34 ++-- apps/web/src/shell/StreetScene.tsx | 195 ++++++++++++++++++++++ apps/web/src/shell/street-scene.css | 97 +++++++++++ apps/web/src/shell/streetRunner.ts | 195 ++++++++++++++++++++++ apps/web/tests/setup.ts | 6 + apps/web/tests/street-runner.test.tsx | 102 +++++++++++ docs/HANDOFF.md | 10 ++ 8 files changed, 633 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/shell/StreetScene.tsx create mode 100644 apps/web/src/shell/street-scene.css create mode 100644 apps/web/src/shell/streetRunner.ts create mode 100644 apps/web/tests/street-runner.test.tsx 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