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.
/**
* The boss fight at the end of Level 10: Rog against a giant hairy spider in a cave.
* This file is only the rules; boss-scene.ts draws it.
*
* Rog has 50 HP and punches (Space or Shift) for 1 damage, but only while jumping: the
* spider's body is up high. The spider has 25 HP and every
* 3/4 of a second, with no warning, picks an attack at random: stomp a hairy leg down
* where Rog is standing (2 damage), spray a stream of green goo at him (5 damage), or
* launch a little spider (5 HP, hops on Rog for 1 damage). While a little spider is
* alive the big one just waits. It walks on its eight legs, stepping like a real spider.
*/
import type { Controls } from './game.js';
export const ARENA_W = 480;
export const ARENA_H = 270;
/** Where the cave floor is. */
export const FLOOR = 238;
const WALL_LEFT = 18;
const WALL_RIGHT = ARENA_W - 18;
export const ROG_MAX_HP = 50;
export const SPIDER_MAX_HP = 25;
const RUN_SPEED = 190;
const RUN_ACCEL = 1500;
const GRAVITY = 1700;
const JUMP_SPEED = 560;
/** How long a punch shows, and how soon Rog can punch again. */
export const PUNCH_TIME = 0.22;
const PUNCH_COOLDOWN = 0.3;
/** Rog can punch the spider when he's this close to the middle of its body, facing it... */
const PUNCH_REACH = 78;
/** ...and jumping: his feet have to be at least this far off the floor to reach its body. */
const PUNCH_HEIGHT = 25;
/** After getting hurt, Rog flashes and can't be hurt again for a moment. */
const HURT_TIME = 0.9;
const ATTACK_EVERY = 0.75;
/** A stomp is quick: the leg flicks up, slams down and goes back, all in under half a second. */
export const STOMP_WINDUP = 0.18;
export const STOMP_SLAM = 0.07;
const STOMP_RECOVER = 0.15;
/** A stomp hurts Rog if he's this close to where it lands, and not jumping high enough. */
export const STOMP_RADIUS = 26;
const STOMP_DAMAGE = 2;
const SPIDER_WALK = 45;
/** The goo comes out as a stream: lots of little globs, one every GOO_GAP seconds, for GOO_STREAM seconds. */
export const GOO_STREAM = 0.4;
/** The one warning the spider gives: before the goo, it rears back with a glowing green mouth for this long. */
export const GOO_WINDUP = 0.6;
const GOO_GAP = 0.04;
/** How long each glob takes to reach where Rog is. Fast, but you can still run out of the way. */
const GOO_FLIGHT = 0.45;
const GOO_GRAVITY = 600;
export const GOO_RADIUS = 4.5;
const GOO_DAMAGE = 5;
/** Puddles of goo on the floor fade away after this long. */
export const SPLAT_TIME = 2.5;
export interface Goo {
x: number;
y: number;
vx: number;
vy: number;
age: number;
/** Which stream it came from: a whole stream can only hurt Rog once. */
stream: number;
}
export interface Splat {
x: number;
y: number;
age: number;
/** True if it splattered on Rog rather than the floor. */
onRog: boolean;
}
/** Launching a little spider happens straight away (no warning). */
export const LAUNCH_WINDUP = 0.08;
export const LITTLE_MAX_HP = 5;
const LITTLE_FLIGHT = 0.8;
const LITTLE_GRAVITY = 900;
const LITTLE_WALK = 75;
/** A little spider crouches this long before it hops onto Rog. */
export const LITTLE_CROUCH = 0.35;
const LITTLE_HOP = 320;
const LITTLE_REST = 0.6;
const LITTLE_DAMAGE = 1;
const LITTLE_PUNCH_REACH = 34;
/** How long a beaten little spider takes to go poof. */
export const LITTLE_DEATH = 0.6;
export interface LittleSpider {
x: number;
y: number;
vx: number;
vy: number;
hp: number;
facing: number;
state: 'flying' | 'walk' | 'crouch' | 'hop' | 'rest' | 'dead';
stateTime: number;
hurt: number;
walk: number;
}
/** Where each of the eight feet rests, side to side from the middle of the spider. */
export const LEG_SPREAD = [-170, -125, -82, -42, 42, 82, 125, 170];
/** Legs step in two groups that take turns, so the spider always has four feet down. */
const LEG_GROUP = [0, 1, 0, 1, 1, 0, 1, 0];
/** A foot steps when it's this far from where it should be, and takes this long to step. */
const STEP_TRIGGER = 12;
export const STEP_TIME = 0.16;
export interface Step {
from: number;
to: number;
/** How far through the step, 0 to 1. */
t: number;
}
/** Where little spiders come out: the tip of the big spider's back end. */
export function spiderSpinneret(spider: { x: number; facing: number }): { x: number; y: number } {
return { x: spider.x - spider.facing * 100, y: 158 };
}
/** Where the goo comes out: the spider's mouth, between its fangs. */
export function spiderMouth(spider: { x: number; facing: number }): { x: number; y: number } {
return { x: spider.x + spider.facing * 62, y: 196 };
}
/** How long the "you won" / "you lost" message shows before moving on. */
const END_TIME = 3;
export interface Pop {
text: string;
x: number;
y: number;
age: number;
color: string;
}
export class BossFight {
rog = { x: 80, y: FLOOR, vx: 0, vy: 0, facing: 1, onGround: true, hp: ROG_MAX_HP, hurt: 0, punch: 0, cooldown: 0, cycle: 0 };
spider = {
x: 340,
hp: SPIDER_MAX_HP,
/** Which way the spider faces (towards Rog). */
facing: -1,
/** How far it has walked, for moving its legs. */
walk: 0,
hurt: 0,
nextAttack: ATTACK_EVERY,
state: 'idle' as 'idle' | 'windup' | 'slam' | 'recover' | 'gooWindup' | 'goo' | 'launch' | 'waiting',
stateTime: 0,
/** Where the stomp will land, and which leg does it. */
target: 0,
stomper: -1,
/** Time until the next glob in a goo stream, and which stream this is. */
gooGap: 0,
gooStream: 0,
deadTime: 0,
/** Where each foot is planted on the floor, and any step it's in the middle of. */
feet: LEG_SPREAD.map((spread) => 340 + spread),
steps: LEG_SPREAD.map((): Step | null => null),
};
pops: Pop[] = [];
goo: Goo[] = [];
splats: Splat[] = [];
/** The little spider the big one launched, while it's around. */
little: LittleSpider | null = null;
/** Goo streams that have already hurt Rog (each one only counts once). */
private streamsThatHit = new Set<number>();
/** Shakes the screen after a big stomp. */
shake = 0;
clock = 0;
result: 'fighting' | 'won' | 'lost' = 'fighting';
/** Seconds since the fight was won or lost. */
endTime = 0;
private punchWasDown = false;
private jumpWasDown = false;
/** True once the win or lose message has been shown long enough. */
get finished(): boolean {
return this.result !== 'fighting' && this.endTime >= END_TIME;
}
update(dt: number, controls: Controls): void {
this.clock += dt;
this.shake = Math.max(0, this.shake - dt * 3);
this.pops = this.pops.filter((pop) => (pop.age += dt) < 0.9);
this.splats = this.splats.filter((splat) => (splat.age += dt) < SPLAT_TIME);
this.updateGoo(dt);
this.updateLittle(dt);
if (this.result !== 'fighting') {
this.endTime += dt;
if (this.result === 'won') this.spider.deadTime += dt;
// Rog can still walk about while the message shows.
this.moveRog(dt, this.result === 'won' ? controls : { left: false, right: false, jump: false, punch: false });
return;
}
this.moveRog(dt, controls);
this.updateSpider(dt);
if (this.spider.hp <= 0) {
this.result = 'won';
this.spider.state = 'idle';
// Any little spider still around gives up too.
if (this.little && this.little.state !== 'dead') {
this.little.state = 'dead';
this.little.stateTime = 0;
}
} else if (this.rog.hp <= 0) {
this.result = 'lost';
}
}
private moveRog(dt: number, controls: Controls): void {
const rog = this.rog;
rog.hurt = Math.max(0, rog.hurt - dt);
rog.punch = Math.max(0, rog.punch - dt);
rog.cooldown = Math.max(0, rog.cooldown - dt);
const want = (controls.right ? 1 : 0) - (controls.left ? 1 : 0);
const target = this.result === 'lost' ? 0 : want * RUN_SPEED;
if (rog.vx < target) rog.vx = Math.min(target, rog.vx + RUN_ACCEL * dt);
else rog.vx = Math.max(target, rog.vx - RUN_ACCEL * dt);
if (want !== 0 && rog.punch === 0) rog.facing = want;
if (controls.jump && !this.jumpWasDown && rog.onGround) {
rog.vy = -JUMP_SPEED;
rog.onGround = false;
}
this.jumpWasDown = controls.jump;
rog.vy += GRAVITY * dt;
rog.x = Math.max(WALL_LEFT + 8, Math.min(WALL_RIGHT - 8, rog.x + rog.vx * dt));
rog.y += rog.vy * dt;
if (rog.y >= FLOOR) {
rog.y = FLOOR;
rog.vy = 0;
rog.onGround = true;
}
if (rog.onGround && Math.abs(rog.vx) > 10) rog.cycle += dt * Math.abs(rog.vx) * 0.055;
// Punch!
if (controls.punch && !this.punchWasDown && rog.cooldown === 0) {
rog.punch = PUNCH_TIME;
rog.cooldown = PUNCH_COOLDOWN;
this.tryPunch();
}
this.punchWasDown = controls.punch;
}
private tryPunch(): void {
const { rog, spider, little } = this;
if (this.result !== 'fighting') return;
// A little spider in reach gets punched first.
if (little && little.state !== 'dead' && little.state !== 'flying') {
const ldx = little.x - rog.x;
if (Math.abs(ldx) < LITTLE_PUNCH_REACH && (Math.sign(ldx) === rog.facing || Math.abs(ldx) < 10)) {
little.hp = Math.max(0, little.hp - 1);
little.hurt = 0.2;
little.x += rog.facing * 10;
this.pops.push({ text: '-1', x: little.x, y: little.y - 24, age: 0, color: '#ffcc33' });
this.pops.push({ text: 'POW!', x: rog.x + rog.facing * 26, y: rog.y - 26, age: 0, color: '#ffffff' });
if (little.hp === 0) {
little.state = 'dead';
little.stateTime = 0;
}
return;
}
}
const dx = spider.x - rog.x;
// Close enough, and facing the spider (or right underneath it).
if (Math.abs(dx) < PUNCH_REACH && (Math.sign(dx) === rog.facing || Math.abs(dx) < 20)) {
if (little) {
// While a little spider is out, punches don't hurt the big one.
this.pops.push({ text: 'Not yet!', x: rog.x + rog.facing * 26, y: rog.y - 44, age: 0, color: '#c9c3cf' });
return;
}
if (rog.y > FLOOR - PUNCH_HEIGHT) {
// Its body is too high to reach from the floor.
this.pops.push({ text: 'Jump!', x: rog.x + rog.facing * 26, y: rog.y - 44, age: 0, color: '#c9c3cf' });
return;
}
spider.hp = Math.max(0, spider.hp - 1);
spider.hurt = 0.2;
this.pops.push({ text: '-1', x: rog.x + rog.facing * 22, y: rog.y - 40, age: 0, color: '#ffcc33' });
this.pops.push({ text: 'POW!', x: rog.x + rog.facing * 30, y: rog.y - 26, age: 0, color: '#ffffff' });
}
}
private updateSpider(dt: number): void {
const { rog, spider } = this;
spider.hurt = Math.max(0, spider.hurt - dt);
spider.stateTime += dt;
// A new attack every 3/4 of a second, counting all the time.
if (spider.state !== 'waiting') spider.nextAttack -= dt;
// It turns to face Rog and walks closer if he's far away, even in the middle of spraying goo.
if (spider.state === 'idle' || spider.state === 'gooWindup' || spider.state === 'goo' || spider.state === 'recover') {
const dx = rog.x - spider.x;
spider.facing = dx < 0 ? -1 : 1;
if (Math.abs(dx) > 120) {
const step = Math.sign(dx) * SPIDER_WALK * dt;
spider.x = Math.max(150, Math.min(ARENA_W - 150, spider.x + step));
spider.walk += Math.abs(step);
}
}
if (spider.state === 'idle') {
if (spider.nextAttack <= 0) {
spider.nextAttack = Math.max(0, spider.nextAttack) + ATTACK_EVERY;
spider.stateTime = 0;
const pick = Math.random();
// 50% stomp, 38% goo, 12% little spider.
if (pick < 0.5) {
// Stomp the leg nearest to Rog down where he is right now.
spider.state = 'windup';
spider.target = rog.x;
let best = Infinity;
LEG_SPREAD.forEach((spread, k) => {
const d = Math.abs(spider.x + spread - rog.x);
if (d < best) {
best = d;
spider.stomper = k;
}
});
spider.steps[spider.stomper] = null;
} else if (pick < 0.88) {
// A stream of goo, with a warning first.
spider.state = 'gooWindup';
} else {
// Fling a little spider out.
spider.state = 'launch';
}
}
} else if (spider.state === 'windup') {
if (spider.stateTime >= STOMP_WINDUP) {
spider.state = 'slam';
spider.stateTime = 0;
}
} else if (spider.state === 'slam') {
if (spider.stateTime >= STOMP_SLAM) {
// BOOM. Did it land on Rog?
this.shake = 1;
const onHim = Math.abs(rog.x - spider.target) < STOMP_RADIUS && rog.y > FLOOR - 30;
if (onHim && rog.hurt === 0) {
rog.hp = Math.max(0, rog.hp - STOMP_DAMAGE);
rog.hurt = HURT_TIME;
rog.vx = Math.sign(rog.x - spider.target || 1) * 260;
this.pops.push({ text: `-${STOMP_DAMAGE}`, x: rog.x, y: rog.y - 46, age: 0, color: '#ff5a5a' });
}
spider.state = 'recover';
spider.stateTime = 0;
}
} else if (spider.state === 'recover') {
if (spider.stateTime >= STOMP_RECOVER) {
// The stomping foot steps back to where it belongs.
const k = spider.stomper;
spider.feet[k] = spider.target;
spider.steps[k] = { from: spider.target, to: spider.x + LEG_SPREAD[k], t: 0 };
spider.stomper = -1;
spider.state = 'idle';
spider.stateTime = 0;
}
} else if (spider.state === 'gooWindup') {
if (spider.stateTime >= GOO_WINDUP) {
spider.state = 'goo';
spider.stateTime = 0;
spider.gooGap = 0;
spider.gooStream += 1;
}
} else if (spider.state === 'goo') {
// Spray: a glob every few hundredths of a second, each aimed at Rog with a little wobble.
spider.gooGap -= dt;
while (spider.gooGap <= 0 && spider.stateTime < GOO_STREAM) {
spider.gooGap += GOO_GAP;
const mouth = spiderMouth(spider);
const tx = rog.x + (Math.random() - 0.5) * 10;
const ty = rog.y - 18 + (Math.random() - 0.5) * 10;
this.goo.push({
x: mouth.x,
y: mouth.y,
vx: (tx - mouth.x) / GOO_FLIGHT,
vy: (ty - mouth.y - 0.5 * GOO_GRAVITY * GOO_FLIGHT * GOO_FLIGHT) / GOO_FLIGHT,
age: 0,
stream: spider.gooStream,
});
}
if (spider.stateTime >= GOO_STREAM) {
spider.state = 'idle';
spider.stateTime = 0;
}
} else if (spider.state === 'launch') {
if (spider.stateTime >= LAUNCH_WINDUP) {
// Fling a little spider out on a silk thread, to land between the big spider and Rog.
const from = spiderSpinneret(spider);
const side = rog.x < spider.x ? 1 : -1;
const landX = Math.max(30, Math.min(ARENA_W - 30, rog.x + side * 70));
this.little = {
x: from.x,
y: from.y,
vx: (landX - from.x) / LITTLE_FLIGHT,
vy: (FLOOR - from.y - 0.5 * LITTLE_GRAVITY * LITTLE_FLIGHT * LITTLE_FLIGHT) / LITTLE_FLIGHT,
hp: LITTLE_MAX_HP,
facing: -side,
state: 'flying',
stateTime: 0,
hurt: 0,
walk: 0,
};
spider.state = 'waiting';
spider.stateTime = 0;
}
} else if (spider.state === 'waiting') {
// Does nothing at all until the little spider is beaten.
if (!this.little) {
spider.state = 'idle';
spider.stateTime = 0;
spider.nextAttack = ATTACK_EVERY;
}
}
this.updateLegs(dt);
}
/**
* Walking: each foot stays planted on the floor until the body has moved too far from it,
* then lifts and steps to its new spot. The two groups of four legs take turns.
*/
private updateLegs(dt: number): void {
const { spider } = this;
LEG_SPREAD.forEach((spread, k) => {
if (k === spider.stomper) return;
const step = spider.steps[k];
if (step) {
step.t += dt / STEP_TIME;
if (step.t >= 1) {
spider.feet[k] = step.to;
spider.steps[k] = null;
}
return;
}
const rest = spider.x + spread;
const off = rest - spider.feet[k];
const otherGroupStepping = spider.steps.some((other, j) => other && LEG_GROUP[j] !== LEG_GROUP[k]);
if (Math.abs(off) > STEP_TRIGGER && !otherGroupStepping) {
// Step a little past the resting spot, in the way it's walking.
spider.steps[k] = { from: spider.feet[k], to: rest + Math.sign(off) * 8, t: 0 };
}
});
}
/** The little spider: scuttles up to Rog, crouches, and hops on him (1 damage). */
private updateLittle(dt: number): void {
const little = this.little;
if (!little) return;
const { rog } = this;
little.stateTime += dt;
little.hurt = Math.max(0, little.hurt - dt);
const dx = rog.x - little.x;
if (little.state === 'dead') {
if (little.stateTime >= LITTLE_DEATH) this.little = null;
return;
}
if (this.result !== 'fighting') return;
if (little.state === 'flying' || little.state === 'hop') {
little.vy += (little.state === 'flying' ? LITTLE_GRAVITY : LITTLE_GRAVITY * 1.4) * dt;
little.x = Math.max(24, Math.min(ARENA_W - 24, little.x + little.vx * dt));
little.y += little.vy * dt;
if (little.y >= FLOOR) {
little.y = FLOOR;
// Landed on Rog?
if (little.state === 'hop' && Math.abs(rog.x - little.x) < 16 && rog.y > FLOOR - 20 && rog.hurt === 0) {
rog.hp = Math.max(0, rog.hp - LITTLE_DAMAGE);
rog.hurt = 0.6;
this.pops.push({ text: `-${LITTLE_DAMAGE}`, x: rog.x, y: rog.y - 46, age: 0, color: '#ff5a5a' });
}
little.state = little.state === 'hop' ? 'rest' : 'walk';
little.stateTime = 0;
little.vx = 0;
}
} else if (little.state === 'walk') {
little.facing = dx < 0 ? -1 : 1;
if (Math.abs(dx) > 22) {
const step = Math.sign(dx) * LITTLE_WALK * dt;
little.x += step;
little.walk += Math.abs(step);
} else {
little.state = 'crouch';
little.stateTime = 0;
}
} else if (little.state === 'crouch') {
if (little.stateTime >= LITTLE_CROUCH) {
// Hop to where Rog is right now.
const airtime = (2 * LITTLE_HOP) / (LITTLE_GRAVITY * 1.4);
little.vy = -LITTLE_HOP;
little.vx = (rog.x - little.x) / airtime;
little.state = 'hop';
little.stateTime = 0;
}
} else if (little.state === 'rest') {
if (little.stateTime >= LITTLE_REST) {
little.state = 'walk';
little.stateTime = 0;
}
}
}
/** Goo flies in an arc; it splats on Rog (5 damage) or on the floor. */
private updateGoo(dt: number): void {
const { rog } = this;
this.goo = this.goo.filter((glob) => {
glob.age += dt;
glob.vy += GOO_GRAVITY * dt;
glob.x += glob.vx * dt;
glob.y += glob.vy * dt;
const nearX = Math.max(rog.x - 7, Math.min(glob.x, rog.x + 7));
const nearY = Math.max(rog.y - 36, Math.min(glob.y, rog.y));
if (this.result === 'fighting' && Math.hypot(glob.x - nearX, glob.y - nearY) < GOO_RADIUS) {
this.splats.push({ x: glob.x, y: glob.y, age: 0, onRog: true });
if (!this.streamsThatHit.has(glob.stream)) {
this.streamsThatHit.add(glob.stream);
rog.hp = Math.max(0, rog.hp - GOO_DAMAGE);
rog.hurt = HURT_TIME;
rog.vx = Math.sign(glob.vx || 1) * 200;
this.pops.push({ text: `-${GOO_DAMAGE}`, x: rog.x, y: rog.y - 46, age: 0, color: '#7dff6a' });
}
return false;
}
if (glob.y >= FLOOR) {
this.splats.push({ x: glob.x, y: FLOOR, age: 0, onRog: false });
return false;
}
return glob.x > 0 && glob.x < ARENA_W;
});
}
}
