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/time-cards.ts
// The time-of-day picker's pictures: the interstate from behind a car, by day, at dusk and at
// night. Drawn on a canvas (flat low-poly, like the title backdrop) so they need no image files.
import type { TimeOfDay } from './game/render.js';

interface Palette {
  sky: [string, string, string]; // top, middle, horizon
  hills: string;
  ground: string;
  road: string;
  line: string;
  car: string;
  lit: boolean; // headlights, tail lights and street lamps on
  stars: boolean;
  orb?: { x: number; y: number; r: number; color: string; glow: string };
}

const PALETTES: Record<TimeOfDay, Palette> = {
  day: {
    sky: ['#4f93d8', '#8cc0ea', '#d6e8f2'], hills: '#7f9a62', ground: '#7d9150', road: '#5a5c60', line: '#f2f0e6',
    car: '#2f5fb8', lit: false, stars: false, orb: { x: 0.8, y: 0.18, r: 0.07, color: '#fffbe8', glow: 'rgba(255,250,220,0.45)' },
  },
  dusk: {
    sky: ['#2c2f66', '#b8577a', '#f6a35c'], hills: '#4a3f5c', ground: '#5a5a3c', road: '#3f3e44', line: '#d9cbb6',
    car: '#2f5fb8', lit: true, stars: false, orb: { x: 0.22, y: 0.5, r: 0.09, color: '#ffd28a', glow: 'rgba(255,170,90,0.5)' },
  },
  night: {
    sky: ['#03050f', '#0a1024', '#18213c'], hills: '#0d1220', ground: '#10161c', road: '#1c1e24', line: '#6d6f74',
    car: '#1f3570', lit: true, stars: true, orb: { x: 0.78, y: 0.16, r: 0.045, color: '#eef0f6', glow: 'rgba(200,215,255,0.25)' },
  },
};

export function drawTimeCard(canvas: HTMLCanvasElement, time: TimeOfDay): void {
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  const W = Math.max(1, Math.round(canvas.clientWidth * dpr)), H = Math.max(1, Math.round(canvas.clientHeight * dpr));
  canvas.width = W;
  canvas.height = H;
  const g = canvas.getContext('2d');
  if (!g) return;
  const p = PALETTES[time];
  const horizon = H * 0.55, cx = W / 2;

  // Sky, then the sun or moon and (at night) stars.
  const sky = g.createLinearGradient(0, 0, 0, horizon);
  sky.addColorStop(0, p.sky[0]);
  sky.addColorStop(0.6, p.sky[1]);
  sky.addColorStop(1, p.sky[2]);
  g.fillStyle = sky;
  g.fillRect(0, 0, W, horizon + 1);
  let seed = 7;
  const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
  if (p.stars) {
    g.fillStyle = '#e8ecf8';
    for (let i = 0; i < 70; i++) g.fillRect(rnd() * W, rnd() * horizon * 0.85, rnd() < 0.15 ? 1.6 * dpr : 0.9 * dpr, 0.9 * dpr);
  }
  if (p.orb) {
    const ox = p.orb.x * W, oy = p.orb.y * H, r = p.orb.r * H;
    const glow = g.createRadialGradient(ox, oy, r * 0.5, ox, oy, r * 4);
    glow.addColorStop(0, p.orb.glow);
    glow.addColorStop(1, 'rgba(0,0,0,0)');
    g.fillStyle = glow;
    g.fillRect(ox - r * 4, oy - r * 4, r * 8, r * 8);
    g.fillStyle = p.orb.color;
    g.beginPath();
    g.arc(ox, oy, r, 0, Math.PI * 2);
    g.fill();
  }

  // Low hills along the horizon, then the ground.
  g.fillStyle = p.hills;
  g.beginPath();
  g.moveTo(0, horizon);
  for (let x = 0; x <= W; x += W / 12) g.lineTo(x, horizon - (0.04 + 0.05 * Math.abs(Math.sin(x * 0.013 + 1))) * H);
  g.lineTo(W, horizon);
  g.fill();
  g.fillStyle = p.ground;
  g.fillRect(0, horizon, W, H - horizon);

  // The road running to the vanishing point, with dashed lane lines.
  const half = (y: number) => ((y - horizon) / (H - horizon)) * W * 0.62;
  g.fillStyle = p.road;
  g.beginPath();
  g.moveTo(cx - 2, horizon);
  g.lineTo(cx + 2, horizon);
  g.lineTo(cx + half(H), H);
  g.lineTo(cx - half(H), H);
  g.fill();
  g.strokeStyle = p.line;
  for (const k of [-1 / 3, 1 / 3]) {
    for (let i = 0; i < 9; i++) {
      const y0 = horizon + (H - horizon) * (i / 9) ** 2, y1 = horizon + (H - horizon) * ((i + 0.5) / 9) ** 2;
      g.lineWidth = Math.max(1, ((y1 - horizon) / (H - horizon)) * 4 * dpr);
      g.beginPath();
      g.moveTo(cx + k * half(y0), y0);
      g.lineTo(cx + k * half(y1), y1);
      g.stroke();
    }
  }

  // Street lamps down the left edge, glowing at night and dusk.
  for (let i = 1; i < 5; i++) {
    const y = horizon + (H - horizon) * (i / 5) ** 2;
    const x = cx - half(y) * 1.08, h = (y - horizon) * 1.6;
    g.strokeStyle = time === 'night' ? '#2a2d34' : '#55585c';
    g.lineWidth = Math.max(1, (h / H) * 6 * dpr);
    g.beginPath();
    g.moveTo(x, y);
    g.lineTo(x, y - h);
    g.lineTo(x + h * 0.25, y - h);
    g.stroke();
    if (p.lit) {
      const lx = x + h * 0.25, ly = y - h;
      const glow = g.createRadialGradient(lx, ly, 0, lx, ly, h * 0.35);
      glow.addColorStop(0, 'rgba(255,214,150,0.9)');
      glow.addColorStop(1, 'rgba(255,214,150,0)');
      g.fillStyle = glow;
      g.fillRect(lx - h * 0.35, ly - h * 0.35, h * 0.7, h * 0.7);
    }
  }

  // A car ahead, seen from behind, lighting the road in front of it after dark.
  const cy = horizon + (H - horizon) * 0.62, cw = W * 0.2, ch = cw * 0.45;
  if (p.lit) {
    const beam = g.createRadialGradient(cx, cy - ch * 0.6, 0, cx, cy - ch * 0.6, cw * 1.4);
    beam.addColorStop(0, time === 'night' ? 'rgba(255,240,200,0.55)' : 'rgba(255,240,200,0.3)');
    beam.addColorStop(1, 'rgba(255,240,200,0)');
    g.fillStyle = beam;
    g.beginPath();
    g.moveTo(cx - cw * 0.4, cy - ch * 0.4);
    g.lineTo(cx + cw * 0.4, cy - ch * 0.4);
    g.lineTo(cx + cw * 0.9, cy - ch * 2.2);
    g.lineTo(cx - cw * 0.9, cy - ch * 2.2);
    g.fill();
  }
  g.fillStyle = 'rgba(0,0,0,0.35)';
  g.fillRect(cx - cw * 0.55, cy - ch * 0.08, cw * 1.1, ch * 0.18); // shadow
  g.fillStyle = '#141416';
  g.fillRect(cx - cw * 0.48, cy - ch * 0.25, cw * 0.16, ch * 0.3); // tyres
  g.fillRect(cx + cw * 0.32, cy - ch * 0.25, cw * 0.16, ch * 0.3);
  g.fillStyle = p.car;
  g.fillRect(cx - cw / 2, cy - ch * 0.75, cw, ch * 0.6); // body
  g.fillStyle = time === 'night' ? '#0b0e16' : '#2a3340';
  g.beginPath(); // rear window and roof
  g.moveTo(cx - cw * 0.36, cy - ch * 0.75);
  g.lineTo(cx - cw * 0.28, cy - ch * 1.12);
  g.lineTo(cx + cw * 0.28, cy - ch * 1.12);
  g.lineTo(cx + cw * 0.36, cy - ch * 0.75);
  g.fill();
  for (const side of [-1, 1]) {
    const tx = cx + side * cw * 0.36;
    if (p.lit) {
      const glow = g.createRadialGradient(tx, cy - ch * 0.55, 0, tx, cy - ch * 0.55, cw * 0.22);
      glow.addColorStop(0, 'rgba(255,40,30,0.75)');
      glow.addColorStop(1, 'rgba(255,40,30,0)');
      g.fillStyle = glow;
      g.fillRect(tx - cw * 0.22, cy - ch * 0.55 - cw * 0.22, cw * 0.44, cw * 0.44);
    }
    g.fillStyle = p.lit ? '#ff3a2c' : '#a3161c';
    g.fillRect(tx - cw * 0.1, cy - ch * 0.62, cw * 0.2, ch * 0.14);
  }
  g.fillStyle = '#e6e1cc';
  g.fillRect(cx - cw * 0.1, cy - ch * 0.42, cw * 0.2, ch * 0.12); // plate
}