Back to Stick Runner
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.

src/game/boss.ts
/**
 * 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;
    });
  }
}