SOURCE / PINNED RELEASE
Made of little things.
Stick Brawl
- Release
- 732bb70ea856…
- Author-recorded commit
- 918970bb749a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-554b329db93
Archive hash verified: 67f16c1148706bbd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
blendPose,
drawFigure,
blockPose,
dropKickPose,
figureJoints,
holsterPose,
shootPose,
slidePose,
swordPose,
hurtPose,
idlePose,
jumpPose,
kickPose,
knockedOutPose,
punchPose,
runPose,
tuckPose,
type Point,
type Pose,
} from './figure.js';
import { drawHolster, drawRevolverInHand, drawSwordInHand, drawSwordOnBack, muzzlePoint, swordTip } from './gear.js';
import {
GROUND_Y,
ROOF_Y,
ROOM_W,
WORLD_LEFT,
WORLD_RIGHT,
buildBuilding,
drawBuilding,
drawSky,
firstStep,
resetDoors,
surfaceAt,
type Building,
type Solid,
type SpawnPoint,
type Stairs,
type Surface,
} from './building.js';
/** The camera shows 480×270 units of the building; it is scaled to fit the window. */
export const VIEW_W = 480;
export const VIEW_H = 270;
/** Half the width of a stickman's body, and his height, for bumping into things. */
const BODY_HALF = 5;
const BODY_H = 36;
/** How fast stickmen climb the stairs. */
const CLIMB_SPEED = 150;
/** How close to the end of the stairs you need to be to use them. */
const STAIRS_REACH = 14;
const GRAVITY = 1500;
const JUMP_SPEED = 500;
const PLAYER_SPEED = 150;
const ROLL_SPEED = 250;
const ROLL_TIME = 0.42;
/** Hold ↓ longer than this to slide instead of rolling. */
const SLIDE_HOLD = 0.14;
/** A slide starts a bit faster than you were running, then slows down quickly. */
const SLIDE_BOOST = 1.35;
const SLIDE_SLOWDOWN = 110;
const SLIDE_DRAG = 1.4;
/** Sliding keeps you so low that punches go over your head. */
const SLIDE_HEIGHT = 16;
const PLAYER_HP = 100;
const ENEMY_HP = 2;
const PLAYER_COLOR = '#3ddc5a';
const SPAWN_GAP = 2.6;
const DEFEAT_PAUSE = 3;
const STEP = 1 / 120;
const MAX_STAINS = 500;
type Action = 'none' | 'punch' | 'kick' | 'dropkick' | 'roll' | 'shoot' | 'sword' | 'climb' | 'hurt' | 'dead';
/** How each attack works. Heights are measured up from the feet. */
interface AttackKind {
length: number;
activeFrom: number;
activeTo: number;
reach: number;
top: number;
bottom: number;
knockback: number;
/** How many hits this counts as (1 if not given). */
damage?: number;
}
const PLAYER_PUNCH: AttackKind = { length: 0.28, activeFrom: 0.07, activeTo: 0.16, reach: 26, top: 36, bottom: 18, knockback: 140 };
const PLAYER_KICK: AttackKind = { length: 0.42, activeFrom: 0.1, activeTo: 0.26, reach: 32, top: 30, bottom: 4, knockback: 950 };
// A drop-kick: flying feet-first through the air. It hits everything in the way until you land.
const DROP_KICK: AttackKind = { length: 3, activeFrom: 0.04, activeTo: 3, reach: 26, top: 22, bottom: -6, knockback: 1300 };
const DROP_KICK_SPEED = 300;
// Enemies pull their fist back for a moment first, so you can see it coming.
// Brutes are big and swing much faster, with a longer reach.
const BRUTE_PUNCH: AttackKind = { length: 0.3, activeFrom: 0.14, activeTo: 0.22, reach: 30, top: 48, bottom: 22, knockback: 170 };
// A revolver bullet counts as one hit.
const GUNSHOT: AttackKind = { length: 0, activeFrom: 0, activeTo: 0, reach: 0, top: 0, bottom: 0, knockback: 120 };
const BRUTE_HP = 3;
const BRUTE_SIZE = 1.35;
const BRUTE_CHANCE = 0.25;
// Runts are small, go down after 1½ hits, punch for ½ a hit, and keep coming every 1.5 seconds.
const RUNT_SIZE = 0.7;
const RUNT_HP = 1.5;
const RUNT_SPAWN_GAP = 1.5;
// Every 2 minutes a big gang of 10 normal stickmen turns up together.
const GANG_SIZE = 10;
const GANG_SPAWN_GAP = 120;
const RUNT_PUNCH: AttackKind = { length: 0.45, activeFrom: 0.24, activeTo: 0.34, reach: 19, top: 26, bottom: 12, knockback: 60, damage: 0.5 };
// The revolver: hand to the hip, pull it out and aim, fire, then put it back.
const SHOT_TIME = 0.42;
const SHOT_FIRE_AT = 0.12;
const SHOT_PUT_AWAY_AT = 0.3;
// The sword: grab it off the back, raise it, slash, then put it back.
const SWORD: AttackKind = { length: 0.55, activeFrom: 0.2, activeTo: 0.34, reach: 40, top: 54, bottom: 0, knockback: 260, damage: 1.5 };
const BULLET_SPEED = 1100;
/** The revolver can fire anywhere in this spread (degrees) in front of the player. */
const SHOT_SPREAD = 25;
const ENEMY_PUNCH: AttackKind = { length: 0.5, activeFrom: 0.28, activeTo: 0.38, reach: 24, top: 36, bottom: 18, knockback: 90 };
class Fighter {
vx = 0;
vy = 0;
onGround = true;
facing = 1;
hp: number;
action: Action = 'none';
attack: AttackKind | null = null;
actionTime = 0;
actionLength = 0;
/** Who this attack has already hit, so one swing only hits each stickman once. */
hitAlready = new Set<Fighter>();
pose: Pose = idlePose(0);
runPhase = 0;
flash = 0;
deadTime = 0;
/** On the stairs: which flight, which way, and how far along (0 = bottom, 1 = top). */
climb: { stairs: Stairs; up: boolean; along: number } | null = null;
/** Knocked out on the stairs: stays put instead of falling through the floor. */
stuck = false;
/** Enemies only: */
cooldown = 0.8;
/** Player only: how fast the current attack plays (mashing Space speeds it up). */
attackSpeed = 1;
blocking = false;
/** Player only: sliding along the floor (this is separate from actions, so he can attack while sliding). */
slide: { dir: number; speed: number } | null = null;
speed = 100;
range = 20;
/** Brutes are drawn bigger and have bigger hit boxes. */
size = 1;
punch: AttackKind = ENEMY_PUNCH;
/** Player only: has the revolver fired yet during this draw? */
fired = false;
/** Player only: recent sword tip positions, for the swoosh. */
trail: Point[] = [];
constructor(
public x: number,
public y: number,
public color: string,
public isPlayer: boolean,
) {
this.hp = isPlayer ? PLAYER_HP : ENEMY_HP;
}
start(action: Action, length: number, attack: AttackKind | null = null): void {
this.action = action;
this.actionTime = 0;
this.actionLength = length;
this.attack = attack;
this.attackSpeed = 1;
this.hitAlready.clear();
}
get alive(): boolean {
return this.action !== 'dead';
}
}
interface Drop {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
/** Some drops splat onto the back wall in mid-air. */
wallAt: number | null;
age: number;
}
interface Bullet {
x: number;
y: number;
vx: number;
vy: number;
life: number;
}
interface Popup {
x: number;
y: number;
text: string;
life: number;
}
interface Spark {
x: number;
y: number;
vx: number;
vy: number;
life: number;
/** Dust kicked up by sliding, instead of a bright spark. */
dust?: boolean;
}
interface Splinter {
x: number;
y: number;
vx: number;
vy: number;
angle: number;
spin: number;
life: number;
}
interface Stain {
x: number;
y: number;
rx: number;
ry: number;
color: string;
}
export interface HeldKeys {
left: boolean;
right: boolean;
down: boolean;
block: boolean;
}
export class Game {
held: HeldKeys = { left: false, right: false, down: false, block: false };
paused = true;
private ctx: CanvasRenderingContext2D;
private scale = 1;
private building: Building = buildBuilding();
private camera = { x: 0, y: 0 };
private lastPlayerSurface: Surface | null = null;
/** When ↓ was pressed, while we wait to see if it's a tap (roll) or a hold (slide). */
private downPressedAt: number | null = null;
/** How fast (and which way) the player was going when ↓ was pressed. */
private downPressedVx = 0;
private player!: Fighter;
private enemies: Fighter[] = [];
private drops: Drop[] = [];
private stains: Stain[] = [];
private sparks: Spark[] = [];
private bullets: Bullet[] = [];
private popups: Popup[] = [];
private splinters: Splinter[] = [];
private presses: ('jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword')[] = [];
private lastAttackPress = -10;
private spawnTimer = 1;
private runtTimer = 2;
private gangTimer = GANG_SPAWN_GAP;
private knockouts = 0;
private headshots = 0;
private defeatTimer = 0;
private time = 0;
private lastFrame = 0;
private leftover = 0;
constructor(private canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is not available');
this.ctx = ctx;
this.restart();
}
/** Start a fresh fight. */
restart(): void {
this.player = new Fighter(ROOM_W / 2, GROUND_Y, PLAYER_COLOR, true);
resetDoors(this.building);
this.lastPlayerSurface = null;
this.downPressedAt = null;
this.snapCamera();
this.enemies = [];
this.drops = [];
this.stains = [];
this.sparks = [];
this.bullets = [];
this.popups = [];
this.splinters = [];
this.presses = [];
this.knockouts = 0;
this.headshots = 0;
this.spawnTimer = 1;
this.runtTimer = 2;
this.gangTimer = GANG_SPAWN_GAP;
this.defeatTimer = 0;
}
/** A key was pressed this moment (not held). */
press(what: 'jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword'): void {
if (!this.paused) this.presses.push(what);
}
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;
this.draw();
}
start(): void {
const frame = (now: number) => {
const dt = this.lastFrame ? Math.min((now - this.lastFrame) / 1000, 0.1) : 0;
this.lastFrame = now;
if (!this.paused) {
this.leftover += dt;
while (this.leftover >= STEP) {
this.update(STEP);
this.leftover -= STEP;
}
}
this.draw();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
// ---------- game rules ----------
private update(dt: number): void {
this.time += dt;
if (this.defeatTimer > 0) {
this.defeatTimer -= dt;
if (this.defeatTimer <= 0) this.restart();
} else {
this.controlPlayer();
this.spawnEnemies(dt);
}
this.presses = [];
for (const enemy of this.enemies) this.thinkEnemy(enemy, dt);
for (const fighter of [this.player, ...this.enemies]) this.moveFighter(fighter, dt);
this.enemies = this.enemies.filter((enemy) => !(enemy.action === 'dead' && enemy.deadTime > 2.5));
this.updateBullets(dt);
this.updateDrops(dt);
}
private controlPlayer(): void {
const p = this.player;
if (!p.alive) return;
for (const what of this.presses) {
// Once an attack has landed, another press can start the next one straight away.
const canChain =
p.action === 'none' ||
((p.action === 'punch' || p.action === 'kick') && p.attack !== null && p.actionTime > p.attack.activeTo);
if (what === 'attack') {
// The faster you press, the faster you punch (up to a bit over twice as fast).
const gap = this.time - this.lastAttackPress;
const speed = Math.max(1, Math.min(2.2, 0.3 / gap));
if (canChain) {
p.start('punch', PLAYER_PUNCH.length, PLAYER_PUNCH);
p.attackSpeed = speed;
}
this.lastAttackPress = this.time;
} else if (what === 'kick') {
if (!p.onGround && (p.action === 'none' || p.action === 'punch')) {
// Kicking in the air is a drop-kick.
p.start('dropkick', DROP_KICK.length, DROP_KICK);
p.vy = Math.max(p.vy, -120);
} else if (canChain) {
p.start('kick', PLAYER_KICK.length, PLAYER_KICK);
}
} else if (what === 'jump') {
// At the bottom of the stairs, ↑ climbs them. Anywhere else it jumps.
const stairs = this.stairsHere(p, 'bottom');
if (stairs && p.action === 'none') this.startClimb(p, stairs, true);
else if (p.onGround && p.action === 'none') {
p.vy = -JUMP_SPEED;
p.onGround = false;
}
} else if (what === 'shoot') {
// Quick draw: one shot per press. If the gun is already out, aim and fire again straight away.
if (p.action === 'shoot' && p.fired) {
p.actionTime = SHOT_FIRE_AT - 0.04;
p.fired = false;
} else if (p.action !== 'roll' && p.action !== 'hurt' && p.action !== 'sword' && p.action !== 'shoot') {
p.start('shoot', SHOT_TIME);
p.fired = false;
}
} else if (what === 'sword') {
if (p.action !== 'roll' && p.action !== 'hurt' && p.action !== 'sword') {
p.start('sword', SWORD.length, SWORD);
p.trail = [];
}
} else if (what === 'roll') {
// At the top of the stairs, ↓ goes down them. Anywhere else it rolls.
const stairs = this.stairsHere(p, 'top');
if (stairs && p.action === 'none') this.startClimb(p, stairs, false);
else {
this.downPressedAt = this.time;
this.downPressedVx = p.vx;
}
}
}
this.rollOrSlide(p);
// Holding block: stand still with arms up. Punches from the front do nothing.
p.blocking = this.held.block && p.onGround && p.action === 'none' && !p.slide;
if (p.slide) {
// Sliding: the slide decides how fast you go, whatever else you're doing.
p.slide.speed = Math.max(0, p.slide.speed - (SLIDE_SLOWDOWN + p.slide.speed * SLIDE_DRAG) * STEP);
p.vx = p.slide.dir * p.slide.speed;
if (isAttack(p.action)) {
const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
if (want !== 0) p.facing = want;
}
if (Math.random() < 0.5) {
this.sparks.push({
x: p.x - p.slide.dir * 4,
y: p.y - 1,
vx: -p.slide.dir * (20 + Math.random() * 40),
vy: -10 - Math.random() * 40,
life: 0.2 + Math.random() * 0.2,
dust: true,
});
}
} else if (p.blocking) {
p.vx = approach(p.vx, 0, 1400 * STEP);
} else if (p.action === 'none') {
const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
const accel = p.onGround ? 1400 : 900;
p.vx = approach(p.vx, want * PLAYER_SPEED, accel * STEP);
if (want !== 0) p.facing = want;
} else if (isAttack(p.action)) {
// You can keep moving (and turn around) in the middle of an attack.
const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
const accel = p.onGround ? 1400 : 900;
p.vx = approach(p.vx, want * PLAYER_SPEED, accel * STEP);
if (want !== 0) p.facing = want;
}
}
private spawnEnemies(dt: number): void {
// There's no limit: new enemies come in every so often, however many are already here.
this.runtTimer -= dt;
if (this.runtTimer <= 0) {
this.runtTimer = RUNT_SPAWN_GAP;
this.spawnEnemy('runt');
}
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnTimer = SPAWN_GAP;
this.spawnEnemy(Math.random() < BRUTE_CHANCE ? 'brute' : 'normal');
}
this.gangTimer -= dt;
if (this.gangTimer <= 0) {
this.gangTimer = GANG_SPAWN_GAP;
// The whole gang arrives at the same place, bunched up together.
const spawn = this.randomSpawn();
for (let i = 0; i < GANG_SIZE; i++) this.spawnEnemy('normal', spawn, i * 2);
}
}
private randomSpawn(): SpawnPoint {
return this.building.spawns[Math.floor(Math.random() * this.building.spawns.length)];
}
private spawnEnemy(kind: 'normal' | 'brute' | 'runt', spawn = this.randomSpawn(), offset = 0): void {
// Enemies turn up on a balcony or at the end of the street, and have to break in.
const enemy = new Fighter(spawn.x + spawn.facing * offset, spawn.y, randomEnemyColor(), false);
enemy.facing = spawn.facing;
enemy.speed = 70 + Math.random() * 45;
enemy.range = 18 + Math.random() * 5;
if (kind === 'runt') {
enemy.size = RUNT_SIZE;
enemy.hp = RUNT_HP;
enemy.punch = RUNT_PUNCH;
enemy.range = 13 + Math.random() * 4;
} else if (kind === 'brute') {
enemy.size = BRUTE_SIZE;
enemy.hp = BRUTE_HP;
enemy.punch = BRUTE_PUNCH;
enemy.speed = 32 + Math.random() * 12;
enemy.range = 24 + Math.random() * 4;
}
enemy.cooldown = 0.1 + Math.random() * 0.25;
this.enemies.push(enemy);
}
private thinkEnemy(e: Fighter, dt: number): void {
if (!e.alive) {
e.deadTime += dt;
return;
}
e.cooldown -= dt;
if (e.action !== 'none') return;
const p = this.player;
if (!p.alive || this.defeatTimer > 0) {
if (e.onGround) e.vx = approach(e.vx, 0, 800 * dt);
return;
}
if (!e.onGround) {
// Keep going forward while jumping, so he clears whatever he jumped over.
e.vx = approach(e.vx, e.facing * e.speed, 600 * dt);
return;
}
// Work out where to head: straight for the player if he's on the same floor,
// otherwise along the quickest way there (through doors, along streets, up and down stairs).
const mySurface = surfaceAt(this.building, e.x, e.y);
const playerSurface = this.playerSurface;
let goalX = p.x;
let chasing = mySurface !== null && mySurface === playerSurface;
if (mySurface && playerSurface && !chasing) {
const step = firstStep(this.building, mySurface, playerSurface);
if (step && 'stairs' in step) {
goalX = step.up ? step.stairs.bottomX : step.stairs.topX;
if (Math.abs(e.x - goalX) < 6) {
this.startClimb(e, step.stairs, step.up);
return;
}
} else if (step) {
goalX = step.walkTo;
} else {
chasing = true;
}
}
const dx = goalX - e.x;
e.facing = dx >= 0 ? 1 : -1;
// A closed door in the way gets punched down.
const door = this.doorAhead(e);
if (door) {
e.vx = approach(e.vx, 0, 900 * dt);
if (e.cooldown <= 0) {
e.start('punch', e.punch.length, e.punch);
e.cooldown = e.punch.length + 0.2;
}
return;
}
// Hop over crates and anything else low in the way.
if (this.lowObstacleAhead(e)) {
e.vy = -JUMP_SPEED;
e.vx = e.facing * e.speed;
e.onGround = false;
return;
}
const distance = Math.abs(dx);
if (!chasing || distance > e.range) {
// Don't bunch up: wait if a friend is already in the way.
const blocked = this.enemies.some(
(other) =>
other !== e &&
other.alive &&
Math.sign(other.x - e.x) === e.facing &&
Math.abs(other.x - e.x) < 15 &&
Math.abs(p.x - other.x) < distance,
);
e.vx = approach(e.vx, blocked && chasing ? 0 : e.facing * e.speed, 900 * dt);
} else {
e.vx = approach(e.vx, 0, 900 * dt);
if (e.cooldown <= 0 && Math.abs(p.y - e.y) < 30) {
e.start('punch', e.punch.length, e.punch);
const rest = e.punch === BRUTE_PUNCH ? 0.05 + Math.random() * 0.15 : 0.15 + Math.random() * 0.45;
e.cooldown = e.punch.length + rest;
}
}
}
private moveFighter(f: Fighter, dt: number): void {
f.flash = Math.max(0, f.flash - dt * 5);
if (f.action === 'climb' && f.climb) {
this.moveOnStairs(f, dt);
this.animate(f, dt);
return;
}
if (f.stuck) {
this.animate(f, dt);
return;
}
if (f.action !== 'none' && f.action !== 'dead') {
f.actionTime += dt * (f.action === 'punch' || f.action === 'kick' ? f.attackSpeed : 1);
if (f.action === 'shoot' && !f.fired && f.actionTime >= SHOT_FIRE_AT) {
f.fired = true;
this.fire(f);
}
if (f.action === 'roll') f.vx = f.facing * ROLL_SPEED;
else if (f.action === 'dropkick') {
f.vx = f.facing * DROP_KICK_SPEED;
// Landing ends the drop-kick.
if (f.onGround) {
f.action = 'none';
f.vx *= 0.3;
}
}
else if (f.onGround && !(f.isPlayer && isAttack(f.action))) f.vx = approach(f.vx, 0, 700 * dt);
if (f.attack && f.actionTime >= f.attack.activeFrom && f.actionTime <= f.attack.activeTo) this.checkHits(f, f.attack);
if (f.actionTime >= f.actionLength) f.action = 'none';
}
if (f.action === 'dead' && f.onGround) f.vx = approach(f.vx, 0, 600 * dt);
f.vy = Math.min(f.vy + GRAVITY * dt, 900);
this.moveX(f, f.vx * dt);
this.moveY(f, f.vy * dt);
// Balcony railings keep everyone from falling off the building.
f.x = Math.max(WORLD_LEFT + 6, Math.min(WORLD_RIGHT - 6, f.x));
this.animate(f, dt);
}
private solidsFor(f: Fighter): Solid[] {
return this.building.solids.filter((solid) => !solid.broken && this.overlaps(f, solid));
}
private overlaps(f: Fighter, solid: Solid): boolean {
const half = BODY_HALF * f.size;
return f.x + half > solid.x && f.x - half < solid.x + solid.w && f.y > solid.y && f.y - BODY_H * f.size < solid.y + solid.h;
}
private moveX(f: Fighter, dx: number): void {
f.x += dx;
const half = BODY_HALF * f.size;
for (const solid of this.solidsFor(f)) {
if (dx > 0) f.x = solid.x - half;
else if (dx < 0) f.x = solid.x + solid.w + half;
if (f.action !== 'dead') f.vx = 0;
else f.vx = -f.vx * 0.3;
}
}
private moveY(f: Fighter, dy: number): void {
f.y += dy;
f.onGround = false;
for (const solid of this.solidsFor(f)) {
if (dy > 0) {
f.y = solid.y;
f.onGround = true;
} else if (dy < 0) {
f.y = solid.y + solid.h + BODY_H * f.size;
}
f.vy = 0;
}
}
/** Is this stickman standing at the bottom (or top) of a flight of stairs? */
private stairsHere(f: Fighter, end: 'bottom' | 'top'): Stairs | null {
if (!f.onGround) return null;
for (const stairs of this.building.stairs) {
const x = end === 'bottom' ? stairs.bottomX : stairs.topX;
const y = end === 'bottom' ? stairs.bottomY : stairs.topY;
if (Math.abs(f.x - x) < STAIRS_REACH && Math.abs(f.y - y) < 2) return stairs;
}
return null;
}
private startClimb(f: Fighter, stairs: Stairs, up: boolean): void {
f.start('climb', 99);
f.climb = { stairs, up, along: up ? 0 : 1 };
f.vx = 0;
f.vy = 0;
}
/** Walk up or down the stairs, passing through the floor above. */
private moveOnStairs(f: Fighter, dt: number): void {
const c = f.climb!;
const s = c.stairs;
const length = Math.hypot(s.topX - s.bottomX, s.topY - s.bottomY);
c.along += ((c.up ? 1 : -1) * CLIMB_SPEED * dt) / length;
const along = Math.max(0, Math.min(1, c.along));
f.x = s.bottomX + (s.topX - s.bottomX) * along;
f.y = s.bottomY + (s.topY - s.bottomY) * along;
f.facing = c.up ? s.dir : -s.dir;
f.vx = f.facing * CLIMB_SPEED * 0.75;
f.onGround = true;
if (c.along <= 0 || c.along >= 1) {
// Step off onto the floor, still close enough to turn round and use the stairs again.
f.action = 'none';
f.climb = null;
f.x += f.facing * 8;
f.vx = 0;
}
}
/** Tap ↓ to roll, hold it to slide. Also ends the slide when it's over. */
private rollOrSlide(p: Fighter): void {
if (this.downPressedAt !== null) {
const heldFor = this.time - this.downPressedAt;
if (!this.held.down) {
if (heldFor < SLIDE_HOLD && p.onGround && p.action === 'none') {
p.slide = null;
p.start('roll', ROLL_TIME);
}
this.downPressedAt = null;
} else if (heldFor >= SLIDE_HOLD) {
this.downPressedAt = null;
const busy = p.action === 'roll' || p.action === 'climb' || p.action === 'dropkick' || p.action === 'hurt';
if (p.onGround && !busy) {
const vx = Math.abs(p.vx) > Math.abs(this.downPressedVx) ? p.vx : this.downPressedVx;
p.slide = { dir: Math.sign(vx) || p.facing, speed: Math.abs(vx) * SLIDE_BOOST };
}
}
}
if (p.slide) {
const stopped = p.slide.speed < 12;
const interrupted = p.action === 'roll' || p.action === 'climb' || p.action === 'dropkick' || p.action === 'hurt' || p.action === 'dead';
if (stopped || interrupted || !this.held.down || !p.onGround) p.slide = null;
}
}
/** Something low and solid (like a crate) right in front of this stickman, that he could jump over. */
private lowObstacleAhead(f: Fighter): boolean {
const half = BODY_HALF * f.size;
const probeX = f.x + f.facing * (half + 4);
return this.building.solids.some(
(solid) =>
solid.kind === 'crate' &&
probeX > solid.x &&
probeX < solid.x + solid.w &&
solid.y < f.y &&
solid.y > f.y - 75,
);
}
/** The surface the player is on, or the one he's climbing towards. Remembered while he's in the air. */
private get playerSurface(): Surface | null {
const p = this.player;
if (p.climb) {
const s = p.climb.stairs;
this.lastPlayerSurface = p.climb.up ? surfaceAt(this.building, s.topX, s.topY) : surfaceAt(this.building, s.bottomX, s.bottomY);
} else {
this.lastPlayerSurface = surfaceAt(this.building, p.x, p.y) ?? this.lastPlayerSurface;
}
return this.lastPlayerSurface;
}
/** A closed door right in front of this stickman, on his floor. */
private doorAhead(f: Fighter): Solid | null {
for (const door of this.building.doors) {
if (door.broken || Math.abs(door.y + door.h - f.y) > 2) continue;
const ahead = (door.x + door.w / 2 - f.x) * f.facing;
if (ahead > 0 && ahead < BODY_HALF * f.size + 10) return door;
}
return null;
}
/** One hit smashes a door into splinters. */
private breakDoor(door: Solid, dir: number): void {
door.broken = true;
for (let i = 0; i < 18; i++) {
this.splinters.push({
x: door.x + door.w / 2,
y: door.y + Math.random() * door.h,
vx: dir * (40 + Math.random() * 180) + (Math.random() - 0.5) * 60,
vy: -60 - Math.random() * 160,
spin: (Math.random() - 0.5) * 20,
angle: Math.random() * Math.PI,
life: 1.2 + Math.random() * 0.6,
});
}
// Stains that were on the door go with it.
this.stains = this.stains.filter(
(stain) => !(stain.x > door.x - 2 && stain.x < door.x + door.w + 2 && stain.y > door.y && stain.y < door.y + door.h),
);
}
private checkHits(attacker: Fighter, attack: AttackKind): void {
const attackTop = attacker.y - attack.top;
const attackBottom = attacker.y - attack.bottom;
for (const door of this.building.doors) {
if (door.broken) continue;
const ahead = (door.x + door.w / 2 - attacker.x) * attacker.facing;
if (ahead < -4 || ahead > attack.reach + 4) continue;
if (attackBottom < door.y || attackTop > door.y + door.h) continue;
this.breakDoor(door, attacker.facing);
}
const targets = attacker.isPlayer ? this.enemies : [this.player];
for (const target of targets) {
if (!target.alive || target.action === 'roll' || attacker.hitAlready.has(target)) continue;
const ahead = (target.x - attacker.x) * attacker.facing;
if (ahead < -4 || ahead > attack.reach + 6 * target.size) continue;
const height = target.slide ? SLIDE_HEIGHT : 36 * target.size;
if (attackBottom < target.y - height || attackTop > target.y) continue;
attacker.hitAlready.add(target);
if (target.blocking && Math.sign(attacker.x - target.x) === target.facing) this.blocked(target, attacker);
else this.hurt(target, attacker, attack);
}
}
private hurt(target: Fighter, attacker: Fighter, attack: AttackKind): void {
const damage = attack.damage ?? 1;
target.hp -= damage;
target.flash = 1;
const dir = attacker.facing;
const knockedOut = target.hp <= 0;
this.splat(target, dir, (knockedOut ? 80 : 16) * target.size * damage, knockedOut ? 2.2 : 1);
// A big puddle where a stickman goes down.
if (knockedOut && target.onGround) this.addStain(target.x + dir * 6, target.y + 1, 16, 3, target.color);
if (!target.isPlayer) target.facing = -dir;
const onStairs = target.action === 'climb';
if (target.hp <= 0) {
target.start('dead', 0);
// Knocked out on the stairs: stay there instead of dropping through the floor.
target.stuck = onStairs;
target.climb = null;
target.deadTime = 0;
target.vx = (dir * attack.knockback * 0.8) / target.size;
target.vy = -200;
if (target.isPlayer) this.defeatTimer = DEFEAT_PAUSE;
else this.knockouts += 1;
return;
}
// Someone on the stairs keeps climbing; they just take the hit.
if (onStairs) return;
const kicked = attack === PLAYER_KICK || attack === DROP_KICK;
target.start('hurt', target.isPlayer ? 0.12 : kicked ? 0.6 : 0.25);
// Big brutes are heavier, so they don't fly quite as far.
target.vx = (dir * attack.knockback) / target.size;
target.vy = kicked ? -320 : -70;
}
/** Fire one revolver bullet from the hip, somewhere inside the spread. */
private fire(p: Fighter): void {
const angle = ((Math.random() - 0.5) * SHOT_SPREAD * Math.PI) / 180;
const muzzle = muzzlePoint(figureJoints(p.pose));
const x = p.x + p.facing * muzzle.x;
const y = p.y + muzzle.y;
this.bullets.push({ x, y, vx: Math.cos(angle) * BULLET_SPEED * p.facing, vy: Math.sin(angle) * BULLET_SPEED, life: 1 });
// Muzzle flash sparks.
for (let i = 0; i < 6; i++) {
this.sparks.push({
x: x + p.facing * 4,
y,
vx: p.facing * (60 + Math.random() * 120),
vy: (Math.random() - 0.5) * 120,
life: 0.08 + Math.random() * 0.08,
});
}
}
private updateBullets(dt: number): void {
for (const bullet of this.bullets) {
bullet.life -= dt;
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
// A bullet in the head knocks an enemy out straight away.
const headshot = this.enemies.find((e) => {
if (!e.alive) return false;
const head = figureJoints(e.pose).head;
const hx = e.x + e.facing * head.x * e.size;
const hy = e.y + head.y * e.size;
return Math.hypot(bullet.x - hx, bullet.y - hy) < 5.5 * e.size;
});
const target = this.enemies.find(
(e) => e.alive && Math.abs(bullet.x - e.x) < 6 * e.size && bullet.y > e.y - 30 * e.size && bullet.y < e.y,
);
if (headshot) {
this.hurt(headshot, this.player, { ...GUNSHOT, damage: headshot.hp, knockback: 220 });
this.popups.push({ x: headshot.x, y: headshot.y - 46 * headshot.size, text: 'HEADSHOT!', life: 1 });
this.headshots += 1;
bullet.life = 0;
} else if (target) {
this.hurt(target, this.player, GUNSHOT);
bullet.life = 0;
} else if (this.solidAt(bullet.x, bullet.y)) {
// Bullets smash doors, and leave holes in floors and walls.
const solid = this.solidAt(bullet.x, bullet.y)!;
const hx = bullet.x;
const hy = bullet.y;
if (solid.kind === 'door') this.breakDoor(solid, Math.sign(bullet.vx));
else this.addStain(hx, hy, 1.4, 1.4, '#0b0c14');
for (let i = 0; i < 4; i++) {
this.sparks.push({ x: hx, y: hy, vx: (Math.random() - 0.5) * 140, vy: -40 - Math.random() * 100, life: 0.15 + Math.random() * 0.1 });
}
bullet.life = 0;
}
}
this.bullets = this.bullets.filter((bullet) => bullet.life > 0);
for (const bit of this.splinters) {
bit.life -= dt;
bit.vy += 700 * dt;
bit.x += bit.vx * dt;
bit.y += bit.vy * dt;
bit.angle += bit.spin * dt;
const floor = this.solidAt(bit.x, bit.y);
if (floor && floor.kind !== 'door') {
bit.y = floor.y;
bit.vx *= 0.5;
bit.vy = 0;
bit.spin = 0;
}
}
this.splinters = this.splinters.filter((bit) => bit.life > 0);
for (const popup of this.popups) {
popup.life -= dt;
popup.y -= 18 * dt;
}
this.popups = this.popups.filter((popup) => popup.life > 0);
}
/** A punch hit the player's guard: a little shove and some sparks, but no damage. */
private blocked(target: Fighter, attacker: Fighter): void {
target.vx = attacker.facing * 60;
for (let i = 0; i < 8; i++) {
this.sparks.push({
x: target.x + target.facing * 8,
y: target.y - 30 + (Math.random() - 0.5) * 6,
vx: target.facing * (20 + Math.random() * 90) * -1 + (Math.random() - 0.5) * 60,
vy: -30 - Math.random() * 120,
life: 0.25 + Math.random() * 0.15,
});
}
}
/** Spray drops of the stickman's colour away from the hit. */
private splat(target: Fighter, dir: number, count: number, size = 1): void {
for (let i = 0; i < count; i++) {
this.drops.push({
x: target.x + (Math.random() - 0.5) * 6,
y: target.y - 22 - Math.random() * 10,
vx: (dir * (50 + Math.random() * 220) + (Math.random() - 0.5) * 80) * (size > 1 ? 1.4 : 1),
vy: (-40 - Math.random() * 240) * (size > 1 ? 1.3 : 1),
size: (1 + Math.random() * 1.8) * (size > 1 ? 0.7 + Math.random() * size * 0.6 : 1),
color: target.color,
wallAt: Math.random() < 0.3 ? 0.08 + Math.random() * 0.3 : null,
age: 0,
});
}
}
private updateDrops(dt: number): void {
for (const spark of this.sparks) {
spark.life -= dt;
spark.vy += 500 * dt;
spark.x += spark.vx * dt;
spark.y += spark.vy * dt;
}
this.sparks = this.sparks.filter((spark) => spark.life > 0);
const kept: Drop[] = [];
for (const drop of this.drops) {
drop.age += dt;
drop.vy += 900 * dt;
drop.x += drop.vx * dt;
drop.y += drop.vy * dt;
if (drop.wallAt !== null && drop.age >= drop.wallAt) {
this.addStain(drop.x, drop.y, drop.size * 1.3, drop.size * 1.3, drop.color);
} else if (this.solidAt(drop.x, drop.y)) {
const solid = this.solidAt(drop.x, drop.y)!;
const fromAbove = drop.y - drop.vy * dt <= solid.y + 1;
if (fromAbove) this.addStain(drop.x, solid.y + 1, drop.size * 2.6, drop.size * 0.7, drop.color);
else this.addStain(drop.vx > 0 ? solid.x - 1 : solid.x + solid.w + 1, drop.y, drop.size * 0.8, drop.size * 2, drop.color);
} else if (drop.age > 4) {
// Fell out of the world.
} else {
kept.push(drop);
}
}
this.drops = kept;
}
/** The solid part of the building at this point, if any. */
private solidAt(x: number, y: number): Solid | null {
for (const solid of this.building.solids) {
if (solid.broken) continue;
if (x >= solid.x && x <= solid.x + solid.w && y >= solid.y && y <= solid.y + solid.h) return solid;
}
return null;
}
private addStain(x: number, y: number, rx: number, ry: number, color: string): void {
this.stains.push({ x, y, rx, ry, color });
if (this.stains.length > MAX_STAINS) this.stains.shift();
}
/** Pick the pose for what this stickman is doing, and blend towards it. */
private animate(f: Fighter, dt: number): void {
let target: Pose;
let rate = 14;
const progress = f.actionLength > 0 ? Math.min(1, f.actionTime / f.actionLength) : 0;
if (f.action === 'dead') {
target = knockedOutPose();
rate = 10;
} else if (f.action === 'roll') {
target = tuckPose();
rate = 30;
} else if (f.action === 'punch') {
const windup = f.attack ? f.attack.activeFrom / f.attack.length : 0.25;
target = punchPose(progress, windup);
rate = f.isPlayer ? 34 : 22;
} else if (f.action === 'shoot') {
const aiming = f.actionTime >= 0.06 && f.actionTime < SHOT_PUT_AWAY_AT;
target = aiming ? shootPose() : holsterPose();
rate = 45;
} else if (f.action === 'sword') {
const t = f.actionTime;
target = swordPose(t < 0.1 ? 'grab' : t < SWORD.activeFrom ? 'raise' : t < 0.45 ? (t < SWORD.activeTo ? 'slash' : 'follow') : 'grab');
rate = t >= SWORD.activeFrom && t < SWORD.activeTo ? 55 : 35;
} else if (f.action === 'dropkick') {
target = dropKickPose();
rate = 30;
} else if (f.action === 'kick') {
target = kickPose(progress);
rate = 30;
} else if (f.action === 'climb') {
f.runPhase += dt * CLIMB_SPEED * 0.06;
target = runPose(f.runPhase);
rate = 22;
} else if (f.blocking) {
target = blockPose();
rate = 30;
} else if (f.action === 'hurt') {
target = hurtPose();
rate = 24;
} else if (!f.onGround) {
target = jumpPose(f.vy);
rate = 12;
} else if (Math.abs(f.vx) > 15) {
f.runPhase += dt * Math.abs(f.vx) * 0.075;
target = runPose(f.runPhase);
rate = 22;
} else {
target = idlePose(this.time + f.speed);
rate = 10;
}
if (f.slide) {
// Sliding: low legs and leaning back. Arms keep doing whatever attack is going on.
const slide = slidePose();
target = isAttack(f.action)
? { ...target, lean: slide.lean, legFu: slide.legFu, legFl: slide.legFl, legBu: slide.legBu, legBl: slide.legBl }
: slide;
rate = Math.max(rate, 30);
} else if (isAttack(f.action) && f.action !== 'kick' && f.onGround && Math.abs(f.vx) > 15) {
// Moving while attacking: the legs keep walking under the attack.
f.runPhase += dt * Math.abs(f.vx) * 0.075;
const walk = runPose(f.runPhase);
target = { ...target, legFu: walk.legFu, legFl: walk.legFl, legBu: walk.legBu, legBl: walk.legBl };
}
blendPose(f.pose, target, rate, dt);
// Remember where the sword tip has been, for the swoosh.
if (f.action === 'sword' && f.actionTime >= SWORD.activeFrom - 0.03 && f.actionTime < SWORD.activeTo + 0.05) {
const tip = swordTip(figureJoints(f.pose));
f.trail.push({ x: f.x + f.facing * tip.x * f.size, y: f.y + tip.y * f.size });
if (f.trail.length > 8) f.trail.shift();
} else if (f.trail.length > 0) {
f.trail.shift();
}
}
// ---------- drawing ----------
private draw(): void {
const ctx = this.ctx;
this.followCamera();
ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
drawSky(ctx, VIEW_W, VIEW_H, this.camera.x);
ctx.save();
ctx.translate(-Math.round(this.camera.x * 4) / 4, -Math.round(this.camera.y * 4) / 4);
drawBuilding(ctx, this.building, { x: this.camera.x, y: this.camera.y, w: VIEW_W, h: VIEW_H }, this.time);
for (const stain of this.stains) {
ctx.fillStyle = stain.color;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.ellipse(stain.x, stain.y, stain.rx, stain.ry, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
const fighters = [...this.enemies.filter((e) => !e.alive), ...this.enemies.filter((e) => e.alive), this.player];
for (const f of fighters) this.drawFighter(f);
for (const drop of this.drops) {
ctx.fillStyle = drop.color;
ctx.beginPath();
ctx.arc(drop.x, drop.y, drop.size, 0, Math.PI * 2);
ctx.fill();
}
// Bullets leave a short bright streak.
ctx.strokeStyle = '#fff2b0';
ctx.lineWidth = 1.2;
for (const bullet of this.bullets) {
ctx.beginPath();
ctx.moveTo(bullet.x, bullet.y);
ctx.lineTo(bullet.x - bullet.vx * 0.012, bullet.y - bullet.vy * 0.012);
ctx.stroke();
}
for (const spark of this.sparks) {
ctx.globalAlpha = Math.min(1, spark.life * 4) * (spark.dust ? 0.5 : 1);
ctx.fillStyle = spark.dust ? '#9a9aa6' : '#fff6c8';
ctx.fillRect(spark.x - 1, spark.y - 1, 2, 2);
}
ctx.globalAlpha = 1;
// Bits of smashed doors.
ctx.fillStyle = '#8a5f3c';
for (const bit of this.splinters) {
ctx.save();
ctx.globalAlpha = Math.min(1, bit.life * 2);
ctx.translate(bit.x, bit.y);
ctx.rotate(bit.angle);
ctx.fillRect(-3, -0.8, 6, 1.6);
ctx.restore();
}
ctx.globalAlpha = 1;
this.drawStairsHint();
for (const popup of this.popups) {
ctx.globalAlpha = Math.min(1, popup.life * 2);
ctx.textAlign = 'center';
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.fillStyle = '#12131c';
ctx.fillText(popup.text, popup.x + 1, popup.y + 1);
ctx.fillStyle = '#ffd23f';
ctx.fillText(popup.text, popup.x, popup.y);
}
ctx.globalAlpha = 1;
ctx.restore();
this.drawHud();
}
/** Show a little ↑ or ↓ when the player can use the stairs. */
private drawStairsHint(): void {
const p = this.player;
if (p.action !== 'none' || !p.alive) return;
const up = this.stairsHere(p, 'bottom');
const down = this.stairsHere(p, 'top');
if (!up && !down) return;
const ctx = this.ctx;
const bob = Math.sin(this.time * 6) * 1.5;
ctx.fillStyle = '#ffd23f';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(up ? '↑' : '↓', p.x, p.y - 46 + bob);
}
private cameraGoal(): { x: number; y: number } {
const p = this.player;
return {
x: Math.max(WORLD_LEFT - 20, Math.min(WORLD_RIGHT + 20 - VIEW_W, p.x - VIEW_W / 2)),
y: Math.max(ROOF_Y - 60, Math.min(GROUND_Y + 30 - VIEW_H, p.y - VIEW_H * 0.62)),
};
}
private snapCamera(): void {
this.camera = this.cameraGoal();
}
private followCamera(): void {
const goal = this.cameraGoal();
this.camera.x += (goal.x - this.camera.x) * 0.12;
this.camera.y += (goal.y - this.camera.y) * 0.12;
}
private drawFighter(f: Fighter): void {
const progress = f.actionLength > 0 ? Math.min(1, f.actionTime / f.actionLength) : 0;
const spin = f.action === 'roll' ? progress * Math.PI * 2 : 0;
const topple = f.action === 'dead' ? -Math.min(1, f.deadTime * 3.5) * (Math.PI / 2) : 0;
const alpha = f.action === 'dead' && !f.isPlayer ? Math.max(0, Math.min(1, 2.5 - f.deadTime)) : 1;
const minHip = f.action === 'roll' ? 15 : 6;
drawFigure(this.ctx, f.x, f.y, f.facing, f.pose, {
color: f.color,
spin,
minHip,
topple,
alpha,
flash: f.flash,
size: f.size,
gear: f.isPlayer ? (ctx, joints, layer) => this.drawGear(f, ctx, joints, layer) : undefined,
});
if (f.isPlayer) this.drawSwordTrail(f);
}
/** Draw the player's holster, revolver and sword in the right place on his body. */
private drawGear(f: Fighter, ctx: CanvasRenderingContext2D, joints: ReturnType<typeof figureJoints>, layer: 'behind' | 'front'): void {
const t = f.actionTime;
const gunOut = f.action === 'shoot' && t >= 0.05 && t < SHOT_PUT_AWAY_AT + 0.08;
const swordOut = f.action === 'sword' && t >= 0.07 && t < SWORD.length - 0.04;
if (layer === 'behind') {
if (!swordOut) drawSwordOnBack(ctx, joints);
return;
}
drawHolster(ctx, joints, !gunOut);
if (gunOut) drawRevolverInHand(ctx, joints, f.fired && t < SHOT_FIRE_AT + 0.06);
if (swordOut) drawSwordInHand(ctx, joints);
}
/** A swoosh following the sword tip during the slash. */
private drawSwordTrail(f: Fighter): void {
if (f.action !== 'sword' || f.trail.length < 2) return;
const ctx = this.ctx;
ctx.lineCap = 'round';
for (let i = 1; i < f.trail.length; i++) {
ctx.strokeStyle = `rgba(230, 240, 255, ${(i / f.trail.length) * 0.6})`;
ctx.lineWidth = 1 + (i / f.trail.length) * 3;
ctx.beginPath();
ctx.moveTo(f.trail[i - 1].x, f.trail[i - 1].y);
ctx.lineTo(f.trail[i].x, f.trail[i].y);
ctx.stroke();
}
}
private drawHud(): void {
const ctx = this.ctx;
const p = this.player;
// Health bar.
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
ctx.fillRect(10, 10, 124, 14);
const hp = Math.max(0, p.hp);
const part = hp / PLAYER_HP;
ctx.fillStyle = part > 0.3 ? PLAYER_COLOR : '#ff5a4f';
ctx.fillRect(12, 12, 120 * part, 10);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 9px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`HP ${Number.isInteger(hp) ? hp : hp.toFixed(1)} / ${PLAYER_HP}`, 16, 20.5);
ctx.textAlign = 'left';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.fillText(`KOs: ${this.knockouts}`, 146, 21.5);
ctx.fillStyle = '#ffd23f';
ctx.fillText(`Headshots: ${this.headshots}`, 216, 21.5);
ctx.fillStyle = '#ffffff';
const where = p.climb ? (p.climb.stairs.style === 'fire-escape' ? 'Fire escape' : 'Stairs') : this.playerSurface?.place ?? '';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.fillText(where, 312, 21);
if (this.defeatTimer > 0) {
ctx.fillStyle = 'rgba(10, 8, 16, 0.75)';
ctx.fillRect(0, VIEW_H / 2 - 32, VIEW_W, 64);
ctx.textAlign = 'center';
ctx.fillStyle = '#ff5a4f';
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText('DEFEATED!', VIEW_W / 2, VIEW_H / 2 - 4);
ctx.fillStyle = '#ffffff';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText(
`You knocked out ${this.knockouts} stickmen with ${this.headshots} headshots. Starting again...`,
VIEW_W / 2,
VIEW_H / 2 + 18,
);
}
}
}
function isAttack(action: Action): boolean {
return action === 'punch' || action === 'kick' || action === 'shoot' || action === 'sword';
}
function approach(value: number, target: number, step: number): number {
if (value < target) return Math.min(target, value + step);
return Math.max(target, value - step);
}
/** Any bright colour except green (green is the player's colour). */
function randomEnemyColor(): string {
let hue = Math.floor(Math.random() * 260);
if (hue >= 70) hue += 100; // skip the greens (70 to 170)
const saturation = 70 + Math.floor(Math.random() * 20);
const lightness = 52 + Math.floor(Math.random() * 12);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
