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.
// 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);
}
