Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

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

src/game/paint.ts
import { GRASS, L_CANOPY, L_DECK, L_DECKTOP, MAP_H, MAP_W, type City } from './city.js';

const CHUNK = 256;
// Low levels serve the A-10 at height, when much of the city is on screen at once.
const LEVELS = [0.18, 0.25, 0.35, 0.5, 0.71, 1, 1.41, 2, 2.83];
/** Cache budget in canvas pixels (about 100 MB), so many small chunks or a few sharp ones. */
const MAX_PIXELS = 26e6;
const MAX_CHUNKS = 900;
const RENDERS_PER_FRAME = 4;

interface Entry {
  canvas: HTMLCanvasElement;
  used: number;
  empty: boolean;
}

export function paintRegion(ctx: CanvasRenderingContext2D, city: City, x0: number, y0: number, x1: number, y1: number, from: number, to: number): number {
  let drawn = 0;
  for (let layer = from; layer <= to; layer++) {
    for (const item of city.layers[layer]) {
      if (item.x1 < x0 || item.x0 > x1 || item.y1 < y0 || item.y0 > y1) continue;
      item.draw(ctx);
      drawn++;
    }
  }
  return drawn;
}

/**
 * Paints the static city in cached 256-unit chunks at a resolution matched to the
 * zoom. The ground and the elevated freeway deck are cached separately so traffic
 * can drive under and over each other.
 */
export class MapRenderer {
  private readonly cache = new Map<string, Entry>();
  private frame = 0;
  private budget = 0;

  constructor(private readonly city: City) {}

  beginFrame(): void {
    this.frame++;
    this.budget = RENDERS_PER_FRAME;
  }

  /**
   * Zoomed far out (the A-10 at height) the chunks cover more of the map, so a screenful
   * takes a handful of images instead of hundreds; each costs more to paint, so fewer a frame.
   */
  private chunkFor(level: number): number {
    return level <= 0.35 ? CHUNK * 4 : level <= 0.5 ? CHUNK * 2 : CHUNK;
  }

  private budgetFor(level: number): number {
    return level <= 0.35 ? 2 : level <= 0.5 ? 3 : RENDERS_PER_FRAME;
  }

  draw(ctx: CanvasRenderingContext2D, pass: 'ground' | 'deck', viewX: number, viewY: number, zoom: number, devW: number, devH: number, alpha = 1): void {
    const level = LEVELS.find((l) => l >= zoom * 0.95) ?? LEVELS[LEVELS.length - 1];
    if (pass === 'ground') this.budget = Math.min(this.budget, this.budgetFor(level));
    const CHUNK = this.chunkFor(level);
    const cx0 = Math.floor(viewX / CHUNK);
    const cy0 = Math.floor(viewY / CHUNK);
    const cx1 = Math.floor((viewX + devW / zoom) / CHUNK);
    const cy1 = Math.floor((viewY + devH / zoom) / CHUNK);
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.globalAlpha = alpha;
    for (let cy = cy0; cy <= cy1; cy++) {
      for (let cx = cx0; cx <= cx1; cx++) {
        const key = `${pass}:${level}:${cx}:${cy}`;
        let entry = this.cache.get(key);
        if (!entry && this.budget > 0) {
          entry = this.render(pass, cx, cy, level);
          this.cache.set(key, entry);
          this.budget--;
        }
        entry ??= this.fallback(pass, cx, cy, CHUNK);
        const x0 = Math.round((cx * CHUNK - viewX) * zoom);
        const y0 = Math.round((cy * CHUNK - viewY) * zoom);
        const x1 = Math.round(((cx + 1) * CHUNK - viewX) * zoom);
        const y1 = Math.round(((cy + 1) * CHUNK - viewY) * zoom);
        if (entry) {
          entry.used = this.frame;
          if (!entry.empty) ctx.drawImage(entry.canvas, x0, y0, x1 - x0, y1 - y0);
        } else if (pass === 'ground') {
          ctx.fillStyle = GRASS;
          ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
        }
      }
    }
    ctx.globalAlpha = 1;
    this.evict();
  }

  private render(pass: 'ground' | 'deck', cx: number, cy: number, level: number): Entry {
    const CHUNK = this.chunkFor(level);
    const canvas = document.createElement('canvas');
    const size = Math.ceil(CHUNK * level);
    canvas.width = size;
    canvas.height = size;
    const ground = pass === 'ground';
    const ctx = canvas.getContext('2d', { alpha: !ground })!;
    if (ground) {
      ctx.fillStyle = GRASS;
      ctx.fillRect(0, 0, size, size);
    }
    ctx.scale(level, level);
    ctx.translate(-cx * CHUNK, -cy * CHUNK);
    const x0 = cx * CHUNK - 1;
    const y0 = cy * CHUNK - 1;
    const drawn = paintRegion(ctx, this.city, x0, y0, x0 + CHUNK + 2, y0 + CHUNK + 2, ground ? 0 : L_DECK, ground ? L_CANOPY : L_DECKTOP);
    const empty = !ground && drawn === 0;
    if (empty) canvas.width = canvas.height = 1;
    return { canvas, used: this.frame, empty };
  }

  private fallback(pass: string, cx: number, cy: number, chunk: number): Entry | undefined {
    for (const level of LEVELS) {
      if (this.chunkFor(level) !== chunk) continue;
      const entry = this.cache.get(`${pass}:${level}:${cx}:${cy}`);
      if (entry) return entry;
    }
    return undefined;
  }

  private evict(): void {
    let pixels = 0;
    for (const e of this.cache.values()) pixels += e.canvas.width * e.canvas.height;
    if (this.cache.size <= MAX_CHUNKS && pixels <= MAX_PIXELS) return;
    const entries = [...this.cache.entries()].sort((a, b) => a[1].used - b[1].used);
    for (const [key, e] of entries) {
      if (this.cache.size <= MAX_CHUNKS && pixels <= MAX_PIXELS) break;
      // Never throw away what's on screen this frame.
      if (e.used === this.frame) break;
      pixels -= e.canvas.width * e.canvas.height;
      this.cache.delete(key);
    }
  }
}

/** A small overview of the whole city for the minimap. */
export function renderMinimap(city: City, width: number, height: number): HTMLCanvasElement {
  const canvas = document.createElement('canvas');
  canvas.width = Math.max(1, Math.round(width));
  canvas.height = Math.max(1, Math.round(height));
  const ctx = canvas.getContext('2d', { alpha: false })!;
  const scale = Math.min(canvas.width / MAP_W, canvas.height / MAP_H);
  ctx.fillStyle = GRASS;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.scale(scale, scale);
  paintRegion(ctx, city, 0, 0, MAP_W, MAP_H, 0, 3);
  paintRegion(ctx, city, 0, 0, MAP_W, MAP_H, L_DECK, L_DECK);
  return canvas;
}