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 {
ARENA_H,
ARENA_W,
FLOOR,
PUNCH_TIME,
ROG_MAX_HP,
SPIDER_MAX_HP,
STOMP_SLAM,
STOMP_WINDUP,
GOO_RADIUS,
GOO_WINDUP,
LEG_SPREAD,
LAUNCH_WINDUP,
LITTLE_CROUCH,
LITTLE_DEATH,
LITTLE_MAX_HP,
spiderSpinneret,
SPLAT_TIME,
type BossFight,
} from './boss.js';
import { drawStickman } from './render2d.js';
/**
* Draws the boss fight: a dark cave with stalactites, webs and glowing mushrooms,
* a giant hairy spider, Rog, health bars and the red warning circle before a stomp.
*/
function noise(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
}
const ease = (t: number) => t * t * (3 - 2 * t);
export function drawBossScene(ctx: CanvasRenderingContext2D, fight: BossFight): void {
ctx.save();
// The whole cave shakes after a big stomp.
if (fight.shake > 0) {
const s = fight.shake * 4;
ctx.translate((noise(fight.clock * 60) - 0.5) * s, (noise(fight.clock * 60 + 3) - 0.5) * s);
}
drawCave(ctx, fight.clock);
drawSplats(ctx, fight, false);
drawSpider(ctx, fight);
drawLittle(ctx, fight);
drawRog(ctx, fight);
drawSplats(ctx, fight, true);
drawGoo(ctx, fight);
drawDust(ctx, fight);
for (const pop of fight.pops) {
ctx.globalAlpha = 1 - pop.age / 0.9;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.strokeStyle = '#1d1724';
ctx.strokeText(pop.text, pop.x, pop.y - pop.age * 26);
ctx.fillStyle = pop.color;
ctx.fillText(pop.text, pop.x, pop.y - pop.age * 26);
}
ctx.globalAlpha = 1;
ctx.restore();
drawBars(ctx, fight);
drawMessages(ctx, fight);
}
function drawCave(ctx: CanvasRenderingContext2D, clock: number): void {
const air = ctx.createLinearGradient(0, 0, 0, ARENA_H);
air.addColorStop(0, '#2e2333');
air.addColorStop(1, '#4a3842');
ctx.fillStyle = air;
ctx.fillRect(0, 0, ARENA_W, ARENA_H);
// Bumpy rock wall in the background.
for (let i = 0; i < 26; i++) {
ctx.fillStyle = `rgba(70, 52, 60, ${0.25 + noise(i) * 0.3})`;
ctx.beginPath();
ctx.ellipse(noise(i * 3.1) * ARENA_W, 40 + noise(i * 5.7) * 170, 20 + noise(i * 2) * 40, 12 + noise(i * 4) * 22, noise(i * 7), 0, Math.PI * 2);
ctx.fill();
}
// Stalactites hanging from the ceiling.
ctx.fillStyle = '#3b2c33';
for (let i = 0; i < 14; i++) {
const x = 10 + i * 35 + noise(i * 9) * 15;
const length = 14 + noise(i * 13) * 30;
ctx.beginPath();
ctx.moveTo(x - 9, 0);
ctx.lineTo(x + 9, 0);
ctx.lineTo(x, length);
ctx.fill();
}
// Spider webs in the top corners.
ctx.strokeStyle = 'rgba(230, 225, 235, 0.35)';
ctx.lineWidth = 0.8;
for (const [cx, dir] of [[0, 1], [ARENA_W, -1]] as const) {
for (let a = 0; a <= 4; a++) {
const angle = (a / 4) * (Math.PI / 2);
ctx.beginPath();
ctx.moveTo(cx, 0);
ctx.lineTo(cx + dir * Math.cos(angle) * 70, Math.sin(angle) * 70);
ctx.stroke();
}
for (let r = 15; r <= 70; r += 14) {
ctx.beginPath();
for (let a = 0; a <= 4; a++) {
const angle = (a / 4) * (Math.PI / 2);
const px = cx + dir * Math.cos(angle) * r;
const py = Math.sin(angle) * r;
if (a === 0) ctx.moveTo(px, py);
else ctx.quadraticCurveTo(cx + dir * Math.cos(angle - 0.2) * r * 0.85, Math.sin(angle - 0.2) * r * 0.85, px, py);
}
ctx.stroke();
}
}
// Rocky side walls.
ctx.fillStyle = '#2f2228';
ctx.fillRect(0, 0, 18, ARENA_H);
ctx.fillRect(ARENA_W - 18, 0, 18, ARENA_H);
// The floor, with pebbles.
ctx.fillStyle = '#58454e';
ctx.fillRect(0, FLOOR, ARENA_W, ARENA_H - FLOOR);
ctx.fillStyle = '#6e5862';
ctx.fillRect(0, FLOOR, ARENA_W, 3);
for (let i = 0; i < 30; i++) {
ctx.fillStyle = '#56434c';
ctx.beginPath();
ctx.ellipse(noise(i * 1.7) * ARENA_W, FLOOR + 8 + noise(i * 2.9) * 24, 2 + noise(i) * 4, 1.5 + noise(i * 3) * 2, 0, 0, Math.PI * 2);
ctx.fill();
}
// Glowing mushrooms by the walls.
for (const [x, size] of [[34, 1], [52, 0.7], [430, 0.9], [448, 1.2]] as const) {
const pulse = 0.6 + 0.4 * Math.sin(clock * 2 + x);
const glow = ctx.createRadialGradient(x, FLOOR - 8 * size, 0, x, FLOOR - 8 * size, 26 * size);
glow.addColorStop(0, `rgba(120, 255, 200, ${0.35 * pulse})`);
glow.addColorStop(1, 'rgba(120, 255, 200, 0)');
ctx.fillStyle = glow;
ctx.fillRect(x - 30, FLOOR - 40, 60, 40);
ctx.fillStyle = '#d8f5e9';
ctx.fillRect(x - 1.5 * size, FLOOR - 8 * size, 3 * size, 8 * size);
ctx.fillStyle = '#5fe6b4';
ctx.beginPath();
ctx.ellipse(x, FLOOR - 8 * size, 7 * size, 4 * size, 0, Math.PI, Math.PI * 2);
ctx.fill();
}
}
/** Short hairs sticking out along a line, so legs and body look hairy. */
function hairs(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, count: number, length: number, seed: number): void {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
ctx.beginPath();
for (let i = 0; i < count; i++) {
const t = (i + 0.5) / count;
const px = x1 + dx * t;
const py = y1 + dy * t;
const side = i % 2 === 0 ? 1 : -1;
const l = length * (0.6 + noise(seed + i) * 0.7);
ctx.moveTo(px, py);
ctx.lineTo(px + nx * side * l + dx / len * l * 0.4, py + ny * side * l + dy / len * l * 0.4);
}
ctx.stroke();
}
function drawSpider(ctx: CanvasRenderingContext2D, fight: BossFight): void {
const { spider } = fight;
const f = spider.facing;
const dead = fight.result === 'won' ? ease(Math.min(1, spider.deadTime / 0.8)) : 0;
const breathe = Math.sin(fight.clock * 2.5) * 2;
// The body bobs a little as the legs step.
const stepping = spider.steps.reduce((bob, step) => (step ? Math.max(bob, Math.sin(step.t * Math.PI)) : bob), 0);
const bodyY = 168 + breathe - stepping * 2.5 + dead * (FLOOR - 40 - 168);
const x = spider.x;
// Legs: hip on the body, knee up high, foot planted on the floor (or lifted mid-step).
const legs = LEG_SPREAD.map((_spread, k) => {
const hip = { x: x + f * 22 + (k - 3.5) * 6, y: bodyY + 8 };
const step = spider.steps[k];
let foot = step
? { x: step.from + (step.to - step.from) * ease(Math.min(1, step.t)), y: FLOOR - Math.sin(Math.min(1, step.t) * Math.PI) * 12 }
: { x: spider.feet[k], y: FLOOR };
if (k === spider.stomper) {
// The stomping leg flicks up over Rog and slams down.
const high = { x: spider.target, y: FLOOR - 110 };
if (spider.state === 'windup') {
const t = ease(Math.min(1, spider.stateTime / STOMP_WINDUP));
foot = { x: foot.x + (high.x - foot.x) * t, y: foot.y + (high.y - foot.y) * t };
} else if (spider.state === 'slam') {
const t = Math.min(1, spider.stateTime / STOMP_SLAM);
foot = { x: high.x, y: high.y + (FLOOR - high.y) * t * t };
} else {
foot = { x: spider.target, y: FLOOR };
}
}
if (dead > 0) {
// Legs curl up when it's beaten.
foot = { x: foot.x + (hip.x + (foot.x - hip.x) * 0.25 - foot.x) * dead, y: foot.y + (bodyY - 45 - foot.y) * dead };
}
const reach = Math.abs(foot.x - hip.x);
const knee = {
x: (hip.x + foot.x) / 2 + (foot.x - hip.x) * 0.12,
y: Math.min(hip.y, foot.y) - 55 - reach * 0.1 + dead * 30,
};
return { hip, knee, foot };
});
const furDark = '#24150d';
const fur = '#3d2616';
// Legs on the far side first (darker), then the body, then the near legs.
const drawLeg = (leg: (typeof legs)[number], k: number, near: boolean) => {
ctx.strokeStyle = near ? fur : furDark;
ctx.lineCap = 'round';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(leg.hip.x, leg.hip.y);
ctx.lineTo(leg.knee.x, leg.knee.y);
ctx.stroke();
ctx.lineWidth = 4.5;
ctx.beginPath();
ctx.moveTo(leg.knee.x, leg.knee.y);
ctx.lineTo(leg.foot.x, leg.foot.y);
ctx.stroke();
// Orange bands at the knees, like a tarantula.
ctx.fillStyle = '#b8642a';
ctx.beginPath();
ctx.arc(leg.knee.x, leg.knee.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = near ? '#5a3a24' : '#3a2416';
ctx.lineWidth = 1;
hairs(ctx, leg.hip.x, leg.hip.y, leg.knee.x, leg.knee.y, 10, 5, k * 31);
hairs(ctx, leg.knee.x, leg.knee.y, leg.foot.x, leg.foot.y, 12, 4, k * 47);
};
legs.forEach((leg, k) => k % 2 === 1 && drawLeg(leg, k, false));
const flash = spider.hurt > 0;
ctx.save();
if (dead > 0) {
ctx.translate(x, bodyY);
ctx.rotate(dead * Math.PI * f * 0.08);
ctx.translate(-x, -bodyY);
}
// Big hairy abdomen behind, with orange markings. It lifts and wiggles before launching a little spider.
const lift = spider.state === 'launch' ? ease(Math.min(1, spider.stateTime / LAUNCH_WINDUP)) : 0;
const ax = x - f * (42 + lift * 8) + Math.sin(fight.clock * 30) * lift * 1.5;
const ay = bodyY - 10 - lift * 14;
const abdomen = ctx.createRadialGradient(ax - f * 10, ay - 18, 4, ax, ay, 64);
abdomen.addColorStop(0, '#5a3a24');
abdomen.addColorStop(1, '#1e120c');
ctx.fillStyle = abdomen;
ctx.beginPath();
ctx.ellipse(ax, ay, 62, 46, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(184, 100, 42, 0.7)';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(ax - f * (24 - i * 16), ay - 30 + i * 4);
ctx.lineTo(ax - f * (14 - i * 16), ay - 18 + i * 4);
ctx.lineTo(ax - f * (4 - i * 16), ay - 30 + i * 4);
ctx.fill();
}
// Hair all around the outside.
ctx.strokeStyle = '#2e1c11';
ctx.lineWidth = 1.2;
ctx.beginPath();
for (let i = 0; i < 90; i++) {
const a = (i / 90) * Math.PI * 2;
const px = ax + Math.cos(a) * 60;
const py = ay + Math.sin(a) * 44;
const l = 5 + noise(i * 1.3) * 7 + Math.sin(fight.clock * 3 + i) * 0.8;
ctx.moveTo(px, py);
ctx.lineTo(px + Math.cos(a) * l, py + Math.sin(a) * l);
}
ctx.stroke();
// Head-and-chest in front.
// (no warning before spitting: the head just lunges forward as the goo comes out)
let rear = 0;
// Lunges its head forward while spraying goo.
if (spider.state === 'gooWindup') rear = ease(Math.min(1, spider.stateTime / GOO_WINDUP));
if (spider.state === 'goo') rear = -0.7;
const hx = x + f * (30 - rear * 7);
const hy = bodyY + 6 - rear * 8;
const head = ctx.createRadialGradient(hx, hy - 12, 3, hx, hy, 40);
head.addColorStop(0, '#5a3a24');
head.addColorStop(1, '#24150d');
ctx.fillStyle = head;
ctx.beginPath();
ctx.ellipse(hx, hy, 38, 29, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#2e1c11';
ctx.beginPath();
for (let i = 0; i < 50; i++) {
const a = (i / 50) * Math.PI * 2;
const px = hx + Math.cos(a) * 37;
const py = hy + Math.sin(a) * 28;
const l = 4 + noise(i * 2.3) * 5;
ctx.moveTo(px, py);
ctx.lineTo(px + Math.cos(a) * l, py + Math.sin(a) * l);
}
ctx.stroke();
// Big curved fangs.
ctx.fillStyle = '#1a0f0a';
for (const side of [-1, 1]) {
const fx = hx + f * 30 + side * 5;
ctx.beginPath();
ctx.moveTo(fx - 4, hy + 14);
ctx.quadraticCurveTo(fx + f * 6, hy + 30, fx + f * 1, hy + 38);
ctx.lineTo(fx + 4, hy + 16);
ctx.fill();
}
// A cluster of glowing red eyes (they turn into Xs when it's beaten).
const eyes: [number, number, number][] = [[18, -14, 4.5], [27, -10, 4.5], [12, -6, 3], [31, -2, 3], [22, -20, 2.5], [33, -16, 2.5], [8, -16, 2], [36, -8, 2]];
for (const [ex, ey, r] of eyes) {
const px = hx + f * ex;
const py = hy + ey;
if (dead > 0.5) {
ctx.strokeStyle = '#ff6a6a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(px - r, py - r);
ctx.lineTo(px + r, py + r);
ctx.moveTo(px + r, py - r);
ctx.lineTo(px - r, py + r);
ctx.stroke();
continue;
}
const glow = ctx.createRadialGradient(px, py, 0, px, py, r * 2.4);
glow.addColorStop(0, 'rgba(255, 60, 60, 0.6)');
glow.addColorStop(1, 'rgba(255, 60, 60, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(px, py, r * 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#d61f2a';
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffd0d0';
ctx.beginPath();
ctx.arc(px - r * 0.3, py - r * 0.3, r * 0.3, 0, Math.PI * 2);
ctx.fill();
}
// The goo warning: a glowing green mouth with goo dripping from the fangs.
if (spider.state === 'gooWindup' && dead === 0) {
const t = Math.min(1, spider.stateTime / GOO_WINDUP);
const mx = hx + f * 32;
const my = hy + 22;
const glow = ctx.createRadialGradient(mx, my, 0, mx, my, 8 + t * 16);
glow.addColorStop(0, `rgba(170, 255, 120, ${0.5 + t * 0.5})`);
glow.addColorStop(1, 'rgba(90, 220, 60, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(mx, my, 8 + t * 16, 0, Math.PI * 2);
ctx.fill();
for (let i = 0; i < 3; i++) {
const drip = (fight.clock * 2.5 + i / 3) % 1;
ctx.fillStyle = `rgba(110, 230, 70, ${1 - drip})`;
ctx.beginPath();
ctx.ellipse(mx + (i - 1) * 5, my + 6 + drip * 26, 2, 3 + drip * 2, 0, 0, Math.PI * 2);
ctx.fill();
}
}
// Flashes white when punched.
if (flash) {
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.beginPath();
ctx.ellipse(ax, ay, 62, 46, 0, 0, Math.PI * 2);
ctx.ellipse(hx, hy, 38, 29, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
ctx.restore();
legs.forEach((leg, k) => k % 2 === 0 && drawLeg(leg, k, true));
}
function drawRog(ctx: CanvasRenderingContext2D, fight: BossFight): void {
const { rog } = fight;
// Flickers while hurt.
if (rog.hurt > 0 && Math.floor(rog.hurt * 14) % 2 === 0) return;
// A soft light around Rog so he stands out in the dark cave.
const light = ctx.createRadialGradient(rog.x, rog.y - 18, 0, rog.x, rog.y - 18, 46);
light.addColorStop(0, 'rgba(255, 236, 190, 0.45)');
light.addColorStop(1, 'rgba(255, 236, 190, 0)');
ctx.fillStyle = light;
ctx.beginPath();
ctx.arc(rog.x, rog.y - 18, 46, 0, Math.PI * 2);
ctx.fill();
const lost = fight.result === 'lost';
const pose = lost ? 'fall' : !rog.onGround ? 'jump' : Math.abs(rog.vx) > 10 ? 'run' : fight.result === 'won' ? 'cheer' : 'stand';
drawStickman(ctx, rog.x, rog.y, rog.facing, pose, rog.cycle + fight.clock * (pose === 'cheer' ? 6 : 0), rog.vy);
if (rog.punch > 0) {
// A fast punch: the arm shoots out and comes back.
const out = Math.sin(Math.PI * (1 - rog.punch / PUNCH_TIME));
const sx = rog.x + rog.facing * 2;
const sy = rog.y - 28;
const fx = rog.x + rog.facing * (8 + 16 * out);
ctx.strokeStyle = '#1d1724';
ctx.lineWidth = 2.6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(fx, sy - 1);
ctx.stroke();
ctx.fillStyle = '#1d1724';
ctx.beginPath();
ctx.arc(fx, sy - 1, 3.4, 0, Math.PI * 2);
ctx.fill();
if (out > 0.6) {
// Whoosh lines.
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.lineWidth = 1;
for (const dy of [-4, 0, 4]) {
ctx.beginPath();
ctx.moveTo(fx - rog.facing * 14, sy - 1 + dy);
ctx.lineTo(fx - rog.facing * 6, sy - 1 + dy);
ctx.stroke();
}
}
}
}
/** A little hairy spider: hops at Rog, with a tiny health bar over its head. */
function drawLittle(ctx: CanvasRenderingContext2D, fight: BossFight): void {
const little = fight.little;
if (!little) return;
const { x, y } = little;
// A silk thread back to the big spider while it's flying out.
if (little.state === 'flying') {
const from = spiderSpinneret(fight.spider);
ctx.strokeStyle = 'rgba(235, 230, 240, 0.7)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(x, y - 8);
ctx.stroke();
}
const dying = little.state === 'dead' ? Math.min(1, little.stateTime / LITTLE_DEATH) : 0;
const squash = little.state === 'crouch' ? Math.min(1, little.stateTime / LITTLE_CROUCH) : 0;
ctx.save();
ctx.globalAlpha = 1 - dying;
ctx.translate(x, y);
ctx.scale(1.35, 1.35);
if (dying > 0) ctx.rotate(dying * Math.PI);
const bodyY = -9 + squash * 3;
// Eight hairy little legs.
ctx.strokeStyle = '#2e1c11';
ctx.lineCap = 'round';
for (let k = 0; k < 8; k++) {
const side = k < 4 ? -1 : 1;
const i = k % 4;
const step = Math.sin(little.walk * 0.25 + k * 1.7) * 2;
const footX = side * (6 + i * 4) + (k % 2 ? step : -step);
const kneeX = side * (4 + i * 3);
const kneeY = bodyY - 7 - (i % 2) * 2 + squash * 2;
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(side * 2, bodyY);
ctx.lineTo(kneeX, kneeY);
ctx.lineTo(footX, little.state === 'flying' || little.state === 'hop' ? bodyY + 6 : 0);
ctx.stroke();
}
// Fuzzy body and head.
const f = little.facing;
ctx.fillStyle = '#3d2616';
ctx.beginPath();
ctx.ellipse(-f * 5, bodyY - 1, 8, 6.5, 0, 0, Math.PI * 2);
ctx.ellipse(f * 5, bodyY + 1, 5, 4.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#2e1c11';
ctx.lineWidth = 0.7;
ctx.beginPath();
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
ctx.moveTo(-f * 5 + Math.cos(a) * 7.5, bodyY - 1 + Math.sin(a) * 6);
ctx.lineTo(-f * 5 + Math.cos(a) * 10, bodyY - 1 + Math.sin(a) * 8);
}
ctx.stroke();
ctx.fillStyle = '#b8642a';
ctx.fillRect(-f * 5 - 2, bodyY - 5, 4, 2);
// Red eyes.
ctx.fillStyle = '#ff3c3c';
for (const [ex, ey] of [[7, 0], [8.5, 2], [6, 2]]) {
ctx.beginPath();
ctx.arc(f * ex, bodyY + ey - 1, 1, 0, Math.PI * 2);
ctx.fill();
}
if (little.hurt > 0) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.beginPath();
ctx.ellipse(0, bodyY, 12, 8, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// A poof of dust when it's beaten.
if (dying > 0) {
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
ctx.fillStyle = `rgba(200, 190, 180, ${0.6 * (1 - dying)})`;
ctx.beginPath();
ctx.arc(x + Math.cos(a) * dying * 18, y - 9 + Math.sin(a) * dying * 12, 3 + dying * 4, 0, Math.PI * 2);
ctx.fill();
}
return;
}
// Tiny health bar: 5 little blocks.
for (let i = 0; i < LITTLE_MAX_HP; i++) {
ctx.fillStyle = i < little.hp ? '#e5484d' : '#3a2b33';
ctx.fillRect(x - 12 + i * 5, y - 36, 4, 3);
}
}
/** Globs of green goo flying through the air, with a few drops trailing behind. */
function drawGoo(ctx: CanvasRenderingContext2D, fight: BossFight): void {
for (const glob of fight.goo) {
const speed = Math.hypot(glob.vx, glob.vy) || 1;
for (let i = 1; i <= 3; i++) {
ctx.fillStyle = `rgba(110, 230, 70, ${0.6 - i * 0.15})`;
ctx.beginPath();
ctx.arc(glob.x - (glob.vx / speed) * i * 6, glob.y - (glob.vy / speed) * i * 6, GOO_RADIUS - i * 1.5, 0, Math.PI * 2);
ctx.fill();
}
// A wobbly, shiny blob.
const wobble = Math.sin(fight.clock * 25) * 0.15;
const goo = ctx.createRadialGradient(glob.x - 2, glob.y - 2, 0, glob.x, glob.y, GOO_RADIUS + 1);
goo.addColorStop(0, '#d8ff9a');
goo.addColorStop(0.5, '#6be33f');
goo.addColorStop(1, '#2f8f1e');
ctx.fillStyle = goo;
ctx.beginPath();
ctx.ellipse(glob.x, glob.y, (GOO_RADIUS + 1) * (1 + wobble), (GOO_RADIUS + 1) * (1 - wobble), Math.atan2(glob.vy, glob.vx), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.beginPath();
ctx.arc(glob.x - 2.5, glob.y - 2.5, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
/** Splats of goo: puddles on the floor, or a slimy splash on Rog. */
function drawSplats(ctx: CanvasRenderingContext2D, fight: BossFight, onRog: boolean): void {
for (const splat of fight.splats) {
if (splat.onRog !== onRog) continue;
const t = splat.age / SPLAT_TIME;
const fade = Math.min(1, (1 - t) * 3);
if (onRog) {
// Splashes out from where it hit, and some sticks to Rog and drips off.
if (splat.age > 1) continue;
const a = splat.age;
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const r = 4 + a * 40;
ctx.fillStyle = `rgba(110, 230, 70, ${1 - a})`;
ctx.beginPath();
ctx.arc(splat.x + Math.cos(angle) * r, splat.y + Math.sin(angle) * r + a * a * 60, 2.5 * (1 - a) + 0.5, 0, Math.PI * 2);
ctx.fill();
}
const { rog } = fight;
ctx.fillStyle = `rgba(110, 230, 70, ${0.8 * (1 - a)})`;
ctx.beginPath();
ctx.ellipse(rog.x, rog.y - 20, 8, 10, 0, 0, Math.PI * 2);
ctx.fill();
continue;
}
// A puddle that spreads out, with a splash at first.
const spread = Math.min(1, splat.age * 6);
ctx.fillStyle = `rgba(90, 210, 60, ${0.8 * fade})`;
ctx.beginPath();
ctx.ellipse(splat.x, FLOOR + 1, 6 + spread * 16, 2 + spread * 2.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(200, 255, 150, ${0.6 * fade})`;
ctx.beginPath();
ctx.ellipse(splat.x - 4, FLOOR, 3 + spread * 4, 1, 0, 0, Math.PI * 2);
ctx.fill();
if (splat.age < 0.5) {
const a = splat.age / 0.5;
for (let i = 0; i < 7; i++) {
const angle = Math.PI + (i / 6) * Math.PI;
const r = 6 + a * 26;
ctx.fillStyle = `rgba(110, 230, 70, ${1 - a})`;
ctx.beginPath();
ctx.arc(splat.x + Math.cos(angle) * r, FLOOR + Math.sin(angle) * r * 0.8 + a * a * 30, 2.2 * (1 - a) + 0.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
/** Dust puffing up where the stomp landed. */
function drawDust(ctx: CanvasRenderingContext2D, fight: BossFight): void {
if (fight.shake <= 0) return;
const t = 1 - fight.shake;
for (let i = 0; i < 8; i++) {
const dir = i % 2 === 0 ? 1 : -1;
const x = fight.spider.target + dir * (6 + t * (20 + noise(i) * 30));
const y = FLOOR - 4 - t * (6 + noise(i * 3) * 18);
ctx.fillStyle = `rgba(150, 130, 120, ${0.5 * fight.shake})`;
ctx.beginPath();
ctx.arc(x, y, 4 + t * 8, 0, Math.PI * 2);
ctx.fill();
}
}
/**
* For the 3D views: the cave is drawn in 3D, so only the health bars, messages, damage numbers
* and the little spider's health go on top. `project` finds where a point of the fight is on screen.
*/
export function drawBossHud(
ctx: CanvasRenderingContext2D,
fight: BossFight,
project: (x: number, y: number) => { x: number; y: number } | null,
): void {
for (const pop of fight.pops) {
const p = project(pop.x, pop.y);
if (!p) continue;
ctx.globalAlpha = 1 - pop.age / 0.9;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.strokeStyle = '#1d1724';
ctx.strokeText(pop.text, p.x, p.y - pop.age * 26);
ctx.fillStyle = pop.color;
ctx.fillText(pop.text, p.x, p.y - pop.age * 26);
}
ctx.globalAlpha = 1;
const little = fight.little;
if (little && little.state !== 'dead') {
const p = project(little.x, little.y - 34);
if (p) {
for (let i = 0; i < LITTLE_MAX_HP; i++) {
ctx.fillStyle = i < little.hp ? '#e5484d' : '#3a2b33';
ctx.fillRect(p.x - 12 + i * 5, p.y, 4, 3);
}
}
}
drawBars(ctx, fight);
drawMessages(ctx, fight, true);
}
function drawBars(ctx: CanvasRenderingContext2D, fight: BossFight): void {
const bar = (x: number, label: string, hp: number, max: number, color: string, alignRight: boolean) => {
const w = 150;
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
ctx.fillRect(x - 2, 30, w + 4, 14);
ctx.fillStyle = '#3a2b33';
ctx.fillRect(x, 32, w, 10);
ctx.fillStyle = color;
const filled = (w * hp) / max;
ctx.fillRect(alignRight ? x + w - filled : x, 32, filled, 10);
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.textAlign = alignRight ? 'right' : 'left';
ctx.fillStyle = '#ffffff';
ctx.fillText(`${label} ${hp}/${max} HP`, alignRight ? x + w : x, 26);
};
bar(150, 'ROG', fight.rog.hp, ROG_MAX_HP, '#46c96b', false);
bar(ARENA_W - 168, 'GIANT SPIDER', fight.spider.hp, SPIDER_MAX_HP, '#e5484d', true);
}
function drawMessages(ctx: CanvasRenderingContext2D, fight: BossFight, threeD = false): void {
ctx.textAlign = 'center';
if (fight.result === 'fighting' && fight.clock < 4) {
ctx.globalAlpha = Math.min(1, (4 - fight.clock) * 2);
ctx.font = 'bold 13px system-ui, sans-serif';
ctx.fillStyle = '#ffcc33';
ctx.fillText(
threeD ? '↑ ↓ to move, 1 to jump, Space to PUNCH: jump up to punch the spider!' : 'Space to PUNCH, ↑ or 1 to jump: jump up to punch the spider!',
ARENA_W / 2,
66,
);
ctx.globalAlpha = 1;
}
if (fight.result === 'fighting' && fight.little && fight.little.state !== 'dead' && fight.clock >= 4) {
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.fillStyle = '#ffcc33';
ctx.fillText('The giant spider is waiting... beat the little spider!', ARENA_W / 2, 66);
}
if (fight.result === 'fighting' && fight.spider.state === 'gooWindup') {
// The goo warning, big and green in the middle of the screen.
const pulse = 1 + Math.sin(fight.clock * 25) * 0.06;
ctx.save();
ctx.translate(ARENA_W / 2, 104);
ctx.scale(pulse, pulse);
ctx.font = '900 22px system-ui, sans-serif';
ctx.lineWidth = 5;
ctx.strokeStyle = '#123d0c';
ctx.strokeText('⚠ GOO INCOMING!', 0, 0);
ctx.fillStyle = '#8dff5a';
ctx.fillText('⚠ GOO INCOMING!', 0, 0);
ctx.restore();
}
if (fight.result === 'fighting') return;
const won = fight.result === 'won';
ctx.fillStyle = 'rgba(10, 6, 12, 0.7)';
ctx.fillRect(0, ARENA_H / 2 - 32, ARENA_W, 60);
ctx.font = '900 22px system-ui, sans-serif';
ctx.fillStyle = won ? '#ffcc33' : '#ff6a6a';
ctx.fillText(won ? 'YOU BEAT THE GIANT SPIDER!' : 'The spider got Rog!', ARENA_W / 2, ARENA_H / 2 - 4);
ctx.font = '12px system-ui, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(won ? 'On to Level 1...' : 'Try again...', ARENA_W / 2, ARENA_H / 2 + 16);
}
