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 {
CanvasTexture,
CircleGeometry,
ConeGeometry,
CylinderGeometry,
Group,
InstancedMesh,
Mesh,
MeshStandardMaterial,
Object3D,
PlaneGeometry,
PointLight,
Quaternion,
RepeatWrapping,
SRGBColorSpace,
SphereGeometry,
Sprite,
SpriteMaterial,
Vector3,
type Material,
} from 'three';
import {
ARENA_W,
FLOOR,
GOO_RADIUS,
GOO_WINDUP,
LEG_SPREAD,
LITTLE_CROUCH,
LITTLE_DEATH,
PUNCH_TIME,
SPLAT_TIME,
STOMP_SLAM,
STOMP_WINDUP,
type BossFight,
} from './boss.js';
/**
* The boss fight in 3D (first and third person): a dark cave with stalactites and glowing
* mushrooms, the giant hairy spider walking on eight real legs, little spiders, goo and dust.
* It follows exactly the same fight as the flat view (boss.ts); this only draws it.
* Fight units are used directly: x across, y down (so the 3D height is -y), z is depth.
*/
const UP = new Vector3(0, 1, 0);
/** In 3D the spider's body is held a bit higher than in the flat view, so Rog fits underneath it. */
const BODY_LIFT = 25;
/** How far apart the left and right legs are planted. */
const FOOT_Z = 62;
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);
/** A point in the fight (x, y down) at depth z, as a 3D position. */
const at = (x: number, y: number, z = 0) => new Vector3(x, -y, z);
function paint(width: number, height: number, draw: (ctx: CanvasRenderingContext2D) => void): CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
draw(canvas.getContext('2d')!);
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
return texture;
}
/** Stretch a cylinder (1 unit tall) from a to b. */
function setBone(mesh: Mesh, a: Vector3, b: Vector3): void {
const direction = b.clone().sub(a);
const length = direction.length() || 0.001;
mesh.position.copy(a).addScaledVector(direction, 0.5);
mesh.quaternion.copy(new Quaternion().setFromUnitVectors(UP, direction.divideScalar(length)));
mesh.scale.set(1, length, 1);
}
export class BossCave3D {
readonly group = new Group();
private body = new Group();
private head = new Group();
private bodyMaterials: MeshStandardMaterial[] = [];
private eyeMaterial: MeshStandardMaterial;
private legs: { upper: Mesh; lower: Mesh; knee: Mesh }[] = [];
private little = new Group();
private littleLegs: Mesh[] = [];
private goo: Mesh[] = [];
private splats: Mesh[] = [];
private rogSplat: Mesh;
private dust: Sprite[] = [];
private fist: Mesh;
/** A warm light that follows Rog, so he's easy to see in the dark. */
readonly rogLight = new PointLight('#ffe8c0', 2600, 220, 2);
private eyeLight = new PointLight('#ff3030', 900, 160, 2);
/** The goo warning: a green glow in the spider's mouth. */
private gooGlow: Mesh;
private gooLight = new PointLight('#8dff5a', 0, 160, 2);
constructor() {
this.buildCave();
this.buildSpider();
this.buildLittle();
const gooMaterial = new MeshStandardMaterial({ color: '#6be33f', emissive: '#2f8f1e', emissiveIntensity: 0.6, roughness: 0.2 });
for (let i = 0; i < 40; i++) {
const glob = new Mesh(new SphereGeometry(GOO_RADIUS + 0.5, 10, 8), gooMaterial);
glob.visible = false;
this.goo.push(glob);
this.group.add(glob);
}
for (let i = 0; i < 40; i++) {
const splat = new Mesh(
new CircleGeometry(1, 16),
new MeshStandardMaterial({ color: '#5ad23c', emissive: '#2a7a1a', emissiveIntensity: 0.4, transparent: true, roughness: 0.2 }),
);
splat.rotation.x = -Math.PI / 2;
splat.visible = false;
this.splats.push(splat);
this.group.add(splat);
}
this.rogSplat = new Mesh(
new SphereGeometry(1, 12, 8),
new MeshStandardMaterial({ color: '#6be33f', transparent: true, opacity: 0.7, roughness: 0.2 }),
);
this.group.add(this.rogSplat);
const dustTexture = paint(64, 64, (ctx) => {
const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, 'rgba(160,140,130,0.8)');
g.addColorStop(1, 'rgba(160,140,130,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
});
for (let i = 0; i < 8; i++) {
const puff = new Sprite(new SpriteMaterial({ map: dustTexture, transparent: true, depthWrite: false }));
puff.visible = false;
this.dust.push(puff);
this.group.add(puff);
}
this.fist = new Mesh(new SphereGeometry(3.4, 10, 8), new MeshStandardMaterial({ color: '#1d1724', roughness: 0.6 }));
this.fist.visible = false;
this.group.add(this.fist);
this.group.add(this.rogLight, this.eyeLight);
this.gooGlow = new Mesh(
new SphereGeometry(1, 14, 10),
new MeshStandardMaterial({ color: '#8dff5a', emissive: '#6be33f', emissiveIntensity: 2, transparent: true, opacity: 0.85 }),
);
this.gooGlow.position.set(36, -16, 0);
this.gooGlow.visible = false;
this.head.add(this.gooGlow);
this.gooLight.position.set(40, -16, 0);
this.head.add(this.gooLight);
this.eyeMaterial = new MeshStandardMaterial({ color: '#d61f2a', emissive: '#ff2020', emissiveIntensity: 1.4 });
this.head.traverse((part) => {
if (part instanceof Mesh && part.userData.eye) part.material = this.eyeMaterial;
});
}
// ---------- building ----------
private buildCave(): void {
const rock = paint(256, 256, (ctx) => {
ctx.fillStyle = '#3b2c33';
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 160; i++) {
ctx.fillStyle = noise(i) > 0.5 ? 'rgba(86, 67, 76, 0.7)' : 'rgba(40, 28, 34, 0.7)';
ctx.beginPath();
ctx.ellipse(noise(i * 3.1) * 256, noise(i * 7.7) * 256, 4 + noise(i * 2) * 14, 3 + noise(i * 5) * 8, noise(i * 9) * 3, 0, Math.PI * 2);
ctx.fill();
}
});
rock.wrapS = rock.wrapT = RepeatWrapping;
const floorTexture = rock.clone();
floorTexture.repeat.set(5, 4);
floorTexture.needsUpdate = true;
const floor = new Mesh(new PlaneGeometry(700, 500), new MeshStandardMaterial({ map: floorTexture, roughness: 0.95 }));
floor.rotation.x = -Math.PI / 2;
floor.position.set(ARENA_W / 2, -FLOOR, 0);
floor.receiveShadow = true;
this.group.add(floor);
const wallTexture = rock.clone();
wallTexture.repeat.set(4, 2);
wallTexture.needsUpdate = true;
const wallMaterial = new MeshStandardMaterial({ map: wallTexture, roughness: 1, color: '#a8949e' });
const wall = (w: number, h: number, x: number, z: number, turn: number) => {
const mesh = new Mesh(new PlaneGeometry(w, h), wallMaterial);
mesh.position.set(x, -FLOOR + h / 2, z);
mesh.rotation.y = turn;
mesh.receiveShadow = true;
this.group.add(mesh);
};
wall(700, 300, ARENA_W / 2, -170, 0);
wall(700, 300, ARENA_W / 2, 170, Math.PI);
wall(400, 300, 0, 0, Math.PI / 2);
wall(400, 300, ARENA_W, 0, -Math.PI / 2);
const ceiling = new Mesh(new PlaneGeometry(700, 400), wallMaterial);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.set(ARENA_W / 2, -FLOOR + 260, 0);
this.group.add(ceiling);
// Stalactites hanging from the ceiling, and boulders round the edges.
const stoneMaterial = new MeshStandardMaterial({ color: '#4a3840', roughness: 0.95 });
for (let i = 0; i < 40; i++) {
const length = 20 + noise(i * 13) * 50;
const tip = new Mesh(new ConeGeometry(6 + noise(i) * 8, length, 7), stoneMaterial);
tip.rotation.x = Math.PI;
tip.position.set(noise(i * 3.3) * ARENA_W, -FLOOR + 260 - length / 2, (noise(i * 5.1) - 0.5) * 320);
this.group.add(tip);
}
for (let i = 0; i < 24; i++) {
const size = 8 + noise(i * 4) * 20;
const boulder = new Mesh(new SphereGeometry(size, 9, 7), stoneMaterial);
const side = i % 2 ? 1 : -1;
boulder.scale.y = 0.6;
boulder.position.set(noise(i * 6.6) * ARENA_W, -FLOOR + size * 0.3, side * (120 + noise(i * 2.2) * 40));
boulder.castShadow = boulder.receiveShadow = true;
this.group.add(boulder);
}
// Glowing mushrooms in the corners, lighting the cave a little green.
const stem = new MeshStandardMaterial({ color: '#d8f5e9', emissive: '#7fd6b4', emissiveIntensity: 0.4 });
const capMaterial = new MeshStandardMaterial({ color: '#5fe6b4', emissive: '#3fd69a', emissiveIntensity: 1.2 });
for (const [x, z, size] of [[34, -110, 1], [52, -90, 0.7], [430, 110, 0.9], [448, -120, 1.2], [240, -150, 1], [300, 140, 0.8]]) {
const s = new Mesh(new CylinderGeometry(1.6 * size, 2 * size, 9 * size, 8), stem);
s.position.set(x, -FLOOR + 4.5 * size, z);
const cap = new Mesh(new SphereGeometry(7 * size, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), capMaterial);
cap.position.set(x, -FLOOR + 9 * size, z);
this.group.add(s, cap);
}
for (const [x, z] of [[40, -100], [440, 0]]) {
const glow = new PointLight('#7dffcf', 1800, 260, 2);
glow.position.set(x, -FLOOR + 30, z);
this.group.add(glow);
}
// A dim purple glow from up high, so the spider's hairy body shows up in the dark.
const fill = new PointLight('#e0d0f0', 40000, 900, 2);
fill.position.set(ARENA_W / 2, -FLOOR + 220, 80);
this.group.add(fill);
}
private buildSpider(): void {
const fur = paint(128, 64, (ctx) => {
const shade = ctx.createLinearGradient(0, 0, 0, 64);
shade.addColorStop(0, '#4a2e1b');
shade.addColorStop(0.5, '#7a4e2e');
shade.addColorStop(1, '#3d2616');
ctx.fillStyle = shade;
ctx.fillRect(0, 0, 128, 64);
ctx.strokeStyle = 'rgba(20, 10, 5, 0.6)';
ctx.lineWidth = 1;
for (let i = 0; i < 500; i++) {
const x = noise(i * 1.7) * 128;
const y = noise(i * 3.9) * 64;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (noise(i) - 0.5) * 6, y + 3 + noise(i * 2) * 4);
ctx.stroke();
}
// Orange tarantula markings.
ctx.fillStyle = 'rgba(184, 100, 42, 0.8)';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(30 + i * 30, 10);
ctx.lineTo(40 + i * 30, 24);
ctx.lineTo(50 + i * 30, 10);
ctx.fill();
}
});
const furMaterial = new MeshStandardMaterial({ map: fur, roughness: 0.9, emissive: '#ffffff', emissiveIntensity: 0 });
const headMaterial = new MeshStandardMaterial({ color: '#5a3a24', roughness: 0.85, emissive: '#ffffff', emissiveIntensity: 0 });
this.bodyMaterials.push(furMaterial, headMaterial);
const abdomen = new Mesh(new SphereGeometry(1, 24, 18), furMaterial);
abdomen.scale.set(62, 46, 52);
abdomen.position.set(-42, 0, 0);
abdomen.castShadow = true;
this.body.add(abdomen);
// Lots of bristly hairs sticking out of the abdomen.
const hairs = new InstancedMesh(new ConeGeometry(0.9, 9, 4), new MeshStandardMaterial({ color: '#3d2616', roughness: 1 }), 220);
const place = new Object3D();
for (let i = 0; i < 220; i++) {
const u = noise(i * 1.3) * Math.PI * 2;
const v = Math.acos(2 * noise(i * 7.1) - 1);
const n = new Vector3(Math.sin(v) * Math.cos(u), Math.cos(v), Math.sin(v) * Math.sin(u));
place.position.set(-42 + n.x * 62, n.y * 46, n.z * 52);
place.quaternion.setFromUnitVectors(UP, n);
place.updateMatrix();
hairs.setMatrixAt(i, place.matrix);
}
this.body.add(hairs);
// Head and chest, with fangs and a cluster of glowing eyes.
const headBall = new Mesh(new SphereGeometry(1, 20, 14), headMaterial);
headBall.scale.set(38, 29, 34);
headBall.castShadow = true;
this.head.add(headBall);
const fangMaterial = new MeshStandardMaterial({ color: '#1a0f0a', roughness: 0.4 });
for (const z of [-7, 7]) {
const fang = new Mesh(new ConeGeometry(3, 20, 8), fangMaterial);
fang.rotation.x = Math.PI;
fang.rotation.z = -0.3;
fang.position.set(30, -24, z);
this.head.add(fang);
}
const eyes: [number, number, number, number][] = [
[34, 12, -9, 4.5], [34, 12, 9, 4.5], [36, 4, -14, 3], [36, 4, 14, 3],
[30, 20, -5, 2.5], [30, 20, 5, 2.5], [28, 16, -15, 2], [28, 16, 15, 2],
];
for (const [ex, ey, ez, r] of eyes) {
const eye = new Mesh(new SphereGeometry(r, 10, 8));
eye.userData.eye = true;
eye.position.set(ex, ey, ez);
this.head.add(eye);
}
// (The goo warning glow is made in the constructor, once the head exists.)
this.body.add(this.head);
this.group.add(this.body);
this.eyeLight.position.set(40, 10, 0);
// Legs: two hairy segments with an orange knee, set every frame.
const legMaterial = new MeshStandardMaterial({ color: '#4a2e1b', roughness: 0.95 });
const kneeMaterial = new MeshStandardMaterial({ color: '#b8642a', roughness: 0.8 });
for (let k = 0; k < 8; k++) {
const upper = new Mesh(new CylinderGeometry(3.6, 3, 1, 8), legMaterial);
const lower = new Mesh(new CylinderGeometry(2.6, 1, 1, 8), legMaterial);
const knee = new Mesh(new SphereGeometry(4.2, 10, 8), kneeMaterial);
upper.castShadow = lower.castShadow = true;
this.legs.push({ upper, lower, knee });
this.group.add(upper, lower, knee);
}
}
private buildLittle(): void {
const fur = new MeshStandardMaterial({ color: '#3d2616', roughness: 0.9 });
const body = new Mesh(new SphereGeometry(1, 14, 10), fur);
body.scale.set(10, 8, 9);
body.position.set(-6, 0, 0);
const head = new Mesh(new SphereGeometry(1, 12, 8), fur);
head.scale.set(6.5, 5.5, 6);
head.position.set(7, 1, 0);
this.little.add(body, head);
const eye = new MeshStandardMaterial({ color: '#ff3c3c', emissive: '#ff2020', emissiveIntensity: 1.2 });
for (const z of [-2.5, 2.5]) {
const e = new Mesh(new SphereGeometry(1.4, 8, 6), eye);
e.position.set(12.5, 3, z);
this.little.add(e);
}
const leg = new MeshStandardMaterial({ color: '#2e1c11', roughness: 0.95 });
for (let k = 0; k < 8; k++) {
const piece = new Mesh(new CylinderGeometry(1, 0.6, 1, 6), leg);
this.littleLegs.push(piece);
this.little.add(piece);
}
this.little.visible = false;
this.group.add(this.little);
}
// ---------- every frame ----------
update(fight: BossFight): void {
this.updateSpider(fight);
this.updateLittle(fight);
this.updateGoo(fight);
this.updateDust(fight);
this.updateRogBits(fight);
}
private updateSpider(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;
const stepping = spider.steps.reduce((bob, step) => (step ? Math.max(bob, Math.sin(step.t * Math.PI)) : bob), 0);
const bodyY = 168 - BODY_LIFT + breathe - stepping * 2.5 + dead * (FLOOR - 40 - 168 + BODY_LIFT);
const x = spider.x;
this.body.position.copy(at(x, bodyY));
this.body.rotation.set(dead * 0.15 * f, f > 0 ? 0 : Math.PI, dead * 0.25);
// Lunges its head forward while spraying goo.
// Pulls its head back with a glowing green mouth before the goo (the warning), then lunges as it sprays.
const windup = spider.state === 'gooWindup' ? ease(Math.min(1, spider.stateTime / GOO_WINDUP)) : 0;
this.head.position.set(spider.state === 'goo' ? 36 : 30 - windup * 8, -6 + windup * 6, 0);
this.gooGlow.visible = windup > 0 && dead === 0;
this.gooGlow.scale.setScalar(2 + windup * 6 + Math.sin(fight.clock * 30) * 0.6);
this.gooLight.intensity = windup * 3000;
for (const material of this.bodyMaterials) material.emissiveIntensity = spider.hurt > 0 ? 0.5 : 0;
this.eyeMaterial.emissiveIntensity = dead > 0.5 ? 0.1 : 1.4;
this.eyeLight.intensity = dead > 0.5 ? 0 : 900;
this.eyeLight.position.copy(at(x + f * 60, bodyY - 10, 0));
LEG_SPREAD.forEach((spread, k) => {
const side = k % 2 === 0 ? 1 : -1;
const hip = at(x + f * 22 + (k - 3.5) * 5, bodyY + 8, side * 14);
const step = spider.steps[k];
let footX = step ? step.from + (step.to - step.from) * ease(Math.min(1, step.t)) : spider.feet[k];
let footY = step ? FLOOR - Math.sin(Math.min(1, step.t) * Math.PI) * 12 : FLOOR;
let footZ = side * (FOOT_Z + (Math.abs(spread) / 170) * 20);
if (k === spider.stomper) {
// The stomping leg flicks up over Rog (in the middle, where he is) and slams down.
const highY = FLOOR - 110;
if (spider.state === 'windup') {
const t = ease(Math.min(1, spider.stateTime / STOMP_WINDUP));
footX += (spider.target - footX) * t;
footY += (highY - footY) * t;
footZ *= 1 - t;
} else if (spider.state === 'slam') {
const t = Math.min(1, spider.stateTime / STOMP_SLAM);
footX = spider.target;
footY = highY + (FLOOR - highY) * t * t;
footZ = 0;
} else {
footX = spider.target;
footY = FLOOR;
footZ = 0;
}
}
let foot = at(footX, footY, footZ);
if (dead > 0) {
// Legs curl up when it's beaten.
foot = foot.clone().lerp(at(x + spread * 0.25, bodyY - 45, side * 20), dead);
}
const reach = Math.abs(footX - hip.x);
const knee = new Vector3((hip.x + foot.x) / 2 + (foot.x - hip.x) * 0.12, Math.max(hip.y, foot.y) + 55 + reach * 0.1 - dead * 30, (hip.z + foot.z) * 0.62);
const leg = this.legs[k];
setBone(leg.upper, hip, knee);
setBone(leg.lower, knee, foot);
leg.knee.position.copy(knee);
});
}
private updateLittle(fight: BossFight): void {
const little = fight.little;
this.little.visible = !!little;
if (!little) return;
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;
const size = 1.35 * (1 - dying * 0.8);
this.little.position.copy(at(little.x, little.y - 9 * size + squash * 3));
this.little.rotation.set(dying * Math.PI, little.facing > 0 ? 0 : Math.PI, 0);
this.little.scale.set(size, size * (1 - squash * 0.25), size);
const airborne = little.state === 'flying' || little.state === 'hop';
this.littleLegs.forEach((piece, k) => {
const side = k % 2 === 0 ? 1 : -1;
const i = Math.floor(k / 2);
const wiggle = Math.sin(little.walk * 0.3 + k * 1.7) * 2;
const hip = new Vector3(-4 + i * 4, 0, side * 4);
const foot = new Vector3(-10 + i * 7 + wiggle, airborne ? -4 : -9 + squash * 2, side * 13);
setBone(piece, hip, foot);
});
}
private updateGoo(fight: BossFight): void {
this.goo.forEach((glob, i) => {
const g = fight.goo[i];
glob.visible = !!g;
if (!g) return;
glob.position.copy(at(g.x, g.y));
const s = 1 + Math.sin(fight.clock * 25 + i) * 0.15;
glob.scale.set(s, 1 / s, s);
});
let rogSplat = 0;
this.splats.forEach((mesh, i) => {
const splat = fight.splats[i];
mesh.visible = !!splat && !splat.onRog;
if (splat?.onRog) rogSplat = Math.max(rogSplat, 1 - splat.age);
if (!splat || splat.onRog) return;
const spread = Math.min(1, splat.age * 6);
mesh.position.copy(at(splat.x, FLOOR - 0.5 - i * 0.01, 0));
mesh.scale.setScalar(6 + spread * 16);
(mesh.material as MeshStandardMaterial).opacity = Math.min(1, (1 - splat.age / SPLAT_TIME) * 3) * 0.85;
});
this.rogSplat.visible = rogSplat > 0;
this.rogSplat.position.copy(at(fight.rog.x, fight.rog.y - 20));
this.rogSplat.scale.set(6, 8, 6);
(this.rogSplat.material as MeshStandardMaterial).opacity = rogSplat * 0.5;
}
private updateDust(fight: BossFight): void {
const t = 1 - fight.shake;
this.dust.forEach((puff, i) => {
puff.visible = fight.shake > 0;
if (!puff.visible) return;
const angle = (i / this.dust.length) * Math.PI * 2;
const r = 6 + t * (20 + noise(i) * 30);
puff.position.copy(at(fight.spider.target + Math.cos(angle) * r, FLOOR - 4 - t * (6 + noise(i * 3) * 18), Math.sin(angle) * r));
puff.scale.setScalar(8 + t * 16);
(puff.material as SpriteMaterial).opacity = 0.7 * fight.shake;
});
}
/** Rog's punching fist, and the light that follows him. */
private updateRogBits(fight: BossFight): void {
const { rog } = fight;
this.rogLight.position.copy(at(rog.x, rog.y - 40, 25));
this.fist.visible = rog.punch > 0;
if (rog.punch > 0) {
const out = Math.sin(Math.PI * (1 - rog.punch / PUNCH_TIME));
this.fist.position.copy(at(rog.x + rog.facing * (8 + 16 * out), rog.y - 29, 0));
}
}
dispose(): void {
this.group.traverse((object) => {
if (object instanceof Mesh || object instanceof Sprite || object instanceof InstancedMesh) {
object.geometry.dispose();
const materials: Material[] = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) material.dispose();
}
});
}
}
