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