SOURCE / PINNED RELEASE
Made of little things.
Stick Runner
- Release
- 068f2c4fd180…
- Author-recorded commit
- 2f0b589e2e75…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-938fb0aa7b7
Archive hash verified: a0399c408781bf50…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { CLOTHES, EXPLOSION_TIME, FIREBALL_RADIUS, type Explosion, type Fireball, type GameView, type LineState, type WorldView } from './game.js';
import { SAW_RADIUS, SPIKE_HEIGHT, SPRING_HEIGHT, SPRING_WIDTH, VIEW_H, VIEW_W, lineHeightAt, roofHeightAt, sawCenter, type Block, type Ice, type Saw, type Spikes, type Spring } from './level.js';
import type { Pose } from './stickman.js';
/**
* The 2D (side view) look of Stick Runner: flat sunset rooftops drawn on a canvas.
* The 3D first-person look is in render3d.ts; view-mode.ts picks which one is used.
*/
export class Renderer2D implements WorldView {
private ctx: CanvasRenderingContext2D;
private scale = 1;
private cameraX = 0;
/** The frame being drawn. */
private v!: GameView;
constructor(private canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is not available');
this.ctx = ctx;
}
resize(cssWidth: number, cssHeight: number): void {
const ratio = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(cssWidth * ratio));
this.canvas.height = Math.max(1, Math.round(cssHeight * ratio));
this.scale = this.canvas.width / VIEW_W;
}
render(view: GameView): void {
this.v = view;
// The camera slides along to keep Rog a third of the way across the screen.
const goal = Math.max(0, Math.min(view.level.end - VIEW_W, view.x - VIEW_W * 0.33));
if (view.phase === 'running' && Math.abs(goal - this.cameraX) > VIEW_W) this.cameraX = goal;
this.cameraX += (goal - this.cameraX) * 0.15;
const ctx = this.ctx;
ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
this.drawSky();
this.drawFarCity();
ctx.save();
ctx.translate(-Math.round(this.cameraX * 4) / 4, 0);
this.drawPitFog();
// Saws first, so the roof hides the bottom of each blade.
for (const saw of view.level.saws) this.drawSaw(saw);
for (const block of view.level.blocks) this.drawBlock(block);
for (const saw of view.level.saws) this.drawSawSlot(saw);
for (const patch of view.level.spikes) this.drawSpikes(patch);
for (const spring of view.level.springs) this.drawSpring(spring);
for (const patch of view.level.ice) this.drawIce(patch);
for (const state of view.lines) this.drawClothesline(state);
this.drawFinish();
drawStickman(ctx, view.x, view.y, view.facing, view.pose, view.cycle, view.vy);
if (view.dragon) this.drawDragon(view.dragon.x, view.dragon.y, view.dragon.warn);
for (const ball of view.fireballs) this.drawFireball(ball);
for (const blast of view.explosions) this.drawExplosion(blast);
ctx.restore();
}
private drawSky(): void {
const ctx = this.ctx;
const sky = ctx.createLinearGradient(0, 0, 0, VIEW_H);
const { scenery } = this.v.level;
sky.addColorStop(0, scenery.sky[0]);
sky.addColorStop(0.55, scenery.sky[1]);
sky.addColorStop(1, scenery.sky[2]);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.fillStyle = scenery.sun;
ctx.beginPath();
ctx.arc(360 - this.cameraX * 0.05, 92, 30, 0, Math.PI * 2);
ctx.fill();
}
/** Faraway buildings that slide slowly, so the city feels deep. */
private drawFarCity(): void {
const ctx = this.ctx;
const layers = [
{ speed: 0.2, color: this.v.level.scenery.farCity[0], base: 150, spread: 60, width: 46 },
{ speed: 0.4, color: this.v.level.scenery.farCity[1], base: 175, spread: 50, width: 58 },
];
for (const layer of layers) {
ctx.fillStyle = layer.color;
const offset = this.cameraX * layer.speed;
const first = Math.floor(offset / layer.width) - 1;
for (let i = first; i < first + VIEW_W / layer.width + 3; i++) {
const top = layer.base - noise(i * 7.3 + layer.speed * 100 + this.v.levelIndex * 31) * layer.spread;
ctx.fillRect(i * layer.width - offset, top, layer.width - 4, VIEW_H - top);
}
}
}
/** Darkness at the bottom of the gaps, so the pits look deep and scary. */
private drawPitFog(): void {
const ctx = this.ctx;
const fog = ctx.createLinearGradient(0, VIEW_H - 130, 0, VIEW_H);
fog.addColorStop(0, 'rgba(29, 23, 36, 0)');
fog.addColorStop(0.6, 'rgba(29, 23, 36, 0.85)');
fog.addColorStop(1, 'rgba(10, 6, 12, 1)');
ctx.fillStyle = fog;
ctx.fillRect(this.cameraX - 10, VIEW_H - 130, VIEW_W + 20, 130);
}
private drawBlock(block: Block): void {
const ctx = this.ctx;
if (block.x > this.cameraX + VIEW_W || block.x + block.w < this.cameraX) return;
// Springs are drawn by drawSpring, so they can stretch.
if (block.kind === 'spring') return;
if (block.kind === 'roof') {
ctx.fillStyle = '#4a3f55';
ctx.fillRect(block.x, block.y, block.w, block.h);
// Windows, some lit and some dark.
for (let wy = block.y + 22; wy < VIEW_H; wy += 26) {
for (let wx = block.x + 14; wx < block.x + block.w - 18; wx += 26) {
ctx.fillStyle = noise(wx * 0.37 + wy * 1.7) > 0.55 ? '#ffd27a' : '#3a3044';
ctx.fillRect(wx, wy, 10, 13);
}
}
// Roof ledge.
ctx.fillStyle = '#6e6280';
ctx.fillRect(block.x - 3, block.y, block.w + 6, 6);
// Warning stripes at the edges of each pit.
this.drawEdgeStripe(block.x - 3, block.y);
this.drawEdgeStripe(block.x + block.w - 9, block.y);
} else if (block.kind === 'chimney') {
ctx.fillStyle = '#8a4b3c';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#6b3a2f';
ctx.fillRect(block.x - 2, block.y, block.w + 4, 5);
} else if (block.kind === 'vent') {
ctx.fillStyle = '#8f9aa6';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#6d7782';
for (let i = 4; i < block.h - 2; i += 5) ctx.fillRect(block.x + 4, block.y + i, block.w - 8, 2);
} else {
ctx.fillStyle = '#7b5a3f';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#5e432e';
ctx.fillRect(block.x, block.y + block.h * 0.33, block.w, 3);
ctx.fillRect(block.x, block.y + block.h * 0.66, block.w, 3);
}
}
/** A spinning silver blade with sharp teeth and a red middle. */
private drawSaw(saw: Saw): void {
const ctx = this.ctx;
const { x, y } = sawCenter(saw, this.v.levelTime);
const spin = this.v.clock * 14;
const teeth = 12;
ctx.save();
ctx.translate(x, y);
ctx.rotate(spin);
ctx.fillStyle = '#c9d1d9';
ctx.strokeStyle = '#6d7782';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < teeth; i++) {
const a = (i / teeth) * Math.PI * 2;
const b = ((i + 0.6) / teeth) * Math.PI * 2;
ctx.lineTo(Math.cos(a) * (SAW_RADIUS - 4), Math.sin(a) * (SAW_RADIUS - 4));
ctx.lineTo(Math.cos(b) * SAW_RADIUS, Math.sin(b) * SAW_RADIUS);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#9aa3ad';
ctx.beginPath();
ctx.arc(0, 0, SAW_RADIUS * 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e5484d';
ctx.beginPath();
ctx.arc(0, 0, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* A red dragon flying to the right: shaded scales, bat wings with bones and skin,
* spikes down its back, horns, claws and a spade-tipped tail. Before it breathes fire
* its jaw opens, its mouth glows and smoke puffs out of its nose.
*/
private drawDragon(x: number, y: number, warn: number): void {
const ctx = this.ctx;
const clock = this.v.clock;
const flap = Math.sin(clock * 7);
ctx.save();
ctx.translate(x, y);
this.drawDragonWing(flap * 0.8 - 0.15, '#5e1520', '#3e0d15', -3);
// Tail: thick at the body, tapering to a spade, swaying as it flies.
const sway = Math.sin(clock * 3) * 4;
const tail: { x: number; y: number; w: number }[] = [];
for (let i = 0; i <= 12; i++) {
const t = i / 12;
const px = -14 - t * 40;
const py = 3 + Math.sin(t * Math.PI) * 6 - t * 8 + sway * t * t;
tail.push({ x: px, y: py, w: 6 * (1 - t) + 0.8 });
}
ctx.fillStyle = '#a52f37';
ctx.beginPath();
tail.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y - p.w) : ctx.lineTo(p.x, p.y - p.w)));
for (let i = tail.length - 1; i >= 0; i--) ctx.lineTo(tail[i].x, tail[i].y + tail[i].w * 0.8);
ctx.fill();
const tip = tail[tail.length - 1];
ctx.fillStyle = '#7a1f2b';
ctx.beginPath();
ctx.moveTo(tip.x + 3, tip.y);
ctx.lineTo(tip.x - 4, tip.y - 6);
ctx.lineTo(tip.x - 9, tip.y);
ctx.lineTo(tip.x - 4, tip.y + 6);
ctx.fill();
// Back leg, tucked up with claws.
ctx.fillStyle = '#9b2c37';
ctx.beginPath();
ctx.ellipse(-8, 6, 7, 5, 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#9b2c37';
ctx.lineWidth = 3.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-8, 9);
ctx.lineTo(-14, 15);
ctx.stroke();
this.drawClaws(-14, 15);
// Body with shading: dark along the back, bright in the middle.
const body = ctx.createLinearGradient(0, -11, 0, 11);
body.addColorStop(0, '#7e1b26');
body.addColorStop(0.45, '#c43d3d');
body.addColorStop(1, '#a52f37');
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(0, 0, 19, 10.5, -0.05, 0, Math.PI * 2);
ctx.fill();
// Belly plates.
ctx.save();
ctx.beginPath();
ctx.ellipse(1, 5.5, 14, 4.5, 0, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#e8a83e';
ctx.fillRect(-14, 0, 30, 12);
ctx.strokeStyle = 'rgba(122, 60, 20, 0.6)';
ctx.lineWidth = 0.7;
for (let px = -12; px <= 14; px += 3.5) {
ctx.beginPath();
ctx.moveTo(px, 1);
ctx.lineTo(px + 1, 10);
ctx.stroke();
}
ctx.restore();
// Little scales.
ctx.strokeStyle = 'rgba(70, 10, 20, 0.35)';
ctx.lineWidth = 0.6;
for (let row = -6; row <= 2; row += 3) {
for (let px = -15 + (row % 2) * 1.5; px <= 15; px += 3.5) {
if ((px / 19) ** 2 + (row / 10.5) ** 2 > 0.8) continue;
ctx.beginPath();
ctx.arc(px, row, 1.6, 0.2, Math.PI - 0.2);
ctx.stroke();
}
}
// Spikes down its back, from neck to tail.
ctx.fillStyle = '#5e1520';
for (let px = 14; px >= -16; px -= 5) {
const top = -10.5 * Math.sqrt(Math.max(0, 1 - (px / 19) ** 2));
ctx.beginPath();
ctx.moveTo(px + 2.2, top + 1);
ctx.lineTo(px - 1.5, top - 4.5);
ctx.lineTo(px - 2.2, top + 1);
ctx.fill();
}
// Front leg with claws.
ctx.strokeStyle = '#a52f37';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(11, 6);
ctx.lineTo(15, 13);
ctx.stroke();
this.drawClaws(15, 13);
// Neck, curving up to the head.
ctx.fillStyle = '#b8343c';
ctx.beginPath();
ctx.moveTo(10, -8);
ctx.quadraticCurveTo(20, -14, 28, -9);
ctx.lineTo(29, -1);
ctx.quadraticCurveTo(20, -2, 13, 5);
ctx.fill();
ctx.fillStyle = '#5e1520';
for (const [px, py] of [[16, -12], [21, -13], [26, -12]]) {
ctx.beginPath();
ctx.moveTo(px + 2, py + 1);
ctx.lineTo(px - 1, py - 3.5);
ctx.lineTo(px - 2, py + 1.5);
ctx.fill();
}
// Lower jaw: it drops open while the dragon gets ready to breathe fire.
const jaw = warn * 0.45;
ctx.save();
ctx.translate(29, 0);
ctx.rotate(jaw);
ctx.fillStyle = '#9b2c37';
ctx.beginPath();
ctx.moveTo(0, -1);
ctx.lineTo(13, 1);
ctx.lineTo(12, 3.5);
ctx.lineTo(0, 4);
ctx.fill();
ctx.fillStyle = '#f7f1e3';
for (let px = 3; px <= 11; px += 2.6) {
ctx.beginPath();
ctx.moveTo(px, 0.5);
ctx.lineTo(px + 1, -1.6);
ctx.lineTo(px + 2, 0.7);
ctx.fill();
}
ctx.restore();
// Fire glowing inside the open mouth.
if (warn > 0) {
const glow = ctx.createRadialGradient(36, 1, 0, 36, 1, 4 + warn * 12);
glow.addColorStop(0, `rgba(255, 245, 190, ${0.5 + warn * 0.5})`);
glow.addColorStop(0.4, `rgba(255, 170, 40, ${0.4 + warn * 0.4})`);
glow.addColorStop(1, 'rgba(255, 60, 10, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(36, 1, 4 + warn * 12, 0, Math.PI * 2);
ctx.fill();
}
// Head and long snout, with teeth along the top jaw.
const head = ctx.createLinearGradient(0, -12, 0, 2);
head.addColorStop(0, '#8e2230');
head.addColorStop(1, '#c43d3d');
ctx.fillStyle = head;
ctx.beginPath();
ctx.moveTo(23, -6);
ctx.quadraticCurveTo(27, -12, 33, -9);
ctx.lineTo(42, -4);
ctx.quadraticCurveTo(44, -1, 42, 0.5);
ctx.lineTo(29, 0.5);
ctx.quadraticCurveTo(24, 0, 23, -6);
ctx.fill();
ctx.fillStyle = '#f7f1e3';
for (let px = 31; px <= 41; px += 2.6) {
ctx.beginPath();
ctx.moveTo(px, 0.3);
ctx.lineTo(px + 1, 2.4);
ctx.lineTo(px + 2, 0.3);
ctx.fill();
}
// Nostril, brow and a fierce yellow eye with a slit pupil.
ctx.fillStyle = '#3e0d15';
ctx.beginPath();
ctx.ellipse(40.5, -3.2, 1.1, 0.7, 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffd23a';
ctx.beginPath();
ctx.ellipse(30, -6, 2.6, 1.8, -0.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1d1724';
ctx.beginPath();
ctx.ellipse(30.4, -6, 0.6, 1.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#5e1520';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(26.5, -8.6);
ctx.lineTo(33.5, -8);
ctx.stroke();
// Two curved horns sweeping back.
for (const [ox, color] of [[-2, '#cfc6b4'], [0, '#f2ede4']] as const) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(26 + ox, -9);
ctx.quadraticCurveTo(21 + ox, -14, 15 + ox, -15);
ctx.quadraticCurveTo(21 + ox, -11, 24 + ox, -6.5);
ctx.fill();
}
// Smoke puffing out of its nose as it gets ready.
if (warn > 0) {
for (let i = 0; i < 3; i++) {
const age = (clock * 1.5 + i / 3) % 1;
ctx.fillStyle = `rgba(90, 80, 90, ${0.45 * warn * (1 - age)})`;
ctx.beginPath();
ctx.arc(42 + age * 6, -5 - age * 12, 1.5 + age * 4, 0, Math.PI * 2);
ctx.fill();
}
}
this.drawDragonWing(flap, '#9b2c37', '#5e1520', 0);
ctx.restore();
}
/** A bat-like wing: bony arm and fingers with skin stretched between them, scalloped at the edge. */
private drawDragonWing(flap: number, skin: string, bone: string, shift: number): void {
const ctx = this.ctx;
const lift = flap * 14;
const shoulder = { x: 3 + shift, y: -7 };
const elbow = { x: -6 + shift, y: -20 - lift * 0.6 };
const wrist = { x: -2 + shift, y: -33 - lift };
const tips = [
{ x: -26 + shift, y: -34 - lift * 0.9 },
{ x: -36 + shift, y: -20 - lift * 0.5 },
{ x: -30 + shift, y: -6 - lift * 0.1 },
];
const gradient = ctx.createLinearGradient(wrist.x, wrist.y, shoulder.x, shoulder.y);
gradient.addColorStop(0, skin);
gradient.addColorStop(1, bone);
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.95;
ctx.beginPath();
ctx.moveTo(shoulder.x, shoulder.y);
ctx.lineTo(elbow.x, elbow.y);
ctx.lineTo(wrist.x, wrist.y);
ctx.lineTo(tips[0].x, tips[0].y);
// Scalloped skin between the finger tips, and back to the body.
ctx.quadraticCurveTo((tips[0].x + tips[1].x) / 2 + 5, (tips[0].y + tips[1].y) / 2 + 2, tips[1].x, tips[1].y);
ctx.quadraticCurveTo((tips[1].x + tips[2].x) / 2 + 6, (tips[1].y + tips[2].y) / 2, tips[2].x, tips[2].y);
ctx.quadraticCurveTo(-18 + shift, -4, -10 + shift, -5);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = bone;
ctx.lineCap = 'round';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(shoulder.x, shoulder.y);
ctx.lineTo(elbow.x, elbow.y);
ctx.lineTo(wrist.x, wrist.y);
ctx.stroke();
ctx.lineWidth = 1.2;
for (const tip of tips) {
ctx.beginPath();
ctx.moveTo(wrist.x, wrist.y);
ctx.lineTo(tip.x, tip.y);
ctx.stroke();
}
// A little claw on the wing's thumb.
ctx.fillStyle = '#f2ede4';
ctx.beginPath();
ctx.moveTo(wrist.x, wrist.y);
ctx.lineTo(wrist.x + 3, wrist.y - 3);
ctx.lineTo(wrist.x + 1.5, wrist.y + 1);
ctx.fill();
}
private drawClaws(x: number, y: number): void {
const ctx = this.ctx;
ctx.fillStyle = '#f2ede4';
for (const dx of [-2, 0.5, 3]) {
ctx.beginPath();
ctx.moveTo(x + dx - 1, y);
ctx.lineTo(x + dx + 1.5, y + 3);
ctx.lineTo(x + dx + 1, y - 0.5);
ctx.fill();
}
}
/**
* A fireball: a white-hot core, flickering flame tongues streaming backwards,
* a bright glow, sparks and a trail of smoke.
*/
private drawFireball(ball: Fireball): void {
const ctx = this.ctx;
const clock = this.v.clock;
const backAngle = Math.atan2(-ball.vy, -ball.vx);
const seed = Math.floor(ball.vx * 7 + ball.vy * 13);
ctx.save();
ctx.translate(ball.x, ball.y);
ctx.rotate(backAngle);
// Smoke left behind, drifting and fading.
for (let i = 1; i <= 7; i++) {
const wobble = Math.sin(clock * 6 + i * 1.7 + seed) * (1 + i * 0.6);
ctx.fillStyle = `rgba(70, 60, 66, ${0.22 * (1 - i / 8)})`;
ctx.beginPath();
ctx.arc(10 + i * 7, wobble, 2.5 + i * 0.9, 0, Math.PI * 2);
ctx.fill();
}
// Soft glow all around.
const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, FIREBALL_RADIUS * 3);
glow.addColorStop(0, 'rgba(255, 150, 40, 0.45)');
glow.addColorStop(0.6, 'rgba(255, 90, 20, 0.15)');
glow.addColorStop(1, 'rgba(255, 60, 10, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, 0, FIREBALL_RADIUS * 3, 0, Math.PI * 2);
ctx.fill();
// Flame tongues licking backwards, each flickering at its own pace:
// wide dark-red ones on the outside, then orange, then short yellow ones in the middle.
const layers: [number, number, string, string][] = [
[1.15, 1.25, 'rgba(214, 48, 18, 0.95)', 'rgba(140, 20, 10, 0)'],
[0.9, 1.0, 'rgba(255, 122, 26, 1)', 'rgba(230, 60, 15, 0)'],
[0.55, 0.6, 'rgba(255, 214, 80, 1)', 'rgba(255, 140, 30, 0)'],
];
for (const [thick, reach, base, tip] of layers) {
for (let k = 0; k < 5; k++) {
const spread = (k - 2) * 0.26;
const length = (12 + 8 * (0.5 + 0.5 * Math.sin(clock * 24 + k * 1.9 + seed + thick * 5))) * reach;
const width = FIREBALL_RADIUS * thick * (0.8 - Math.abs(k - 2) * 0.12);
const bend = Math.sin(clock * 17 + k * 2.3 + thick * 3) * 3;
ctx.save();
ctx.rotate(spread);
const flame = ctx.createLinearGradient(0, 0, length + 6, 0);
flame.addColorStop(0, base);
flame.addColorStop(1, tip);
ctx.fillStyle = flame;
ctx.beginPath();
ctx.moveTo(0, -width);
ctx.quadraticCurveTo(length * 0.5, -width + bend, length + 6, bend * 1.5);
ctx.quadraticCurveTo(length * 0.5, width + bend, 0, width);
ctx.arc(0, 0, width, Math.PI / 2, Math.PI * 1.5);
ctx.fill();
ctx.restore();
}
}
// Sparks flying off the back.
for (let i = 0; i < 6; i++) {
const age = (clock * 2.2 + i / 6 + (seed % 7) / 7) % 1;
const side = Math.sin(i * 12.9 + seed) * 9 * age;
ctx.fillStyle = `rgba(255, ${200 - age * 120}, 60, ${1 - age})`;
ctx.beginPath();
ctx.arc(8 + age * 34, side, 1.2 * (1 - age) + 0.4, 0, Math.PI * 2);
ctx.fill();
}
// A small white-hot core.
const core = ctx.createRadialGradient(-1, 0, 0, 0, 0, FIREBALL_RADIUS * 0.75);
core.addColorStop(0, 'rgba(255, 255, 235, 1)');
core.addColorStop(0.5, 'rgba(255, 238, 150, 1)');
core.addColorStop(1, 'rgba(255, 190, 60, 0)');
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(0, 0, FIREBALL_RADIUS * 0.75 * (1 + 0.1 * Math.sin(clock * 40 + seed)), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* A fireball blowing up: a bright flash, a billowing ball of fire, a shock ring across the roof,
* sparks flying out in arcs, smoke rising afterwards and a scorch mark left behind.
*/
private drawExplosion(blast: Explosion): void {
const ctx = this.ctx;
const t = blast.age / EXPLOSION_TIME;
const { x, y } = blast;
const seed = Math.floor(x * 3.1);
ctx.save();
// Scorch mark on the roof, fading away.
if (blast.ground) {
ctx.fillStyle = `rgba(29, 20, 24, ${0.55 * (1 - t)})`;
ctx.beginPath();
ctx.ellipse(x, y + 0.5, 24, 3.5, 0, 0, Math.PI * 2);
ctx.fill();
}
// Smoke billowing up once the fire dies down.
for (let i = 0; i < 6; i++) {
const start = 0.15 + i * 0.05;
if (t < start) continue;
const s = (t - start) / (1 - start);
const drift = (noise(seed + i) - 0.5) * 30;
ctx.fillStyle = `rgba(70, 62, 68, ${0.45 * (1 - s)})`;
ctx.beginPath();
ctx.arc(x + drift * s, y - 12 - s * 46 - i * 4, 7 + s * 15, 0, Math.PI * 2);
ctx.fill();
}
// Ball of fire: blobs that puff outwards and upwards, from yellow to orange to dark red.
if (t < 0.6) {
const f = t / 0.6;
for (let i = 0; i < 9; i++) {
const angle = Math.PI + (i / 8) * Math.PI + (noise(seed + i * 7) - 0.5) * 0.4;
const reach = (12 + noise(seed + i) * 20) * Math.sqrt(f);
const bx = x + Math.cos(angle) * reach;
const by = (blast.ground ? y - 6 : y) + Math.sin(angle) * reach - f * 12;
const radius = (9 + noise(seed + i * 3) * 7) * (1 - f * 0.45);
const fire = ctx.createRadialGradient(bx, by, 0, bx, by, radius);
fire.addColorStop(0, `rgba(255, 236, 150, ${1 - f})`);
fire.addColorStop(0.45, `rgba(255, 130, 30, ${1 - f})`);
fire.addColorStop(0.8, `rgba(200, 45, 15, ${0.7 * (1 - f)})`);
fire.addColorStop(1, `rgba(180, 30, 10, 0)`);
ctx.fillStyle = fire;
ctx.beginPath();
ctx.arc(bx, by, radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Shock ring racing out along the roof.
if (blast.ground && t < 0.5) {
const r = t / 0.5;
ctx.strokeStyle = `rgba(255, 210, 120, ${0.8 * (1 - r)})`;
ctx.lineWidth = 2.5 * (1 - r) + 0.5;
ctx.beginPath();
ctx.ellipse(x, y, 8 + r * 58, 2.5 + r * 6, 0, Math.PI, Math.PI * 2);
ctx.stroke();
}
// Sparks and burning bits flying out in arcs.
for (let i = 0; i < 12; i++) {
const angle = Math.PI + 0.15 + (i / 11) * (Math.PI - 0.3) + (noise(seed + i * 5) - 0.5) * 0.3;
const speed = 100 + noise(seed + i * 11) * 130;
const age = blast.age;
const px = x + Math.cos(angle) * speed * age;
const py = y + Math.sin(angle) * speed * age + 260 * age * age;
if (blast.ground && py > y) continue;
const life = 1 - t;
ctx.fillStyle = `rgba(255, ${170 + noise(seed + i) * 80}, 60, ${life})`;
ctx.beginPath();
ctx.arc(px, py, 2 * life + 0.5, 0, Math.PI * 2);
ctx.fill();
}
// The bright flash, right at the start.
if (t < 0.25) {
const f = t / 0.25;
const flash = ctx.createRadialGradient(x, y - 3, 0, x, y - 3, 14 + f * 42);
flash.addColorStop(0, `rgba(255, 255, 235, ${1 - f})`);
flash.addColorStop(0.4, `rgba(255, 220, 120, ${0.7 * (1 - f)})`);
flash.addColorStop(1, 'rgba(255, 150, 40, 0)');
ctx.fillStyle = flash;
ctx.beginPath();
ctx.arc(x, y - 3, 14 + f * 42, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
/**
* A spring: a metal coil between a base plate and a red-and-yellow pad.
* When Rog bounces on it, it shoots up and then wobbles back down.
*/
private drawSpring(spring: Spring): void {
const ctx = this.ctx;
const bounce = this.v.springBounce;
const since = bounce && bounce.x === spring.x ? this.v.clock - bounce.at : Infinity;
// Normally squashed a little; just after a bounce it stretches tall, then wobbles back.
const stretch = since < 0.6 ? Math.exp(-since * 7) * Math.cos(since * 30) : 0;
const height = SPRING_HEIGHT - 4 + Math.max(-5, stretch * 18);
const { x, roofY } = spring;
const w = SPRING_WIDTH;
// Base plate.
ctx.fillStyle = '#3a3044';
ctx.fillRect(x, roofY - 2, w, 2);
// Zig-zag coil.
ctx.strokeStyle = '#9aa3ad';
ctx.lineWidth = 2.4;
ctx.beginPath();
const turns = 5;
for (let i = 0; i <= turns * 2; i++) {
const px = x + 4 + (i % 2) * (w - 8);
const py = roofY - 2 - (i / (turns * 2)) * (height - 2);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
// Pad on top: red with a yellow stripe, so it stands out.
const top = roofY - height - 4;
ctx.fillStyle = '#e5484d';
ctx.fillRect(x - 2, top, w + 4, 5);
ctx.fillStyle = '#ffcc33';
ctx.fillRect(x + 4, top + 1.5, w - 8, 2);
}
/** A row of sharp metal spikes on a dark base plate. */
private drawSpikes(patch: Spikes): void {
const ctx = this.ctx;
const count = Math.max(2, Math.round(patch.w / 7));
const width = patch.w / count;
ctx.fillStyle = '#3a3044';
ctx.fillRect(patch.x, patch.roofY - 2, patch.w, 2);
for (let i = 0; i < count; i++) {
const left = patch.x + i * width;
// Each spike is lit on one side and shaded on the other.
ctx.fillStyle = '#c9d1d9';
ctx.beginPath();
ctx.moveTo(left, patch.roofY - 2);
ctx.lineTo(left + width / 2, patch.roofY - SPIKE_HEIGHT);
ctx.lineTo(left + width / 2, patch.roofY - 2);
ctx.fill();
ctx.fillStyle = '#7d8793';
ctx.beginPath();
ctx.moveTo(left + width / 2, patch.roofY - SPIKE_HEIGHT);
ctx.lineTo(left + width, patch.roofY - 2);
ctx.lineTo(left + width / 2, patch.roofY - 2);
ctx.fill();
}
}
/** The dark slot in the roof that a saw slides along. */
private drawSawSlot(saw: Saw): void {
const ctx = this.ctx;
ctx.fillStyle = '#1d1724';
ctx.fillRect(saw.x1 - SAW_RADIUS, saw.roofY, saw.x2 - saw.x1 + SAW_RADIUS * 2, 3);
}
private drawIce(patch: Ice): void {
const ctx = this.ctx;
if (patch.x > this.cameraX + VIEW_W || patch.x + patch.w + 10 < this.cameraX) return;
const left = patch.x;
const right = patch.x + patch.w + 3;
const top = patch.y - 3;
const bottom = patch.y + 6;
// Icicles hanging under the roof ledge, longer near the edge where the water dripped.
for (let ix = left + 4; ix < right - 2; ix += 3 + noise(ix * 0.37) * 6) {
const nearEdge = (ix - left) / (right - left);
const length = 3 + noise(ix * 0.71) * 9 * (0.4 + nearEdge);
const icicle = ctx.createLinearGradient(0, bottom, 0, bottom + length);
icicle.addColorStop(0, 'rgba(225, 245, 255, 0.95)');
icicle.addColorStop(1, 'rgba(160, 215, 245, 0.35)');
ctx.fillStyle = icicle;
ctx.beginPath();
ctx.moveTo(ix - 2, bottom);
ctx.lineTo(ix + 2, bottom);
ctx.lineTo(ix + 0.3, bottom + length);
ctx.closePath();
ctx.fill();
}
// The ice slab: clear and pale on top, deeper blue underneath, with the roof showing through.
const slab = ctx.createLinearGradient(0, top, 0, bottom);
slab.addColorStop(0, 'rgba(240, 252, 255, 0.95)');
slab.addColorStop(0.35, 'rgba(180, 228, 250, 0.8)');
slab.addColorStop(1, 'rgba(95, 160, 210, 0.75)');
ctx.fillStyle = slab;
ctx.beginPath();
// Lumpy left end where the ice starts, like a frozen puddle.
ctx.moveTo(left + 6, top);
ctx.lineTo(right, top);
ctx.lineTo(right, bottom);
ctx.lineTo(left, bottom);
ctx.quadraticCurveTo(left - 3, top + 3, left + 6, top);
ctx.fill();
// Bright edge along the top where light hits it.
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(left + 5, top, patch.w - 2, 1);
// Cracks frozen inside the ice.
ctx.strokeStyle = 'rgba(70, 130, 180, 0.55)';
ctx.lineWidth = 0.6;
for (let cx = left + 12; cx < right - 10; cx += 23) {
const wiggle = noise(cx * 1.3) * 6;
ctx.beginPath();
ctx.moveTo(cx, top + 1);
ctx.lineTo(cx + 3 + wiggle * 0.5, top + 4);
ctx.lineTo(cx + 1 + wiggle, bottom - 1);
ctx.moveTo(cx + 3 + wiggle * 0.5, top + 4);
ctx.lineTo(cx + 8, top + 5);
ctx.stroke();
}
// A glare of light that slowly slides across the ice.
const glareX = left + ((this.v.clock * 25) % (patch.w + 40)) - 20;
ctx.save();
ctx.beginPath();
ctx.rect(left, top, right - left, bottom - top);
ctx.clip();
ctx.fillStyle = 'rgba(255, 255, 255, 0.55)';
ctx.beginPath();
ctx.moveTo(glareX, bottom);
ctx.lineTo(glareX + 6, top);
ctx.lineTo(glareX + 10, top);
ctx.lineTo(glareX + 4, bottom);
ctx.fill();
ctx.restore();
// Little star sparkles that twinkle on and off.
ctx.fillStyle = '#ffffff';
for (let sx = left + 9; sx < right - 4; sx += 17) {
const twinkle = Math.sin(this.v.clock * 2.5 + noise(sx) * 10);
if (twinkle < 0.6) continue;
const size = (twinkle - 0.6) * 6;
const sy = top + 1 + noise(sx * 2.1) * 3;
ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
}
this.drawEdgeStripe(patch.x + patch.w - 9, patch.y);
}
private drawClothesline(state: LineState): void {
const ctx = this.ctx;
const { line } = state;
if (line.x1 > this.cameraX + VIEW_W || line.x2 < this.cameraX) return;
// Metal hooks on the walls.
ctx.fillStyle = '#9aa3ad';
ctx.fillRect(line.x1 - 4, line.y1 - 2, 5, 4);
ctx.fillRect(line.x2 - 1, line.y2 - 2, 5, 4);
ctx.strokeStyle = '#f2ede4';
ctx.lineWidth = 1.4;
ctx.lineCap = 'round';
if (state.snappedAt === null) {
// Once someone is on it, it shakes like it's about to give.
const shake = state.held === null ? 0 : Math.sin(this.v.clock * 70) * (0.4 + state.held * 2);
const pointAt = (t: number) => {
const x = line.x1 + (line.x2 - line.x1) * t;
return { x, y: lineHeightAt(line, x) + shake * Math.sin(Math.PI * t) };
};
ctx.beginPath();
for (let i = 0; i <= 24; i++) {
const p = pointAt(i / 24);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
for (const [t, kind, color] of CLOTHES) {
const p = pointAt(t);
this.drawClothing(kind, color, p.x, p.y);
}
return;
}
// Snapped: each half swings down and hangs from its hook, and the clothes fall.
const age = this.v.clock - state.snappedAt;
const drop = Math.min(1, age * 4);
const swing = Math.sin(age * 6) * Math.exp(-age * 1.5) * 8;
const halfLength = Math.min(70, (line.x2 - line.x1) / 2);
const halves: [number, number, number][] = [
[line.x1, line.y1, 1],
[line.x2, line.y2, -1],
];
for (const [hx, hy, dir] of halves) {
const endX = hx + dir * halfLength * (1 - drop) + swing * dir;
const endY = hy + halfLength * drop;
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.quadraticCurveTo(hx + dir * 6, (hy + endY) / 2, endX, endY);
ctx.stroke();
}
const fall = 0.5 * 900 * age * age;
for (const [t, kind, color] of CLOTHES) {
const x = line.x1 + (line.x2 - line.x1) * t + Math.sin(age * 5 + t * 9) * 6 * age;
const y = lineHeightAt(line, x) + fall;
if (y < VIEW_H + 20) this.drawClothing(kind, color, x, y);
}
}
/** A piece of laundry hanging with its top at (x, y). */
private drawClothing(kind: 'shirt' | 'sock' | 'pants', color: string, x: number, y: number): void {
const ctx = this.ctx;
ctx.fillStyle = color;
ctx.strokeStyle = 'rgba(29, 23, 36, 0.5)';
ctx.lineWidth = 0.8;
ctx.beginPath();
if (kind === 'shirt') {
ctx.moveTo(x - 6, y);
ctx.lineTo(x + 6, y);
ctx.lineTo(x + 10, y + 5);
ctx.lineTo(x + 6, y + 6);
ctx.lineTo(x + 6, y + 15);
ctx.lineTo(x - 6, y + 15);
ctx.lineTo(x - 6, y + 6);
ctx.lineTo(x - 10, y + 5);
} else if (kind === 'pants') {
ctx.moveTo(x - 6, y);
ctx.lineTo(x + 6, y);
ctx.lineTo(x + 7, y + 17);
ctx.lineTo(x + 2, y + 17);
ctx.lineTo(x, y + 6);
ctx.lineTo(x - 2, y + 17);
ctx.lineTo(x - 7, y + 17);
} else {
ctx.moveTo(x - 2, y);
ctx.lineTo(x + 2, y);
ctx.lineTo(x + 2, y + 8);
ctx.lineTo(x + 5, y + 10);
ctx.lineTo(x + 4, y + 12);
ctx.lineTo(x - 2, y + 11);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// Clothes peg.
ctx.fillStyle = '#c98b4a';
ctx.fillRect(x - 0.8, y - 2.5, 1.6, 4);
}
private drawEdgeStripe(x: number, y: number): void {
const ctx = this.ctx;
ctx.fillStyle = '#ffcc33';
ctx.fillRect(x, y, 12, 6);
ctx.fillStyle = '#2b2233';
ctx.fillRect(x + 4, y, 4, 6);
}
private drawFinish(): void {
const ctx = this.ctx;
const flagX = this.v.level.finishX;
const ground = roofHeightAt(this.v.level.blocks, flagX);
const top = ground - 70;
const wave = Math.sin(this.v.clock * 6) * 2;
if (this.v.level.flag === 'stairs') {
// A little stairwell hut with an open dark doorway and a sign pointing down.
ctx.fillStyle = '#6e6280';
ctx.fillRect(flagX - 4, ground - 40, 40, 40);
ctx.fillStyle = '#4a3f55';
ctx.fillRect(flagX - 7, ground - 44, 46, 5);
ctx.fillStyle = '#1d1724';
ctx.fillRect(flagX + 2, ground - 32, 22, 32);
// Top steps going down inside the doorway.
ctx.fillStyle = '#8a7a90';
for (let i = 0; i < 3; i++) ctx.fillRect(flagX + 2 + i * 7, ground - 9 + i * 3, 22 - i * 7, 2);
ctx.fillStyle = '#ffcc33';
ctx.fillRect(flagX - 6, top + 6, 50, 15);
ctx.fillStyle = '#1d1724';
ctx.font = 'bold 9px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('↓ STAIRS', flagX + 19, top + 17);
} else if (this.v.level.flag === 'checkered') {
ctx.fillStyle = '#2b2233';
ctx.fillRect(flagX - 2, top, 4, 70);
// Checkered flag that waves a little.
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 5; col++) {
ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#1d1724';
ctx.fillRect(flagX + 2 + col * 6, top + row * 6 + (col / 4) * wave, 6, 6);
}
}
} else {
// Level 2's flag: a big gold flag with a star on a shiny pole.
ctx.fillStyle = '#f2ede4';
ctx.fillRect(flagX - 2, top - 6, 4, 76);
ctx.fillStyle = '#ffcc33';
ctx.beginPath();
ctx.arc(flagX, top - 7, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffb000';
ctx.strokeStyle = '#a86a00';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(flagX + 2, top);
ctx.quadraticCurveTo(flagX + 18, top - 3 + wave, flagX + 36, top + 2 + wave);
ctx.lineTo(flagX + 36, top + 22 + wave);
ctx.quadraticCurveTo(flagX + 18, top + 17 + wave, flagX + 2, top + 22);
ctx.closePath();
ctx.fill();
ctx.stroke();
this.drawStar(flagX + 19, top + 11 + wave * 0.6, 6);
// Sparkles around it.
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 3; i++) {
const twinkle = Math.sin(this.v.clock * 4 + i * 2.1);
if (twinkle < 0.3) continue;
const sx = flagX + 8 + i * 14;
const sy = top - 8 + (i % 2) * 34;
const size = twinkle * 2.5;
ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
}
}
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.v.level.name.toUpperCase(), flagX, top - 14);
}
private drawStar(x: number, y: number, radius: number): void {
const ctx = this.ctx;
ctx.fillStyle = '#fff6d6';
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? radius : radius * 0.45;
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
ctx.lineTo(x + Math.cos(angle) * r, y + Math.sin(angle) * r);
}
ctx.closePath();
ctx.fill();
}
}
/** A repeatable "random" number from 0 to 1, so the city looks the same every time. */
function noise(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
}
// ---------- Rog, drawn flat ----------
const INK_2D = '#1d1724';
const BANDANA = '#2fa84f';
const BANDANA_DARK = '#1e7a37';
/**
* Draw the stickman with his feet at (x, y).
* `cycle` moves the legs and arms while running, or is how far through a roll he is (0 to 1).
*/
export function drawStickman(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
facing: number,
pose: Pose,
cycle: number,
vy: number,
): void {
ctx.save();
ctx.translate(x, y);
ctx.scale(facing, 1);
ctx.strokeStyle = INK_2D;
ctx.fillStyle = INK_2D;
ctx.lineWidth = 2.6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (pose === 'roll') {
drawRoll(ctx, cycle);
ctx.restore();
return;
}
const hip = { x: 0, y: -16 };
let lean = 0;
let legs: [number, number][] = [];
let arms: [number, number][] = [];
if (pose === 'run') {
const swing = Math.sin(cycle);
lean = 3;
legs = [limb(hip, 0.2 + swing * 0.8, 16), limb(hip, 0.2 - swing * 0.8, 16)];
arms = [[-swing * 7, 7], [swing * 7, 7]];
} else if (pose === 'jump') {
const rising = vy < 0;
lean = 2;
legs = rising ? [[7, -6], [-5, -2]] : [[5, -1], [-4, 0]];
arms = rising ? [[6, -8], [-6, -6]] : [[8, -2], [-8, -3]];
} else if (pose === 'slide') {
// Surfing on the ice: feet apart, leaning back, arms out for balance.
const wobble = Math.sin(cycle * 3) * 2;
lean = -3;
legs = [[9, 0], [-7, 0]];
arms = [[10, -4 + wobble], [-10, -6 - wobble]];
} else if (pose === 'fall') {
legs = [[-7, -4], [7, -2]];
arms = [[-9, -12], [9, -12]];
} else if (pose === 'cheer') {
const bounce = Math.abs(Math.sin(cycle)) * 3;
ctx.translate(0, -bounce);
legs = [[-5, 0], [5, 0]];
arms = [[-7, -14], [7, -14]];
} else {
legs = [[-4, 0], [4, 0]];
arms = [[-4, 9], [4, 9]];
}
const shoulder = { x: lean, y: -29 };
// Body.
line(ctx, hip.x, hip.y, shoulder.x, shoulder.y);
// Head, with Rog's green bandana.
const headX = shoulder.x + lean * 0.4;
const headY = shoulder.y - 6;
ctx.beginPath();
ctx.arc(headX, headY, 5, 0, Math.PI * 2);
ctx.fill();
drawBandana(ctx, headX, headY, 5, pose !== 'stand');
// Legs (knee halfway, pushed forward a little).
for (const [fx, fy] of legs) {
const kx = (hip.x + fx) / 2 + 3;
const ky = (hip.y + fy) / 2;
polyline(ctx, [hip.x, hip.y, kx, ky, fx, fy]);
}
// Arms.
for (const [hx, hy] of arms) {
const ex = shoulder.x + hx * 0.5 + (pose === 'run' ? -2 : 0);
const ey = shoulder.y + hy * 0.5 + 2;
polyline(ctx, [shoulder.x, shoulder.y + 1, ex, ey, shoulder.x + hx, shoulder.y + hy]);
}
ctx.restore();
}
/** Curled up in a ball, spinning forward once. */
function drawRoll(ctx: CanvasRenderingContext2D, progress: number): void {
// Squash down at the start and stand back up at the end.
const tuck = Math.sin(progress * Math.PI);
ctx.translate(0, -10 - (1 - tuck) * 4);
ctx.rotate(progress * Math.PI * 2);
// Curved back.
ctx.beginPath();
ctx.arc(0, 0, 8, Math.PI * 0.75, Math.PI * 2.1);
ctx.stroke();
// Head tucked in.
ctx.beginPath();
ctx.arc(6, -5, 4.5, 0, Math.PI * 2);
ctx.fill();
drawBandana(ctx, 6, -5, 4.5, true);
// Knees pulled up, arms hugging them.
polyline(ctx, [-6, 5, 2, -1, 6, 6]);
polyline(ctx, [-5, 6, 4, 3, 7, 8]);
polyline(ctx, [3, -4, 4, 2, 8, 1]);
}
/** Rog's green bandana: a band around his forehead, tied at the back with two tails that flap when he moves. */
function drawBandana(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, moving: boolean): void {
ctx.save();
// The band, wrapped around the top of the head.
ctx.beginPath();
ctx.arc(x, y, r + 0.3, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = BANDANA;
ctx.fillRect(x - r - 1, y - r * 0.8, r * 2 + 2, r * 0.65);
ctx.restore();
// The knot at the back of the head.
const knotX = x - r * 0.9;
const knotY = y - r * 0.45;
ctx.fillStyle = BANDANA_DARK;
ctx.beginPath();
ctx.arc(knotX, knotY, 1.3, 0, Math.PI * 2);
ctx.fill();
// Two tails: streaming out behind when moving, hanging down when standing still.
const flap = Math.sin(performance.now() / 70);
const tails: [number, number][] = moving
? [
[knotX - 7, knotY - 1 + flap * 1.5],
[knotX - 6, knotY + 2 - flap * 1.5],
]
: [
[knotX - 2, knotY + 6],
[knotX - 0.5, knotY + 7],
];
ctx.strokeStyle = BANDANA;
ctx.lineWidth = 1.6;
for (const [tx, ty] of tails) {
ctx.beginPath();
ctx.moveTo(knotX, knotY);
ctx.quadraticCurveTo((knotX + tx) / 2, (knotY + ty) / 2 + (moving ? flap : 0), tx, ty);
ctx.stroke();
}
ctx.strokeStyle = INK_2D;
ctx.lineWidth = 2.6;
}
function limb(from: { x: number; y: number }, angle: number, length: number): [number, number] {
return [from.x + Math.sin(angle) * length, Math.min(0, from.y + Math.cos(angle) * length)];
}
function line(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function polyline(ctx: CanvasRenderingContext2D, points: number[]): void {
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) ctx.lineTo(points[i], points[i + 1]);
ctx.stroke();
}
