Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/title-scene.ts
// The title screen's backdrop: the game's interstate at dusk, drawn once onto a canvas (no
// animation, nothing running). Flat low-poly shapes like the game — olive fields, cone trees,
// guardrails, lamp posts, grey asphalt with painted lanes — under a night sky, with the road
// running away to a city on the horizon and I-87 NORTH signs along it.

/** A fixed-seed random source, so the scene is the same every time it's drawn. */
function random(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const ROAD_HALF = 13; // six lanes of 3.7 m plus shoulders, as in the game
const LANE_W = 3.7;
const CAMERA_H = 5; // a little above the cars

export function drawTitleScene(canvas: HTMLCanvasElement): void {
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  const W = Math.max(1, Math.round(canvas.clientWidth * dpr));
  const H = Math.max(1, Math.round(canvas.clientHeight * dpr));
  canvas.width = W;
  canvas.height = H;
  const g = canvas.getContext('2d')!;
  const rnd = random(87);

  // Perspective: ground point (x, z) and a height y above it, seen from CAMERA_H up.
  const horizon = H * 0.55;
  const f = Math.max(H * 1.1, W * 0.62);
  const cx = W / 2;
  const sx = (x: number, z: number) => cx + (f * x) / z;
  const sy = (y: number, z: number) => horizon + (f * (CAMERA_H - y)) / z;
  const zNear = (f * CAMERA_H) / (H - horizon); // where the ground meets the bottom edge

  // ---- sky: night overhead, dusk glow at the horizon, and stars
  const sky = g.createLinearGradient(0, 0, 0, horizon);
  sky.addColorStop(0, '#070a18');
  sky.addColorStop(0.45, '#141936');
  sky.addColorStop(0.72, '#3b2649');
  sky.addColorStop(0.88, '#a8573f');
  sky.addColorStop(1, '#f0a868');
  g.fillStyle = sky;
  g.fillRect(0, 0, W, horizon + 1);
  for (let i = 0; i < 260; i++) {
    const x = rnd() * W, y = rnd() * horizon * 0.6;
    g.fillStyle = `rgba(255, 248, 230, ${(0.25 + rnd() * 0.75) * (1 - y / (horizon * 0.65))})`;
    const r = rnd() < 0.08 ? 1.6 * dpr : 0.8 * dpr;
    g.fillRect(x, y, r, r);
  }

  // ---- the city at the end of the road, under its own orange glow
  const glow = g.createRadialGradient(cx, horizon, 0, cx, horizon, W * 0.45);
  glow.addColorStop(0, 'rgba(255, 170, 90, 0.45)');
  glow.addColorStop(1, 'rgba(255, 170, 90, 0)');
  g.fillStyle = glow;
  g.fillRect(0, 0, W, horizon);
  const buildings: { x0: number; x1: number; top: number; z: number; tall: boolean }[] = [];
  for (let i = 0; i < 70; i++) {
    const z = 1800 + rnd() * 900;
    // Bunched round the road's vanishing point: downtown in the middle, low blocks out wide.
    const spread = (rnd() - 0.5) * (rnd() < 0.7 ? 900 : 2200);
    const tall = Math.abs(spread) < 300 && rnd() < 0.55;
    const height = tall ? 140 + rnd() * 260 : 25 + rnd() * 90;
    const width = 30 + rnd() * (tall ? 60 : 110);
    buildings.push({ x0: spread - width / 2, x1: spread + width / 2, top: height, z, tall });
  }
  buildings.sort((a, b) => b.z - a.z); // far first
  for (const b of buildings) {
    const x0 = sx(b.x0, b.z), x1 = sx(b.x1, b.z), y0 = sy(b.top, b.z), y1 = horizon + 1;
    const shade = 18 + Math.round(rnd() * 14);
    g.fillStyle = `rgb(${shade}, ${shade + 2}, ${shade + 14})`;
    g.fillRect(x0, y0, x1 - x0, y1 - y0);
    // Windows: a grid, only some of them lit, each building with its own share of lights on.
    const cell = Math.max(2, (f * 4) / b.z), lit = 0.15 + rnd() * 0.45;
    for (let y = y0 + cell; y < y1 - cell * 1.5; y += cell * 1.6) {
      for (let x = x0 + cell * 0.6; x < x1 - cell; x += cell * 1.4) {
        if (rnd() > lit) continue;
        const warm = rnd();
        g.fillStyle = warm < 0.75 ? '#ffd27a' : warm < 0.92 ? '#fff1c4' : '#9fd0ff';
        g.fillRect(x, y, Math.max(1, cell * 0.7), Math.max(1, cell * 0.9));
      }
    }
    if (b.tall && b.top > 280) {
      g.fillStyle = '#ff3b3b'; // aircraft warning light
      g.fillRect((x0 + x1) / 2 - dpr, y0 - 3 * dpr, 2 * dpr, 2 * dpr);
    }
  }

  // ---- ground: dusk-dark olive fields in flat patches
  const ground = g.createLinearGradient(0, horizon, 0, H);
  ground.addColorStop(0, '#3a432a');
  ground.addColorStop(1, '#262d18');
  g.fillStyle = ground;
  g.fillRect(0, horizon, W, H - horizon);
  for (let z = zNear; z < 2400; z *= 1.22) {
    const z2 = z * 1.22;
    for (let x = -1400; x < 1400; x += 90) {
      if (Math.abs(x + 45) < ROAD_HALF + 40) continue;
      const tone = 0.85 + rnd() * 0.3;
      g.fillStyle = `rgb(${Math.round(58 * tone)}, ${Math.round(68 * tone)}, ${Math.round(38 * tone)})`;
      g.beginPath();
      g.moveTo(sx(x, z), sy(0, z));
      g.lineTo(sx(x + 90, z), sy(0, z));
      g.lineTo(sx(x + 90, z2), sy(0, z2));
      g.lineTo(sx(x, z2), sy(0, z2));
      g.fill();
    }
  }
  // A thin haze line where the land meets the glow.
  const haze = g.createLinearGradient(0, horizon - H * 0.02, 0, horizon + H * 0.03);
  haze.addColorStop(0, 'rgba(240, 168, 104, 0)');
  haze.addColorStop(0.5, 'rgba(240, 168, 104, 0.25)');
  haze.addColorStop(1, 'rgba(240, 168, 104, 0)');
  g.fillStyle = haze;
  g.fillRect(0, horizon - H * 0.02, W, H * 0.05);

  // ---- the interstate: asphalt, shoulders, lane lines (dashes at even spacing in depth)
  const far = 2200;
  const quad = (xa: number, xb: number, color: string | CanvasGradient) => {
    g.fillStyle = color;
    g.beginPath();
    g.moveTo(sx(xa, zNear), H);
    g.lineTo(sx(xb, zNear), H);
    g.lineTo(sx(xb, far), sy(0, far));
    g.lineTo(sx(xa, far), sy(0, far));
    g.fill();
  };
  quad(-ROAD_HALF - 4, ROAD_HALF + 4, '#5a5446'); // gravel shoulders
  const asphalt = g.createLinearGradient(0, horizon, 0, H);
  asphalt.addColorStop(0, '#4a4a54');
  asphalt.addColorStop(1, '#34343a');
  quad(-ROAD_HALF, ROAD_HALF, asphalt);
  const edge = (LANE_W * 6) / 2;
  quad(-edge - 0.15, -edge + 0.15, '#e8e2c8');
  quad(edge - 0.15, edge + 0.15, '#e8e2c8');
  for (let lane = 1; lane < 6; lane++) {
    const x = -edge + lane * LANE_W;
    for (let z = zNear; z < far; z += 12) {
      const z2 = Math.min(far, z + 3.6);
      g.fillStyle = '#e8e2c8';
      g.beginPath();
      g.moveTo(sx(x - 0.1, z), sy(0, z));
      g.lineTo(sx(x + 0.1, z), sy(0, z));
      g.lineTo(sx(x + 0.1, z2), sy(0, z2));
      g.lineTo(sx(x - 0.1, z2), sy(0, z2));
      g.fill();
    }
  }

  // ---- things beside the road, far to near so nearer ones cover farther ones
  type Thing = { z: number; draw: () => void };
  const things: Thing[] = [];
  // Cone trees out in the fields (flat-shaded: a lit side and a shadow side).
  for (let i = 0; i < 320; i++) {
    const side = rnd() < 0.5 ? -1 : 1;
    const x = side * (ROAD_HALF + 10 + rnd() * rnd() * 260);
    const z = zNear + 4 + rnd() * rnd() * 1500;
    const h = 5 + rnd() * 7, w = h * 0.36;
    things.push({ z, draw: () => {
      const px = sx(x, z), base = sy(0, z), top = sy(h, z), half = (f * w) / z;
      g.fillStyle = '#2f4523';
      g.beginPath(); g.moveTo(px, top); g.lineTo(px - half, base); g.lineTo(px, base); g.fill();
      g.fillStyle = '#1f2f18';
      g.beginPath(); g.moveTo(px, top); g.lineTo(px, base); g.lineTo(px + half, base); g.fill();
    } });
  }
  // Lamp posts each side every 60 m, lamps lit.
  for (let z = 30; z < 1600; z += 60) {
    for (const side of [-1, 1]) {
      const x = side * (ROAD_HALF + 1.6);
      things.push({ z, draw: () => {
        const px = sx(x, z), arm = sx(x - side * 2.4, z), base = sy(0, z), top = sy(9, z);
        const lw = Math.max(1, (f * 0.22) / z);
        g.strokeStyle = '#2a2c30';
        g.lineWidth = lw;
        g.beginPath(); g.moveTo(px, base); g.lineTo(px, top); g.lineTo(arm, top); g.stroke();
        const r = Math.min(H * 0.035, Math.max(2, (f * 1.4) / z)); // near lamps don't swamp the view
        const lamp = g.createRadialGradient(arm, top + lw, 0, arm, top + lw, r * 3);
        lamp.addColorStop(0, 'rgba(255, 214, 140, 0.95)');
        lamp.addColorStop(0.3, 'rgba(255, 190, 110, 0.35)');
        lamp.addColorStop(1, 'rgba(255, 190, 110, 0)');
        g.fillStyle = lamp;
        g.fillRect(arm - r * 3, top + lw - r * 3, r * 6, r * 6);
      } });
    }
  }
  // Overhead guide sign: I-87 NORTH, City Center.
  const gantryZ = 70;
  things.push({ z: gantryZ, draw: () => {
    const z = gantryZ;
    g.fillStyle = '#5d6066';
    for (const side of [-1, 1]) {
      const px = sx(side * (ROAD_HALF + 2), z), w = Math.max(2, (f * 0.5) / z);
      g.fillRect(px - w / 2, sy(9.2, z), w, sy(0, z) - sy(9.2, z));
    }
    g.fillRect(sx(-ROAD_HALF - 2, z), sy(9.2, z), sx(ROAD_HALF + 2, z) - sx(-ROAD_HALF - 2, z), Math.max(2, (f * 0.35) / z));
    const x0 = sx(0, z), x1 = sx(12, z), y0 = sy(9.6, z), y1 = sy(6.4, z);
    g.fillStyle = '#1d6b3c';
    g.fillRect(x0, y0, x1 - x0, y1 - y0);
    g.strokeStyle = '#f4f1e6';
    g.lineWidth = Math.max(1, (x1 - x0) * 0.012);
    g.strokeRect(x0 + (x1 - x0) * 0.02, y0 + (y1 - y0) * 0.06, (x1 - x0) * 0.96, (y1 - y0) * 0.88);
    const sh = (y1 - y0) * 0.62;
    shield(g, x0 + (x1 - x0) * 0.16, y0 + (y1 - y0) * 0.5, sh);
    g.fillStyle = '#f4f1e6';
    g.textAlign = 'left';
    g.textBaseline = 'middle';
    g.font = `bold ${Math.round((y1 - y0) * 0.3)}px Arial, sans-serif`;
    g.fillText('NORTH', x0 + (x1 - x0) * 0.32, y0 + (y1 - y0) * 0.33);
    g.font = `bold ${Math.round((y1 - y0) * 0.22)}px Arial, sans-serif`;
    g.fillText('City Center  2', x0 + (x1 - x0) * 0.32, y0 + (y1 - y0) * 0.7);
  } });
  // Roadside reassurance sign: NORTH over an I-87 shield, on a post on the right.
  const postZ = 38, postX = ROAD_HALF + 3.5;
  things.push({ z: postZ, draw: () => {
    const z = postZ, px = sx(postX, z), s = f / z;
    g.fillStyle = '#7a7d80';
    g.fillRect(px - 0.06 * s, sy(3.6, z), 0.12 * s, sy(0, z) - sy(3.6, z));
    // The direction plaque sits above the shield, as on a real reassurance sign.
    const shieldH = 1.35 * s, shieldY = sy(2.9, z);
    shield(g, px, shieldY, shieldH);
    const pw = 1.3 * s, ph = 0.42 * s, plaqueBottom = shieldY - shieldH / 2 - 0.08 * s;
    g.fillStyle = '#f4f1e6';
    g.fillRect(px - pw / 2, plaqueBottom - ph, pw, ph);
    g.fillStyle = '#141414';
    g.font = `bold ${Math.round(ph * 0.7)}px Arial, sans-serif`;
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.fillText('NORTH', px, plaqueBottom - ph / 2);
  } });
  // Guardrails along both edges (drawn with the things so the near ends overlap correctly).
  things.push({ z: 2000, draw: () => {
    g.strokeStyle = '#8f9296';
    for (const side of [-1, 1]) {
      const x = side * (ROAD_HALF + 0.2);
      g.lineWidth = Math.max(1, H * 0.006);
      g.beginPath();
      g.moveTo(sx(x, zNear), sy(0.62, zNear));
      g.lineTo(sx(x, far), sy(0.62, far));
      g.stroke();
    }
  } });
  things.sort((a, b) => b.z - a.z);
  for (const t of things) t.draw();

  // Darken the bottom edge a touch so the cards read clearly over the road.
  const shade = g.createLinearGradient(0, H * 0.7, 0, H);
  shade.addColorStop(0, 'rgba(6, 7, 12, 0)');
  shade.addColorStop(1, 'rgba(6, 7, 12, 0.55)');
  g.fillStyle = shade;
  g.fillRect(0, H * 0.7, W, H * 0.3);
}

/** An Interstate shield (blue with a red crown, white rim) with "87", centred at (x, y), h tall. */
function shield(g: CanvasRenderingContext2D, x: number, y: number, h: number): void {
  const w = h * 0.92;
  const outline = (inset: number) => {
    const l = x - w / 2 + inset, r = x + w / 2 - inset, t = y - h / 2 + inset, b = y + h / 2 - inset;
    g.beginPath();
    g.moveTo(l, t + h * 0.08);
    g.quadraticCurveTo(x - w * 0.25, t - h * 0.04, x, t + h * 0.06);
    g.quadraticCurveTo(x + w * 0.25, t - h * 0.04, r, t + h * 0.08);
    g.quadraticCurveTo(r + w * 0.05, y + h * 0.15, x, b);
    g.quadraticCurveTo(l - w * 0.05, y + h * 0.15, l, t + h * 0.08);
    g.closePath();
  };
  g.fillStyle = '#f4f1e6';
  outline(0);
  g.fill();
  g.save();
  outline(h * 0.06);
  g.clip();
  g.fillStyle = '#1f4fa8';
  g.fillRect(x - w, y - h, w * 2, h * 2);
  g.fillStyle = '#c8202a'; // the red crown
  g.fillRect(x - w, y - h / 2, w * 2, h * 0.27);
  g.restore();
  g.fillStyle = '#f4f1e6';
  g.textAlign = 'center';
  g.textBaseline = 'middle';
  g.font = `bold ${Math.max(4, Math.round(h * 0.11))}px Arial, sans-serif`;
  g.fillText('INTERSTATE', x, y - h * 0.36);
  g.font = `bold ${Math.max(6, Math.round(h * 0.42))}px Arial, sans-serif`;
  g.fillText('87', x, y + h * 0.1);
}