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.
// Smooth vector vehicles. Local space faces +x; (0, 0) is the vehicle centre.
export type CarModel = 'sedan' | 'taxi' | 'sports' | 'suv' | 'beater' | 'compact' | 'pickup' | 'van' | 'bus' | 'motorcycle' | 'police' | 'limo' | 'icecream' | 'tow' | 'ambulance' | 'firetruck' | 'garbage' | 'sweeper' | 'works' | 'dump' | 'roadside';
export interface CarLook {
model: CarModel;
color: string;
accent: string;
rude: boolean;
length: number;
width: number;
seed: number;
}
export interface Dent {
x: number;
y: number;
r: number;
}
export interface TruckPose {
x: number;
y: number;
angle: number;
trailer: { x: number; y: number };
trailerAngle: number;
braking: boolean;
cabDents: Dent[];
trailerDents: Dent[];
steer?: number;
roll?: number;
}
/** Visible crash damage on an AI car. */
export interface CarDamage {
/** Crush depth at the front, rear, left and right (px). */
crumple: [number, number, number, number];
/** Front-left, front-right, rear-left, rear-right wheels still attached. */
wheels: [boolean, boolean, boolean, boolean];
/** Lying on its roof. */
flipped: boolean;
/** Height above the road while airborne, and tumble angle. */
z: number;
roll: number;
hazard: boolean;
/** 0..1, how burnt-out the shell is. */
burnt: number;
glass: boolean;
/** Motorcycles: the rider has been thrown off. */
riderGone?: boolean;
}
export const COLORS = {
ink: '#15141b',
glass: '#1d2a3c',
glint: 'rgba(170, 205, 255, 0.55)',
headlight: '#fff2b0',
taillight: '#ff3b3b',
cab: '#d8402f',
trailer: '#efeae0',
chrome: '#d4d8dd',
};
const DIMS: Record<CarModel, [number, number]> = {
sedan: [16, 8.6],
taxi: [16, 8.6],
sports: [17, 8.4],
suv: [17, 9.4],
beater: [15.5, 8.6],
compact: [13.5, 8],
pickup: [18, 9],
van: [19, 9.6],
bus: [34, 11],
motorcycle: [7.5, 3.4],
police: [16.5, 8.8],
limo: [27, 9],
icecream: [18.5, 10],
tow: [21, 10],
ambulance: [20, 10],
firetruck: [30, 11],
garbage: [24, 10.5],
sweeper: [15, 9],
works: [18, 9.2],
dump: [24, 10.5],
roadside: [17, 9],
};
export function makeLook(model: CarModel, color: string, accent: string, rude: boolean): CarLook {
const [length, width] = DIMS[model];
return { model, color, accent, rude, length, width, seed: Math.random() };
}
/** Lighten (amount > 0) or darken (amount < 0) a #rrggbb colour. */
export function shade(hex: string, amount: number): string {
const n = parseInt(hex.slice(1), 16);
const channel = (v: number) => {
const out = amount >= 0 ? v + (255 - v) * amount : v * (1 + amount);
return Math.max(0, Math.min(255, Math.round(out)));
};
const r = channel((n >> 16) & 255);
const g = channel((n >> 8) & 255);
const b = channel(n & 255);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function poly(ctx: CanvasRenderingContext2D, pts: number[], fill: string): void {
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
}
function rrect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
fill: string | CanvasGradient,
): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill;
ctx.fill();
}
function bodyGloss(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, 'rgba(255, 255, 255, 0.22)');
g.addColorStop(0.45, 'rgba(255, 255, 255, 0)');
g.addColorStop(1, 'rgba(0, 0, 0, 0.25)');
rrect(ctx, x, y, w, h, r, g);
}
function drawDents(ctx: CanvasRenderingContext2D, dents: Dent[]): void {
for (const d of dents) {
ctx.fillStyle = 'rgba(40, 32, 28, 0.35)';
ctx.beginPath();
ctx.ellipse(d.x, d.y, d.r, d.r * 0.7, d.x, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 0.35;
ctx.beginPath();
ctx.moveTo(d.x - d.r, d.y - d.r * 0.3);
ctx.lineTo(d.x + d.r * 0.8, d.y + d.r * 0.4);
ctx.stroke();
}
}
export function drawCar(
ctx: CanvasRenderingContext2D,
look: CarLook,
x: number,
y: number,
angle: number,
wrecked = false,
braking = false,
dmg?: CarDamage,
): void {
const L = look.length;
const W = look.width;
const hl = L / 2;
const hw = W / 2;
const radius = look.model === 'sports' ? 3.4 : look.model === 'suv' ? 2 : 2.6;
const z = dmg?.z ?? 0;
const roll = dmg?.roll ?? 0;
const lift = 1 + z / 45;
const squash = Math.max(0.15, Math.abs(Math.cos(roll)));
const underside = (dmg?.flipped ?? false) !== Math.cos(roll) < 0;
// Ground shadow (offset in world space), further away the higher the car flies.
ctx.save();
ctx.translate(x + 1.3 + z * 0.35, y + 1.9 + z * 0.5);
ctx.rotate(angle);
rrect(ctx, -hl, -hw, L, W, radius, `rgba(0, 0, 0, ${0.3 / (1 + z / 40)})`);
ctx.restore();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(lift, lift * squash);
if (Math.abs(Math.cos(roll)) < 0.5) {
ctx.scale(1, 1 / squash);
drawSide(ctx, look, dmg!, Math.sin(roll) > 0 ? 1 : -1);
ctx.restore();
return;
}
if (underside) {
drawUnderside(ctx, look, dmg!);
ctx.restore();
return;
}
const color = wrecked ? shade(look.color, -0.3) : look.color;
if (look.model === 'motorcycle') {
drawBike(ctx, look, color, !(dmg?.riderGone ?? false), braking && !wrecked);
if (dmg?.burnt) {
ctx.globalAlpha = Math.min(0.8, dmg.burnt);
rrect(ctx, -hl, -hw, L, W, 1, '#1c1a19');
ctx.globalAlpha = 1;
}
ctx.restore();
return;
}
// Tyres peek out from under the body.
const wheels = dmg?.wheels ?? [true, true, true, true];
ctx.fillStyle = '#141416';
const wx = [0.3 * L, 0.3 * L, -0.3 * L, -0.3 * L];
const wy = [-hw + 0.1, hw - 1.5, -hw + 0.1, hw - 1.5];
for (let i = 0; i < 4; i++) {
if (wheels[i]) ctx.fillRect(wx[i] - 1.8, wy[i] - 0.3, 3.6, 1.7);
}
// Mirrors.
ctx.fillStyle = COLORS.ink;
ctx.fillRect(0.12 * L, -hw - 0.7, 1.1, 0.8);
ctx.fillRect(0.12 * L, hw - 0.1, 1.1, 0.8);
rrect(ctx, -hl, -hw, L, W, radius, color);
bodyGloss(ctx, -hl, -hw, L, W, radius);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.roundRect(-hl, -hw, L, W, radius);
ctx.stroke();
if (look.model === 'beater') {
// Mismatched primer door and rust.
rrect(ctx, -0.12 * L, -hw + 0.2, 0.26 * L, 2.4, 0.6, look.accent);
ctx.fillStyle = 'rgba(120, 62, 30, 0.75)';
ctx.beginPath();
ctx.arc(-hl + 2.5 + look.seed * 3, hw - 1.8, 1.1, 0, Math.PI * 2);
ctx.arc(hl - 3 - look.seed * 2, -hw + 1.5, 0.8, 0, Math.PI * 2);
ctx.fill();
}
paintTop(ctx, look, color, dmg?.hazard ?? false);
// Windscreen glint.
ctx.strokeStyle = COLORS.glint;
ctx.lineWidth = 0.45;
ctx.beginPath();
ctx.moveTo(0.2 * L, -hw + 2.2);
ctx.lineTo(0.24 * L, -hw + 3.6);
ctx.stroke();
if (look.rude) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(-hl + 0.6, -1.15, L - 1.2, 0.7);
ctx.fillRect(-hl + 0.6, 0.45, L - 1.2, 0.7);
rrect(ctx, -hl - 0.8, -hw + 0.3, 1.6, W - 0.6, 0.5, COLORS.ink);
}
// Lights.
ctx.fillStyle = COLORS.headlight;
ctx.fillRect(hl - 1.1, -hw + 0.9, 1, 1.6);
ctx.fillStyle = look.model === 'beater' && look.seed > 0.5 ? '#3a3a3a' : COLORS.headlight;
ctx.fillRect(hl - 1.1, hw - 2.5, 1, 1.6);
ctx.fillStyle = COLORS.taillight;
ctx.fillRect(-hl + 0.1, -hw + 0.9, 0.9, 1.6);
ctx.fillRect(-hl + 0.1, hw - 2.5, 0.9, 1.6);
if (braking && !wrecked) {
ctx.fillStyle = 'rgba(255, 40, 40, 0.4)';
ctx.beginPath();
ctx.arc(-hl, -hw + 1.7, 2.3, 0, Math.PI * 2);
ctx.arc(-hl, hw - 1.7, 2.3, 0, Math.PI * 2);
ctx.fill();
}
if (dmg) {
drawDamage(ctx, look, dmg, radius);
} else if (wrecked) {
// Parked junk: crumpled hood and cracked glass.
poly(ctx, [hl, -hw, hl - 3.5, -hw + 1.5, hl - 2, -1, hl - 4, 1, hl - 2.5, hw - 1.4, hl, hw], shade(look.color, -0.65));
ctx.fillStyle = 'rgba(20, 16, 14, 0.35)';
ctx.beginPath();
ctx.ellipse(-1, 0.5, 3.5, 2.4, 0.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
/** Roof, glass and model-specific details, in the car's local frame. */
function paintTop(ctx: CanvasRenderingContext2D, look: CarLook, color: string, flash: boolean): void {
const L = look.length;
const W = look.width;
const hl = L / 2;
const hw = W / 2;
const glassFront = (a: number, b: number, inset = 1.2) => poly(ctx, [a, -hw + inset, b, -hw + inset + 0.7, b, hw - inset - 0.7, a, hw - inset], COLORS.glass);
const glassBack = (a: number, b: number) => poly(ctx, [a, -hw + 1.2, b, -hw + 1.8, b, hw - 1.8, a, hw - 1.2], COLORS.glass);
const roof = (a: number, b: number) => rrect(ctx, a, -hw + 1.2, b - a, W - 2.4, 1.2, shade(color, 0.14));
switch (look.model) {
case 'sports':
poly(ctx, [-0.14 * L, -hw + 1.4, 0.1 * L, -hw + 1.6, 0.1 * L, hw - 1.6, -0.14 * L, hw - 1.4], shade(color, -0.25));
poly(ctx, [0.1 * L, -hw + 1.4, 0.3 * L, -hw + 2, 0.3 * L, hw - 2, 0.1 * L, hw - 1.4], COLORS.glass);
poly(ctx, [-0.14 * L, -hw + 1.5, -0.24 * L, -hw + 2.1, -0.24 * L, hw - 2.1, -0.14 * L, hw - 1.5], COLORS.glass);
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(0.34 * L, -1.4, 1.8, 0.5);
ctx.fillRect(0.34 * L, 0.9, 1.8, 0.5);
return;
case 'compact':
glassFront(0.16 * L, 0.3 * L);
glassBack(-0.3 * L, -0.38 * L);
roof(-0.3 * L, 0.16 * L);
return;
case 'pickup': {
glassFront(0.12 * L, 0.24 * L);
roof(-0.08 * L, 0.12 * L);
ctx.fillStyle = COLORS.glass;
ctx.fillRect(-0.1 * L, -hw + 1.6, 0.6, W - 3.2);
rrect(ctx, -hl + 0.8, -hw + 0.9, 0.38 * L, W - 1.8, 0.6, shade(color, -0.4));
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.3;
ctx.beginPath();
for (let y = -hw + 2; y < hw - 1; y += 1.4) {
ctx.moveTo(-hl + 1, y);
ctx.lineTo(-hl + 0.38 * L, y);
}
ctx.stroke();
if (look.seed < 0.4) rrect(ctx, -hl + 1.6, -2.4, 4.2, 4.8, 0.6, '#e2c25a');
else if (look.seed < 0.75) {
rrect(ctx, -hl + 1.4, -2.6, 2.8, 2.6, 0.3, '#9a6b3c');
rrect(ctx, -hl + 1.6, 0.4, 2.8, 2.4, 0.3, '#8a5b2c');
}
return;
}
case 'van':
glassFront(0.28 * L, 0.38 * L);
rrect(ctx, -hl + 0.8, -hw + 1, 0.76 * L, W - 2, 1, shade(color, 0.18));
ctx.fillStyle = look.accent;
ctx.fillRect(-hl + 2, -0.6, 0.6 * L, 1.2);
return;
case 'bus': {
rrect(ctx, -hl + 0.8, -hw + 1, L - 2.6, W - 2, 1.2, shade(color, 0.12));
ctx.fillStyle = COLORS.glass;
ctx.fillRect(-hl + 1.5, -hw + 0.4, L - 4, 0.9);
ctx.fillRect(-hl + 1.5, hw - 1.3, L - 4, 0.9);
glassFront(hl - 2.4, hl - 0.6, 0.8);
for (const x of [-0.25 * L, 0.05 * L]) rrect(ctx, x, -2, 5, 4, 0.5, '#c9ccd1');
ctx.fillStyle = '#ffb000';
ctx.fillRect(hl - 1.2, -1.6, 0.8, 3.2);
return;
}
case 'limo': {
glassFront(0.3 * L, 0.38 * L);
glassBack(-0.3 * L, -0.36 * L);
roof(-0.3 * L, 0.3 * L);
ctx.fillStyle = COLORS.glass;
for (let x = -0.24 * L; x < 0.26 * L; x += 4) {
ctx.fillRect(x, -hw + 1.2, 3, 0.7);
ctx.fillRect(x, hw - 1.9, 3, 0.7);
}
rrect(ctx, -2, -1.3, 4, 2.6, 0.6, COLORS.glass);
return;
}
case 'icecream': {
glassFront(0.3 * L, 0.4 * L);
rrect(ctx, -hl + 0.8, -hw + 1, 0.8 * L, W - 2, 1, '#fff6f2');
ctx.fillStyle = '#ff8fc7';
for (let x = -hl + 1.5; x < 0.28 * L; x += 3) ctx.fillRect(x, -hw + 1, 1.4, W - 2);
// The giant cone on the roof.
poly(ctx, [-3.5, 0, 1.2, -1.9, 1.2, 1.9], '#d9a75b');
ctx.fillStyle = '#ffd1e6';
ctx.beginPath();
ctx.arc(1.8, 0, 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e8434f';
ctx.beginPath();
ctx.arc(2.6, -0.6, 0.6, 0, Math.PI * 2);
ctx.fill();
return;
}
case 'tow': {
glassFront(0.22 * L, 0.32 * L);
roof(0.02 * L, 0.22 * L);
rrect(ctx, -hl + 0.8, -hw + 1, 0.5 * L, W - 2, 0.6, '#3a3a40');
ctx.strokeStyle = '#d4d8dd';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-0.05 * L, 0);
ctx.lineTo(-hl - 1.5, 0);
ctx.stroke();
ctx.fillStyle = '#9a9ca2';
ctx.fillRect(-hl - 2.2, -0.8, 1.2, 1.6);
rrect(ctx, 0.05 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ffb000' : '#8a6a1a');
if (flash) {
ctx.fillStyle = 'rgba(255, 176, 0, 0.35)';
ctx.beginPath();
ctx.arc(0.05 * L, 0, 6, 0, Math.PI * 2);
ctx.fill();
}
return;
}
case 'ambulance': {
glassFront(0.32 * L, 0.42 * L);
rrect(ctx, -hl + 0.8, -hw + 1, 0.74 * L, W - 2, 1, '#fdfdfd');
ctx.fillStyle = '#d93a3a';
ctx.fillRect(-hl + 0.8, -0.6, 0.74 * L, 1.2);
ctx.fillRect(-4, -2.8, 3, 5.6);
ctx.fillRect(-5.3, -1.5, 5.6, 3);
rrect(ctx, 0.22 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ff2d2d' : '#7a1515');
if (flash) {
ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
ctx.beginPath();
ctx.arc(0.22 * L, 0, 7, 0, Math.PI * 2);
ctx.fill();
}
return;
}
case 'firetruck': {
glassFront(0.36 * L, 0.44 * L);
rrect(ctx, -hl + 1, -hw + 1.2, 0.8 * L, W - 2.4, 1, shade(color, -0.1));
// The ladder down the middle.
ctx.fillStyle = '#d4d8dd';
ctx.fillRect(-hl + 2, -1.6, 0.68 * L, 0.5);
ctx.fillRect(-hl + 2, 1.1, 0.68 * L, 0.5);
for (let x = -hl + 3; x < -hl + 2 + 0.68 * L; x += 2) ctx.fillRect(x, -1.6, 0.4, 3.2);
ctx.fillStyle = '#f4f1ea';
ctx.fillRect(-hl + 1, -hw + 1.2, 0.8 * L, 0.6);
ctx.fillRect(-hl + 1, hw - 1.8, 0.8 * L, 0.6);
rrect(ctx, 0.3 * L, -hw + 1.4, 1.4, W - 2.8, 0.4, flash ? '#ff2d2d' : '#7a1515');
if (flash) {
ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
ctx.beginPath();
ctx.arc(0.3 * L, 0, 8, 0, Math.PI * 2);
ctx.fill();
}
return;
}
case 'garbage': {
glassFront(0.34 * L, 0.42 * L);
rrect(ctx, 0.22 * L, -hw + 1, 0.12 * L, W - 2, 0.6, shade(color, 0.12));
// The compactor body: ribbed, with the hopper at the back.
rrect(ctx, -hl + 0.6, -hw + 0.8, 0.7 * L, W - 1.6, 1, shade(color, -0.12));
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 0.4;
ctx.beginPath();
for (let x = -hl + 3; x < 0.18 * L; x += 2.4) {
ctx.moveTo(x, -hw + 1);
ctx.lineTo(x, hw - 1);
}
ctx.stroke();
rrect(ctx, -hl - 0.4, -hw + 1.4, 2.6, W - 2.8, 0.5, '#3a3d42');
ctx.fillStyle = '#f4f1ea';
ctx.fillRect(-hl + 4, -0.5, 0.4 * L, 1);
rrect(ctx, 0.2 * L, -hw + 1.6, 1.2, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
return;
}
case 'sweeper': {
glassFront(0.18 * L, 0.36 * L, 1.6);
roof(-0.04 * L, 0.18 * L);
rrect(ctx, -hl + 0.8, -hw + 1.2, 0.38 * L, W - 2.4, 0.8, shade(color, -0.15));
// Rotating brushes at the front corners.
ctx.fillStyle = '#c9a24a';
for (const sy of [-1, 1]) {
ctx.beginPath();
ctx.arc(hl - 1.5, sy * (hw - 0.6), 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = '#7a5a1a';
ctx.lineWidth = 0.3;
ctx.beginPath();
for (const sy of [-1, 1]) {
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
ctx.moveTo(hl - 1.5, sy * (hw - 0.6));
ctx.lineTo(hl - 1.5 + Math.cos(a + look.seed * 6) * 2, sy * (hw - 0.6) + Math.sin(a + look.seed * 6) * 2);
}
}
ctx.stroke();
rrect(ctx, -0.02 * L, -hw + 1.6, 1.1, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
return;
}
case 'works':
case 'roadside': {
glassFront(0.16 * L, 0.28 * L);
roof(-0.06 * L, 0.16 * L);
if (look.model === 'works') {
// Utility bed with tool boxes and a ladder rack.
rrect(ctx, -hl + 0.8, -hw + 0.9, 0.42 * L, W - 1.8, 0.6, '#d4d8dd');
rrect(ctx, -hl + 1.4, -hw + 1.2, 0.34 * L, 1.6, 0.3, '#8d9096');
rrect(ctx, -hl + 1.4, hw - 2.8, 0.34 * L, 1.6, 0.3, '#8d9096');
ctx.fillStyle = '#c9a24a';
ctx.fillRect(-hl + 1, -0.4, 0.44 * L, 0.8);
} else {
// Roadside assistance van: high roof with a stripe.
rrect(ctx, -hl + 0.8, -hw + 1, 0.56 * L, W - 2, 0.8, shade(color, 0.16));
ctx.fillStyle = look.accent;
ctx.fillRect(-hl + 1.2, -hw + 1, 0.56 * L, 1.1);
ctx.fillRect(-hl + 1.2, hw - 2.1, 0.56 * L, 1.1);
}
rrect(ctx, -0.08 * L, -hw + 1.5, 1.2, W - 3, 0.4, flash ? '#ffb000' : '#8a6a1a');
if (flash) {
ctx.fillStyle = 'rgba(255, 176, 0, 0.35)';
ctx.beginPath();
ctx.arc(-0.08 * L, 0, 5, 0, Math.PI * 2);
ctx.fill();
}
return;
}
case 'dump': {
glassFront(0.3 * L, 0.4 * L);
rrect(ctx, 0.2 * L, -hw + 1, 0.12 * L, W - 2, 0.6, shade(color, 0.12));
// Tipper bed full of asphalt.
rrect(ctx, -hl + 0.5, -hw + 0.6, 0.66 * L, W - 1.2, 0.6, shade(color, -0.25));
rrect(ctx, -hl + 1.5, -hw + 1.6, 0.6 * L, W - 3.2, 0.5, '#2b2b2e');
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
for (let i = 0; i < 6; i++) ctx.fillRect(-hl + 2 + i * 2.2, -hw + 2 + (i % 3) * 1.8, 1.4, 1);
rrect(ctx, 0.18 * L, -hw + 1.6, 1.2, W - 3.2, 0.4, flash ? '#ffb000' : '#8a6a1a');
return;
}
case 'police': {
ctx.fillStyle = '#f4f4f4';
ctx.fillRect(-0.18 * L, -hw + 0.1, 0.36 * L, 1.4);
ctx.fillRect(-0.18 * L, hw - 1.5, 0.36 * L, 1.4);
glassFront(0.14 * L, 0.28 * L);
glassBack(-0.22 * L, -0.3 * L);
roof(-0.22 * L, 0.14 * L);
const on = flash;
rrect(ctx, -1, -hw + 1.4, 2, (W - 2.8) / 2, 0.4, on ? '#ff2d2d' : '#7a1515');
rrect(ctx, -1, 0, 2, (W - 2.8) / 2, 0.4, on ? '#1e4bff' : '#14256b');
if (on) {
ctx.fillStyle = 'rgba(255, 60, 60, 0.35)';
ctx.beginPath();
ctx.arc(0, -2, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(60, 90, 255, 0.35)';
ctx.beginPath();
ctx.arc(0, 2, 5, 0, Math.PI * 2);
ctx.fill();
}
return;
}
default: {
const roofStart = look.model === 'suv' ? -0.36 * L : -0.22 * L;
const roofEnd = 0.14 * L;
glassFront(roofEnd, 0.28 * L);
glassBack(roofStart, roofStart - 0.08 * L);
roof(roofStart, roofEnd);
if (look.model === 'suv') {
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(roofStart + 1, -1.6);
ctx.lineTo(roofEnd - 1, -1.6);
ctx.moveTo(roofStart + 1, 1.6);
ctx.lineTo(roofEnd - 1, 1.6);
ctx.stroke();
}
if (look.model === 'taxi') {
rrect(ctx, -2, -1.6, 3.4, 3.2, 0.6, '#fff7c2');
ctx.fillStyle = COLORS.ink;
for (let i = 0; i < 6; i += 2) {
ctx.fillRect(-hl + 2 + i * 1.6, -hw + 0.1, 1.6, 0.7);
ctx.fillRect(-hl + 2 + i * 1.6, hw - 0.8, 1.6, 0.7);
}
}
}
}
}
/** A motorbike and (unless thrown) its rider. */
function drawBike(ctx: CanvasRenderingContext2D, look: CarLook, color: string, rider: boolean, braking: boolean): void {
rrect(ctx, 1.6, -0.7, 2.6, 1.4, 0.7, '#141416');
rrect(ctx, -3.9, -0.8, 2.6, 1.6, 0.8, '#141416');
rrect(ctx, -2.4, -1, 4.6, 2, 1, color);
ctx.strokeStyle = '#9a9ca2';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(1.8, -1.8);
ctx.lineTo(1.8, 1.8);
ctx.stroke();
ctx.fillStyle = braking ? '#ff2a2a' : COLORS.taillight;
ctx.fillRect(-3.9, -0.3, 0.5, 0.6);
if (!rider) return;
ctx.fillStyle = look.accent;
ctx.beginPath();
ctx.ellipse(-0.6, 0, 1.4, 1.7, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = shade(look.color, 0.3);
ctx.beginPath();
ctx.arc(0.3, 0, 1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = COLORS.glass;
ctx.fillRect(0.8, -0.5, 0.5, 1);
}
export type AnimalKind = 'deer' | 'moose' | 'bear' | 'fox' | 'raccoon' | 'rabbit' | 'dog' | 'cow';
/** Wild animals seen from above. `pose`: walking, tumbling through the air, or lying dead. */
export function drawAnimal(ctx: CanvasRenderingContext2D, kind: AnimalKind, x: number, y: number, angle: number, stride: number, pose: 'walk' | 'fly' | 'dead', z = 0, spin = 0): void {
const size: Record<AnimalKind, [number, number, string, string]> = {
deer: [6.5, 2.6, '#9b6a3e', '#d8b88a'],
moose: [9.5, 3.8, '#4a3423', '#6d5136'],
bear: [8, 4.6, '#1d1a18', '#3a332e'],
fox: [4.6, 1.6, '#d8682a', '#f2f0ea'],
raccoon: [4, 2.2, '#7d7b78', '#2a2a2a'],
rabbit: [2.6, 1.6, '#b39a7a', '#f4f0e8'],
dog: [3.6, 1.7, '#c8a06a', '#8a5a3c'],
cow: [8.5, 3.8, '#f4f1ea', '#1b1b1f'],
};
const [len, wid, body, accent] = size[kind];
const lift = 1 + z / 30;
ctx.fillStyle = `rgba(0, 0, 0, ${0.25 / (1 + z / 20)})`;
ctx.beginPath();
ctx.ellipse(x + 0.8 + z * 0.35, y + 1 + z * 0.5, len / 2, wid / 2 + 0.3, angle, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle + spin);
ctx.scale(lift, lift);
const hl = len / 2;
const legs = (spread: number) => {
ctx.strokeStyle = shade(body, -0.3);
ctx.lineWidth = Math.max(0.5, wid * 0.22);
ctx.lineCap = 'round';
ctx.beginPath();
for (const [lx, side] of [
[hl * 0.55, -1],
[hl * 0.55, 1],
[-hl * 0.55, -1],
[-hl * 0.55, 1],
]) {
const swing = pose === 'walk' ? Math.sin(stride + lx) * 0.8 : 0;
ctx.moveTo(lx, (side * wid) / 3);
ctx.lineTo(lx + swing, side * (wid / 2 + spread));
}
ctx.stroke();
ctx.lineCap = 'butt';
};
if (pose === 'dead') {
legs(wid * 0.9);
} else {
legs(0.6);
}
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(0, 0, hl, wid / 2, 0, 0, Math.PI * 2);
ctx.fill();
// Head.
ctx.beginPath();
ctx.ellipse(hl + wid * 0.25, 0, wid * 0.45, wid * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
if (kind === 'deer' || kind === 'moose') {
ctx.strokeStyle = kind === 'moose' ? '#cdb58c' : '#e2cfa8';
ctx.lineWidth = kind === 'moose' ? 0.9 : 0.45;
ctx.beginPath();
ctx.moveTo(hl, -wid * 0.2);
ctx.lineTo(hl - 0.6, -wid * (kind === 'moose' ? 1.1 : 0.8));
ctx.moveTo(hl, wid * 0.2);
ctx.lineTo(hl - 0.6, wid * (kind === 'moose' ? 1.1 : 0.8));
ctx.stroke();
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(-hl, 0, wid * 0.25, 0, Math.PI * 2);
ctx.fill();
} else if (kind === 'fox') {
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(-hl - 1.4, 0, 1.8, 0.7, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(-hl - 2.8, 0, 0.5, 0, Math.PI * 2);
ctx.fill();
} else if (kind === 'raccoon') {
ctx.fillStyle = accent;
ctx.fillRect(hl, -wid * 0.3, 0.6, wid * 0.6);
for (let i = 0; i < 3; i++) {
ctx.fillStyle = i % 2 ? body : accent;
ctx.fillRect(-hl - 0.8 - i * 0.7, -0.45, 0.7, 0.9);
}
} else if (kind === 'cow') {
ctx.fillStyle = accent;
for (const [px, py, r] of [
[-1.5, -0.6, 1.2],
[1.8, 0.8, 0.9],
[-3, 0.9, 0.7],
]) {
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#f2a6b4';
ctx.beginPath();
ctx.arc(hl + wid * 0.55, 0, wid * 0.22, 0, Math.PI * 2);
ctx.fill();
} else if (kind === 'dog') {
ctx.strokeStyle = body;
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(-hl, 0);
ctx.lineTo(-hl - 1.4, Math.sin(stride * 3) * 0.8);
ctx.stroke();
} else if (kind === 'rabbit') {
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(-hl, 0, 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = body;
ctx.fillRect(hl, -0.6, 1.2, 0.35);
ctx.fillRect(hl, 0.25, 1.2, 0.35);
}
ctx.restore();
}
/** Crush zones, shattered glass, soot and blinking hazards over a car body. */
function drawDamage(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage, radius: number): void {
const L = look.length;
const W = look.width;
const hl = L / 2;
const hw = W / 2;
const [front, rear, left, right] = dmg.crumple;
const dark = shade(look.color, -0.6);
const jag = (n: number, seed: number) => ((Math.sin(n * 12.9898 + seed * 78.233) * 43758.5453) % 1 + 1) % 1;
// Crushed nose and tail: cut the panel back with a jagged, dark edge.
if (front > 0.3) {
const pts = [hl + 0.6, -hw - 0.6];
for (let i = 0; i <= 6; i++) pts.push(hl - front * (0.6 + jag(i, look.seed) * 0.6), -hw + (W * i) / 6);
pts.push(hl + 0.6, hw + 0.6);
poly(ctx, pts, dark);
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(hl - front * 1.4, -hw + 0.5, front * 0.6, W - 1);
}
if (rear > 0.3) {
const pts = [-hl - 0.6, -hw - 0.6];
for (let i = 0; i <= 6; i++) pts.push(-hl + rear * (0.6 + jag(i + 9, look.seed) * 0.6), -hw + (W * i) / 6);
pts.push(-hl - 0.6, hw + 0.6);
poly(ctx, pts, dark);
}
for (const [depth, sign] of [
[left, -1],
[right, 1],
] as const) {
if (depth < 0.3) continue;
const c = (jag(sign + 3, look.seed) - 0.5) * L * 0.5;
const span = L * 0.22 + depth;
poly(ctx, [c - span, sign * (hw + 0.5), c - span * 0.3, sign * (hw - depth), c + span * 0.4, sign * (hw - depth * 0.8), c + span, sign * (hw + 0.5)], dark);
}
if (dmg.glass) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 0.3;
ctx.beginPath();
ctx.moveTo(0.22 * L, -1.5);
ctx.lineTo(0.17 * L, 0.3);
ctx.lineTo(0.25 * L, 1.8);
ctx.moveTo(0.17 * L, 0.3);
ctx.lineTo(0.27 * L, -0.4);
ctx.moveTo(-0.26 * L, -1.4);
ctx.lineTo(-0.22 * L, 1.2);
ctx.stroke();
}
if (dmg.burnt > 0) {
ctx.globalAlpha = Math.min(0.85, dmg.burnt);
rrect(ctx, -hl, -hw, L, W, radius, '#1c1a19');
ctx.globalAlpha = 1;
}
if (dmg.hazard) {
ctx.fillStyle = 'rgba(255, 170, 30, 0.95)';
for (const [x, y] of [
[hl - 1, -hw + 0.6],
[hl - 1, hw - 0.6],
[-hl + 1, -hw + 0.6],
[-hl + 1, hw - 0.6],
]) {
ctx.beginPath();
ctx.arc(x, y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(255, 170, 30, 0.25)';
ctx.beginPath();
ctx.arc(0, 0, hl + 2, 0, Math.PI * 2);
ctx.fill();
}
}
/** A car lying on its side: we see one side panel, its windows and the wheels sticking out. */
function drawSide(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage, side: number): void {
const L = look.length;
const hl = L / 2;
const H = look.model === 'suv' ? 7.4 : look.model === 'sports' ? 5.2 : 6.2;
const hh = H / 2;
rrect(ctx, -hl, -hh, L, H, 1.6, shade(look.color, -0.1));
bodyGloss(ctx, -hl, -hh, L, H, 1.6);
// Windows along the cabin.
ctx.fillStyle = COLORS.glass;
ctx.fillRect(-0.25 * L, side > 0 ? -hh + 0.6 : hh - 2.4, 0.22 * L, 1.8);
ctx.fillRect(0.02 * L, side > 0 ? -hh + 0.6 : hh - 2.4, 0.2 * L, 1.8);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(0, -hh);
ctx.lineTo(0, hh);
ctx.stroke();
// Wheels stick out of the bottom edge.
const wy = hh * side * -1;
for (const [i, wx] of [
[0, 0.3 * L],
[2, -0.3 * L],
] as const) {
if (!dmg.wheels[i]) continue;
rrect(ctx, wx - 2.2, wy - 1.3, 4.4, 2.6, 1, '#121214');
}
if (dmg.burnt > 0) {
ctx.globalAlpha = Math.min(0.8, dmg.burnt);
rrect(ctx, -hl, -hh, L, H, 1.6, '#1c1a19');
ctx.globalAlpha = 1;
}
}
/** A car on its roof: chassis rails, axles, exhaust and whatever wheels are left. */
function drawUnderside(ctx: CanvasRenderingContext2D, look: CarLook, dmg: CarDamage): void {
const L = look.length;
const W = look.width;
const hl = L / 2;
const hw = W / 2;
rrect(ctx, -hl, -hw, L, W, 2.4, shade(look.color, -0.45));
rrect(ctx, -hl + 1, -hw + 1, L - 2, W - 2, 1.6, '#2b2b2f');
ctx.strokeStyle = '#4a4a50';
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.moveTo(-hl + 2, -1.6);
ctx.lineTo(hl - 2, -1.6);
ctx.moveTo(-hl + 2, 1.6);
ctx.lineTo(hl - 2, 1.6);
ctx.moveTo(0.3 * L, -hw + 1);
ctx.lineTo(0.3 * L, hw - 1);
ctx.moveTo(-0.3 * L, -hw + 1);
ctx.lineTo(-0.3 * L, hw - 1);
ctx.stroke();
ctx.strokeStyle = '#8a8a90';
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(-hl + 1, 2.4);
ctx.lineTo(0.1 * L, 2.4);
ctx.stroke();
const wx = [0.3 * L, 0.3 * L, -0.3 * L, -0.3 * L];
const wy = [-hw + 1.4, hw - 1.4, -hw + 1.4, hw - 1.4];
for (let i = 0; i < 4; i++) {
if (!dmg.wheels[i]) continue;
rrect(ctx, wx[i] - 2, wy[i] - 1.1, 4, 2.2, 0.8, '#121214');
ctx.fillStyle = '#9a9ca2';
ctx.beginPath();
ctx.arc(wx[i], wy[i], 0.6, 0, Math.PI * 2);
ctx.fill();
}
if (dmg.burnt > 0) {
ctx.globalAlpha = Math.min(0.8, dmg.burnt);
rrect(ctx, -hl, -hw, L, W, 2.4, '#1c1a19');
ctx.globalAlpha = 1;
}
}
export interface PedLook {
shirt: string;
pants: string;
skin: string;
hair: string;
bag?: string;
hat?: string;
}
/** A pedestrian seen from above. `pose`: walking, lying knocked out, or tumbling through the air. */
export function drawPed(
ctx: CanvasRenderingContext2D,
look: PedLook,
x: number,
y: number,
angle: number,
stride: number,
pose: 'walk' | 'down' | 'fly',
z = 0,
spin = 0,
time = 0,
): void {
const lift = 1 + z / 30;
ctx.fillStyle = `rgba(0, 0, 0, ${0.28 / (1 + z / 20)})`;
ctx.beginPath();
ctx.ellipse(x + 0.6 + z * 0.35, y + 0.9 + z * 0.5, pose === 'down' ? 4.2 : 2.4, pose === 'down' ? 1.8 : 1.8, angle, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle + spin);
ctx.scale(lift, lift);
if (pose === 'walk') {
const s = Math.sin(stride) * 1.4;
ctx.fillStyle = look.pants;
ctx.beginPath();
ctx.ellipse(s, -0.8, 1.1, 0.6, 0, 0, Math.PI * 2);
ctx.ellipse(-s, 0.8, 1.1, 0.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = look.skin;
ctx.beginPath();
ctx.arc(-s * 0.8, -2.3, 0.55, 0, Math.PI * 2);
ctx.arc(s * 0.8, 2.3, 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = look.shirt;
ctx.beginPath();
ctx.ellipse(0, 0, 1.3, 2.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = look.hair;
ctx.beginPath();
ctx.arc(0.2, 0, 1.15, 0, Math.PI * 2);
ctx.fill();
if (look.bag) {
rrect(ctx, -0.6 + s * 0.4, 2.2, 1.6, 1.3, 0.3, look.bag);
}
if (look.hat) {
poly(ctx, [1.4, 0, -0.6, -0.8, -0.6, 0.8], look.hat);
}
} else {
// Sprawled out: limbs flung wide.
ctx.strokeStyle = look.pants;
ctx.lineWidth = 1.1;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-1, -0.4);
ctx.lineTo(-3.8, -1.6);
ctx.moveTo(-1, 0.4);
ctx.lineTo(-3.6, 1.8);
ctx.stroke();
ctx.strokeStyle = look.shirt;
ctx.beginPath();
ctx.moveTo(0.8, -0.6);
ctx.lineTo(2.2, -2.8);
ctx.moveTo(0.8, 0.6);
ctx.lineTo(2.6, 2.4);
ctx.stroke();
ctx.lineCap = 'butt';
ctx.fillStyle = look.shirt;
ctx.beginPath();
ctx.ellipse(0, 0, 1.9, 1.3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = look.skin;
ctx.beginPath();
ctx.arc(2.6, 0, 1.05, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = look.hair;
ctx.beginPath();
ctx.arc(2.9, 0, 0.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
if (pose === 'down') {
// Cartoon stars circling a dazed head.
ctx.fillStyle = '#ffd84a';
for (let i = 0; i < 3; i++) {
const a = time * 4 + (i * Math.PI * 2) / 3;
const sx = x + Math.cos(angle) * 2.6 + Math.cos(a) * 3;
const sy = y + Math.sin(angle) * 2.6 + Math.sin(a) * 1.6 - 2;
ctx.beginPath();
for (let k = 0; k < 10; k++) {
const r = k % 2 === 0 ? 1.1 : 0.45;
const t = (k * Math.PI) / 5;
ctx.lineTo(sx + Math.cos(t) * r, sy + Math.sin(t) * r);
}
ctx.closePath();
ctx.fill();
}
}
}
export function drawTruck(ctx: CanvasRenderingContext2D, t: TruckPose): void {
const roll = t.roll ?? 0;
if (Math.abs(roll) > 1.2) {
drawTruckOnSide(ctx, t, Math.sign(roll));
return;
}
if (Math.abs(roll) > 0.02) {
// Leaning: the body slides toward the outside of the turn and the shadow stretches.
const lean = Math.sin(roll) * 5;
const off = (a: number) => ({ x: -Math.sin(a) * lean, y: Math.cos(a) * lean });
const o1 = off(t.trailerAngle);
const o2 = off(t.angle);
t = { ...t, x: t.x + o2.x, y: t.y + o2.y, trailer: { x: t.trailer.x + o1.x, y: t.trailer.y + o1.y } };
}
// Shadows first so the cab's shadow doesn't land on the trailer.
for (const [px, py, a, l, w] of [
[t.trailer.x, t.trailer.y, t.trailerAngle, 38, 12],
[t.x, t.y, t.angle, 18, 12],
] as const) {
ctx.save();
ctx.translate(px + 1.8, py + 2.6);
ctx.rotate(a);
rrect(ctx, -l / 2, -w / 2, l, w, 2, 'rgba(0, 0, 0, 0.32)');
ctx.restore();
}
// Trailer.
ctx.save();
ctx.translate(t.trailer.x, t.trailer.y);
ctx.rotate(t.trailerAngle);
ctx.fillStyle = '#141416';
for (const tx of [-15.5, -11]) {
ctx.fillRect(tx - 1.9, -6.9, 3.8, 1.8);
ctx.fillRect(tx - 1.9, 5.1, 3.8, 1.8);
}
rrect(ctx, -19, -6, 38, 12, 1.4, '#55524a');
rrect(ctx, -18.6, -5.6, 37.2, 11.2, 1.2, COLORS.trailer);
bodyGloss(ctx, -18.6, -5.6, 37.2, 11.2, 1.2);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.09)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = -15; x < 18; x += 3.2) {
ctx.moveTo(x, -5.4);
ctx.lineTo(x, 5.4);
}
ctx.stroke();
ctx.fillStyle = COLORS.cab;
ctx.fillRect(-18.6, -1.3, 37.2, 2.6);
ctx.fillStyle = '#fff';
ctx.font = '700 2.1px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('BIG RIG RUSH', 0, 0.1);
ctx.fillStyle = '#8a8478';
ctx.fillRect(-18.6, -5.6, 0.8, 11.2);
ctx.fillStyle = COLORS.taillight;
ctx.fillRect(-19.2, -5.4, 0.9, 2);
ctx.fillRect(-19.2, 3.4, 0.9, 2);
if (t.braking) {
ctx.fillStyle = 'rgba(255, 40, 40, 0.45)';
ctx.beginPath();
ctx.arc(-19, -4.4, 2.8, 0, Math.PI * 2);
ctx.arc(-19, 4.4, 2.8, 0, Math.PI * 2);
ctx.fill();
}
drawDents(ctx, t.trailerDents);
ctx.restore();
// Cab.
ctx.save();
ctx.translate(t.x, t.y);
ctx.rotate(t.angle);
ctx.fillStyle = '#141416';
ctx.fillRect(-6, -7, 4, 1.9);
ctx.fillRect(-6, 5.1, 4, 1.9);
for (const side of [-1, 1]) {
ctx.save();
ctx.translate(5, side * 6);
ctx.rotate((t.steer ?? 0) * 0.55);
ctx.fillRect(-2, -0.95, 4, 1.9);
ctx.restore();
}
rrect(ctx, -9.5, -3.2, 5, 6.4, 1, '#2a2a30');
ctx.fillStyle = COLORS.ink;
ctx.fillRect(2.4, -7, 1.2, 1.2);
ctx.fillRect(2.4, 5.8, 1.2, 1.2);
rrect(ctx, -6.8, -6, 16, 12, 2.4, COLORS.cab);
bodyGloss(ctx, -6.8, -6, 16, 12, 2.4);
rrect(ctx, -6, -4.9, 7.6, 9.8, 1.8, shade(COLORS.cab, 0.16));
rrect(ctx, -4.6, -3.2, 4.6, 6.4, 1.2, shade(COLORS.cab, -0.12));
poly(ctx, [1.7, -5, 4.2, -5.4, 4.2, 5.4, 1.7, 5], COLORS.glass);
ctx.strokeStyle = COLORS.glint;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(2.4, -4);
ctx.lineTo(3.6, -1.5);
ctx.stroke();
ctx.fillStyle = shade(COLORS.cab, -0.4);
ctx.fillRect(8.4, -4.6, 0.8, 9.2);
ctx.fillStyle = COLORS.chrome;
ctx.fillRect(8.9, -4, 0.5, 8);
ctx.fillStyle = COLORS.headlight;
ctx.fillRect(8.6, -5.4, 0.9, 1.8);
ctx.fillRect(8.6, 3.6, 0.9, 1.8);
ctx.fillStyle = COLORS.chrome;
ctx.beginPath();
ctx.arc(-6.2, -5.4, 0.9, 0, Math.PI * 2);
ctx.arc(-6.2, 5.4, 0.9, 0, Math.PI * 2);
ctx.fill();
drawDents(ctx, t.cabDents);
ctx.restore();
}
/** The rig lying on its side: the trailer's side wall and the cab's door face the sky. */
function drawTruckOnSide(ctx: CanvasRenderingContext2D, t: TruckPose, side: number): void {
for (const [px, py, a, l] of [
[t.trailer.x, t.trailer.y, t.trailerAngle, 38],
[t.x, t.y, t.angle, 18],
] as const) {
ctx.save();
ctx.translate(px + 2, py + 3);
ctx.rotate(a);
rrect(ctx, -l / 2, -8, l, 16, 2, 'rgba(0, 0, 0, 0.35)');
ctx.restore();
}
ctx.save();
ctx.translate(t.trailer.x, t.trailer.y);
ctx.rotate(t.trailerAngle);
rrect(ctx, -19, -7.5, 38, 15, 1.2, '#55524a');
rrect(ctx, -18.6, -7.1, 37.2, 14.2, 1, COLORS.trailer);
bodyGloss(ctx, -18.6, -7.1, 37.2, 14.2, 1);
ctx.fillStyle = COLORS.cab;
ctx.fillRect(-18.6, -1.6, 37.2, 3.2);
ctx.fillStyle = '#fff';
ctx.font = '800 3px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('BIG RIG RUSH', 0, 0.2);
ctx.fillStyle = '#141416';
for (const x of [-15.5, -11]) rrect(ctx, x - 2.2, side > 0 ? -9.5 : 6.5, 4.4, 3, 1, '#141416');
drawDents(ctx, t.trailerDents);
ctx.restore();
ctx.save();
ctx.translate(t.x, t.y);
ctx.rotate(t.angle);
rrect(ctx, -7.5, -7, 17, 14, 2.4, COLORS.cab);
bodyGloss(ctx, -7.5, -7, 17, 14, 2.4);
ctx.fillStyle = COLORS.glass;
ctx.fillRect(1.5, -4.5, 4, 5);
ctx.fillStyle = shade(COLORS.cab, -0.3);
ctx.fillRect(-6.5, -6, 7, 12);
for (const x of [-4, 5]) rrect(ctx, x - 2.2, side > 0 ? -9 : 6, 4.4, 3, 1, '#141416');
drawDents(ctx, t.cabDents);
ctx.restore();
}
/** A helicopter seen from above: shadow on the ground, body lifted by its altitude, spinning rotor. */
export function drawHelicopter(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, z: number, rotor: number, color: string, accent: string, flash: boolean): void {
const lift = 1 + z / 60;
ctx.save();
ctx.translate(x + 2 + z * 0.35, y + 3 + z * 0.5);
ctx.rotate(angle);
ctx.fillStyle = `rgba(0, 0, 0, ${0.28 / (1 + z / 60)})`;
ctx.beginPath();
ctx.ellipse(1, 0, 6, 3.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(-12, -0.7, 8, 1.4);
ctx.beginPath();
ctx.arc(1, 0, 10, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0, 0, 0, ${0.08 / (1 + z / 60)})`;
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(lift, lift);
// Skids, tail boom and tail rotor.
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(-4, -4.2, 9, 0.7);
ctx.fillRect(-4, 3.5, 9, 0.7);
rrect(ctx, -13, -0.8, 9, 1.6, 0.6, shade(color, -0.15));
ctx.fillStyle = accent;
ctx.fillRect(-13.5, -2.2, 1.2, 4.4);
// Cabin with a bubble canopy.
rrect(ctx, -5, -3.4, 12, 6.8, 3.2, color);
bodyGloss(ctx, -5, -3.4, 12, 6.8, 3.2);
ctx.fillStyle = COLORS.glass;
ctx.beginPath();
ctx.ellipse(4, 0, 3, 2.8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = accent;
ctx.fillRect(-4, -0.6, 6, 1.2);
if (flash) {
ctx.fillStyle = 'rgba(255, 60, 60, 0.6)';
ctx.beginPath();
ctx.arc(-1, 0, 1.2, 0, Math.PI * 2);
ctx.fill();
}
// Rotor disc and blades.
ctx.fillStyle = 'rgba(30, 30, 34, 0.12)';
ctx.beginPath();
ctx.arc(0.5, 0, 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(25, 25, 28, 0.75)';
ctx.lineWidth = 0.8;
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const a = rotor + (i * Math.PI) / 2;
ctx.moveTo(0.5, 0);
ctx.lineTo(0.5 + Math.cos(a) * 10, Math.sin(a) * 10);
}
ctx.stroke();
ctx.fillStyle = '#1b1b1f';
ctx.beginPath();
ctx.arc(0.5, 0, 0.9, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* A bulldozer from above: two tracks (treads crawl with `odometer`), a yellow body with
* an engine deck and exhaust stack, a caged cab, and the wide blade on push arms.
*/
export function drawDozer(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, odometer: number, dents: Dent[], braking: boolean): void {
const yellow = '#f2b705';
ctx.save();
ctx.translate(x + 1.6, y + 2.2);
ctx.rotate(angle);
rrect(ctx, -9, -7.4, 21, 14.8, 1.5, 'rgba(0, 0, 0, 0.3)');
ctx.restore();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
// Tracks, with treads that crawl as it moves.
for (const side of [-1, 1]) {
const ty = side * 5.2;
rrect(ctx, -8.5, ty - 1.9, 16, 3.8, 1.6, '#1d1d20');
ctx.fillStyle = '#4a4a50';
const shift = ((odometer % 2) + 2) % 2;
for (let tx = -8 + shift; tx < 7.5; tx += 2) ctx.fillRect(tx, ty - 1.9, 0.7, 3.8);
ctx.fillStyle = '#2b2b2e';
for (const wx of [-6, -2, 2, 6]) {
ctx.beginPath();
ctx.arc(wx, ty, 0.7, 0, Math.PI * 2);
ctx.fill();
}
}
// Push arms and the blade.
ctx.fillStyle = shade(yellow, -0.25);
ctx.fillRect(4, -5.8, 6.5, 1.2);
ctx.fillRect(4, 4.6, 6.5, 1.2);
rrect(ctx, 9.8, -8.6, 2.6, 17.2, 0.6, shade(yellow, -0.1));
ctx.fillStyle = '#8d9096';
ctx.fillRect(12.2, -8.6, 0.7, 17.2);
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
ctx.fillRect(9.8, -8.6, 0.6, 17.2);
// Body: engine deck in front, cab behind, ripper at the back.
rrect(ctx, -7, -3.4, 13, 6.8, 1, yellow);
bodyGloss(ctx, -7, -3.4, 13, 6.8, 1);
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
for (let gx = 1.5; gx < 5.5; gx += 1.1) ctx.fillRect(gx, -2.4, 0.5, 4.8);
ctx.fillStyle = '#2b2b2e';
ctx.beginPath();
ctx.arc(1, -2.2, 0.8, 0, Math.PI * 2);
ctx.fill();
rrect(ctx, -6.5, -3, 5.5, 6, 0.8, shade(yellow, 0.12));
rrect(ctx, -5.8, -2.3, 4.1, 4.6, 0.5, COLORS.glass);
ctx.strokeStyle = '#2b2b2e';
ctx.lineWidth = 0.5;
ctx.strokeRect(-6.5, -3, 5.5, 6);
ctx.fillStyle = '#5c5f66';
ctx.fillRect(-9.6, -1.6, 2.6, 0.8);
ctx.fillRect(-9.6, 0.8, 2.6, 0.8);
// Lights.
ctx.fillStyle = COLORS.headlight;
ctx.fillRect(5.6, -3.2, 0.8, 1);
ctx.fillRect(5.6, 2.2, 0.8, 1);
ctx.fillStyle = braking ? '#ff6b6b' : COLORS.taillight;
ctx.fillRect(-7.1, -3, 0.6, 1);
ctx.fillRect(-7.1, 2, 0.6, 1);
drawDents(ctx, dents);
ctx.restore();
}
/**
* An A-10 Warthog from above: long straight wings with stores, the twin engine pods
* on the rear fuselage, the H-tail, and the gun in the nose. `bank` tilts the wings
* (one foreshortened), `altitude` lifts it off its shadow, `throttle` lights the engines.
*/
