SOURCE / PINNED RELEASE
Made of little things.
Murder Mice
- Release
- d2fa0e0e36c1…
- Author-recorded commit
- 13dc3200cde5…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-26115e0d25b
Archive hash verified: 82738dcf059ef73f…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { keys } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { createGamepadInput, type GamepadSample } from './gamepad.js';
import './styles.css';
const canvas = document.querySelector<HTMLCanvasElement>('#table')!;
const ctx = canvas.getContext('2d')!;
const leftButton = document.querySelector<HTMLButtonElement>('#turnLeft')!;
const rightButton = document.querySelector<HTMLButtonElement>('#turnRight')!;
const shootButton = document.querySelector<HTMLButtonElement>('#shoot')!;
const pauseButton = document.querySelector<HTMLButtonElement>('#pause')!;
const menu = document.querySelector<HTMLDivElement>('#menu')!;
const menuTitle = document.querySelector<HTMLDivElement>('#menuTitle')!;
const playButton = document.querySelector<HTMLButtonElement>('#playButton')!;
const restartButton = document.querySelector<HTMLButtonElement>('#restartButton')!;
/** How fast the gun turns. Math.PI is half a circle per second. */
const TURN_SPEED = Math.PI * 0.8;
/** Which way the gun points. 0 is straight up. */
let gunAngle = 0;
/** How fast the gun is turning right now. It speeds up and slows down gently. */
let gunSpin = 0;
/** How quickly the turning catches up to what you press. Bigger is snappier. */
const SPIN_EASE = 14;
// Everything on the table is measured in "gun sizes" from the middle of the table,
// so the game looks the same when the window changes size.
/** How fast bullets fly, in gun sizes per second. */
const BULLET_SPEED = 14;
type Weapon = 'rifle' | 'shotgun' | 'revolver' | 'rocket' | 'beam';
/**
* Each gun: the level that unlocks it, how many shots before it has to reload,
* how many seconds reloading takes, and where bullets come out (from the gun's middle).
*/
const GUNS: Record<Weapon, { name: string; unlock: number; shots: number; reload: number; muzzle: number }> = {
rifle: { name: 'Rifle', unlock: 1, shots: 1, reload: 0, muzzle: 1.95 },
revolver: { name: 'Revolver', unlock: 2, shots: 6, reload: 3, muzzle: 1.25 },
shotgun: { name: 'Shotgun', unlock: 4, shots: 1, reload: 3, muzzle: 1.55 },
rocket: { name: 'Rocket Launcher', unlock: 7, shots: 1, reload: 3, muzzle: 1.6 },
// The beam's reload starts after the beam stops.
beam: { name: 'Energy Beam', unlock: 9, shots: 1, reload: 1, muzzle: 1.5 },
};
const WEAPONS = Object.keys(GUNS) as Weapon[];
/** Shortest wait between two shots from a gun that holds more than one, in seconds. */
const SHOT_GAP = 0.25;
let weapon: Weapon = 'rifle';
/** The shotgun shoots 3 bullets: straight, and this far to the left and right. */
const SHOTGUN_SPREAD = (Math.PI / 180) * 15;
/** You can't switch guns: you always hold the newest gun you've unlocked. */
const newestGun = () => WEAPONS.filter((w) => level >= GUNS[w].unlock).at(-1) ?? 'rifle';
/** Each gun keeps its own bullets and reloads on its own, even when you're not holding it. */
const ammo: Record<Weapon, number> = { rifle: 1, shotgun: 1, revolver: 6, rocket: 1, beam: 1 };
const reloadLeft: Record<Weapon, number> = { rifle: 0, shotgun: 0, revolver: 0, rocket: 0, beam: 0 };
/** How long the energy beam keeps firing, in seconds. */
const BEAM_TIME = 1;
/** How wide the beam is. Mice touching it die. */
const BEAM_WIDTH = 0.22;
let beamLeft = 0;
/** Stop the beam and start its reload. */
function stopBeam(): void {
if (beamLeft <= 0) return;
beamLeft = 0;
reloadLeft.beam = GUNS.beam.reload;
}
/** Rockets fly slower than bullets. */
const ROCKET_SPEED = 8;
/** Every mouse this close to an explosion dies. */
const BLAST_RADIUS = 2.4;
/** How long the fire and smoke last, in seconds. */
const BLAST_TIME = 1.6;
/** How long the screen shakes after a blast, in seconds. */
const SHAKE_TIME = 0.35;
let shakeLeft = 0;
let shotGap = 0;
/** How close a bullet has to get to a mouse to hit it. */
const MOUSE_RADIUS = 0.3;
const LEVELS = 10;
/** Mice run this much faster each level: level 2 is 12% faster than level 1, and so on. */
const SPEED_UP_PER_LEVEL = 0.12;
/** Level 1 has 10 mice, and every level after that has 10 more. Dead mice don't come back. */
const miceForLevel = (n: number) => 10 * n;
/** The rifle can't turn further than this to either side, so it never points off the bottom. */
const MAX_TURN = (Math.PI / 180) * 85;
/** How far the middle of the rifle sits above the bottom edge. */
const GUN_FROM_BOTTOM = 1.2;
const MAX_SPLATS = 40;
type Bullet = { x: number; y: number; dx: number; dy: number; rocket: boolean };
/** A ball of fire that turns into smoke. */
type Puff = { x: number; y: number; vx: number; vy: number; size: number; delay: number };
/** A hot bit flying out of the explosion. */
type Spark = { vx: number; vy: number; life: number };
type Blast = { x: number; y: number; age: number; puffs: Puff[]; sparks: Spark[] };
/** Burnt black marks left on the table. */
type Scorch = { x: number; y: number; blobs: { x: number; y: number; r: number }[] };
/**
* What a mouse is doing:
* - scurry: running around the table until its timer runs out
* - toHole: running straight for the nearest mouse hole
* - hiding: inside a hole (can't be hit) until its timer runs out
*/
type MouseState = 'scurry' | 'toHole' | 'hiding';
type Mouse = {
x: number;
y: number;
heading: number;
speed: number;
turn: number;
wiggle: number;
state: MouseState;
timer: number;
hole: number;
};
/** Seconds a mouse runs around before heading for a hole: 5 on level 1, half a second less each level. */
const scurryTime = () => Math.max(0.5, 5 - 0.5 * (level - 1));
/** Seconds a mouse hides in a hole: 0 on level 1, half a second more each level. */
const hideTime = () => 0.5 * (level - 1);
/** How far inside the table edge a hole's middle is. */
const HOLE_INSET = 0.25;
const HOLE_SIZE = 0.35;
/** The three mouse holes: middle of the left, top and right edges (none behind the rifle). */
function holes(): { x: number; y: number; inward: number }[] {
return [
{ x: -tableHalfW + HOLE_INSET, y: 0, inward: 0 },
{ x: 0, y: -tableHalfH + HOLE_INSET, inward: Math.PI / 2 },
{ x: tableHalfW - HOLE_INSET, y: 0, inward: Math.PI },
];
}
type Splat = { x: number; y: number; blobs: { x: number; y: number; r: number }[] };
const bullets: Bullet[] = [];
const blasts: Blast[] = [];
const scorches: Scorch[] = [];
const mice: Mouse[] = [];
const splats: Splat[] = [];
let shootQueued = false;
let nextMouseIn = 0.5;
let level = 1;
let miceStillToCome = miceForLevel(level);
/** Half the table's width and height, in gun sizes. */
let tableHalfW = 5;
let tableHalfH = 4;
/** Where the middle of the rifle is: at the bottom of the table. */
const gunY = () => tableHalfH - GUN_FROM_BOTTOM;
// ---------- input ----------
/** Arrow keys being held down right now. */
const heldKeys = { left: false, right: false };
/** Screen buttons being held. */
const heldButtons = { left: false, right: false };
/** Shell key actions only say "pressed", so each press keeps turning a little while (for keys the shell remaps). */
const actionUntil = { left: 0, right: 0 };
const ACTION_HOLD_MS = 120;
if (runtimeHasDomain('keys')) {
void keys
// The shell matches the key's name, and the spacebar's name is a single space.
.register({ id: 'gun.shoot', label: 'Shoot', defaultKey: ' ' }, () => {
shootQueued = true;
})
.catch(() => useLocalKeys());
void keys
.register({ id: 'game.pause', label: 'Pause', defaultKey: 'p' }, togglePause)
.catch(() => useLocalKeys());
void keys
.register({ id: 'gun.turnLeft', label: 'Turn gun left', defaultKey: 'ArrowLeft' }, () => {
actionUntil.left = performance.now() + ACTION_HOLD_MS;
})
.catch(() => useLocalKeys());
void keys
.register({ id: 'gun.turnRight', label: 'Turn gun right', defaultKey: 'ArrowRight' }, () => {
actionUntil.right = performance.now() + ACTION_HOLD_MS;
})
.catch(() => useLocalKeys());
} else {
useLocalKeys();
}
// Escape pauses too. Shells keep Escape for themselves, so the game listens for it itself.
window.addEventListener('keydown', (event) => {
if (event.code === 'Escape' && !event.repeat) togglePause();
});
// Shells can't give Shift on its own as a shortcut, so the game listens for it itself.
// It also watches the arrow keys go down and up, so holding one turns the rifle smoothly
// instead of in bursts from key repeat.
window.addEventListener('keydown', (event) => {
if ((event.code === 'ShiftLeft' || event.code === 'ShiftRight') && !event.repeat) shootQueued = true;
if (event.code === 'ArrowLeft') heldKeys.left = true;
if (event.code === 'ArrowRight') heldKeys.right = true;
});
window.addEventListener('keyup', (event) => {
if (event.code === 'ArrowLeft') heldKeys.left = false;
if (event.code === 'ArrowRight') heldKeys.right = false;
});
window.addEventListener('blur', () => {
heldKeys.left = false;
heldKeys.right = false;
});
let localKeysOn = false;
function useLocalKeys(): void {
if (localKeysOn) return;
localKeysOn = true;
window.addEventListener('keydown', (event) => {
if (event.code === 'Space') {
if (!event.repeat) shootQueued = true;
} else if (event.code === 'KeyP') {
if (!event.repeat) togglePause();
} else if (event.code !== 'ArrowLeft' && event.code !== 'ArrowRight') return;
event.preventDefault();
});
}
function holdButton(button: HTMLButtonElement, side: 'left' | 'right'): void {
button.addEventListener('pointerdown', (event) => {
heldButtons[side] = true;
button.setPointerCapture(event.pointerId);
});
for (const type of ['pointerup', 'pointercancel', 'lostpointercapture'] as const) {
button.addEventListener(type, () => (heldButtons[side] = false));
}
}
holdButton(leftButton, 'left');
holdButton(rightButton, 'right');
shootButton.addEventListener('pointerdown', () => (shootQueued = true));
// ---------- menu ----------
/** The game waits on the menu when it first opens, and whenever you pause. */
let paused = true;
let started = false;
function showMenu(): void {
paused = true;
menu.hidden = false;
menuTitle.textContent = started ? 'Paused' : 'Murder Mice';
playButton.textContent = started ? 'Resume' : 'Play';
restartButton.hidden = !started;
playButton.focus();
}
function hideMenu(): void {
paused = false;
started = true;
menu.hidden = true;
shootQueued = false;
canvas.focus();
}
function togglePause(): void {
if (paused) {
if (started) hideMenu();
} else showMenu();
}
/** Start again from level 1 with a clean table. */
function restartGame(): void {
level = 0;
mice.length = 0;
newRound();
gunAngle = 0;
gunSpin = 0;
hideMenu();
}
playButton.addEventListener('click', hideMenu);
restartButton.addEventListener('click', restartGame);
pauseButton.addEventListener('click', showMenu);
// Controllers: left stick or d-pad turns the gun.
type ShellGamepad = {
getGamepads(): ArrayLike<GamepadSample | null>;
available?: boolean | (() => boolean);
focused?: boolean | (() => boolean);
};
const shellGamepad = (window as Window & { napplet?: { gamepad?: ShellGamepad } }).napplet?.gamepad;
const flag = (value: boolean | (() => boolean) | undefined, fallback: boolean) =>
typeof value === 'function' ? value.call(shellGamepad) : (value ?? fallback);
// A (bottom button) or the right trigger shoots.
const pad = createGamepadInput(
{
turn: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
shoot: [{ button: 0 }, { button: 7 }],
// Start pauses and unpauses.
pause: [{ button: 9 }],
},
shellGamepad
? {
read: () => {
if (!flag(shellGamepad.available, true)) throw new Error('Controllers unavailable');
return shellGamepad.getGamepads();
},
active: () => flag(shellGamepad.focused, document.hasFocus()) && !document.hidden,
}
: {},
);
/** -1 turns left, 1 turns right, in between turns slower. */
function turnInput(now: number, players: ReturnType<typeof pad.poll>['players']): number {
let turn = 0;
if (heldKeys.left || heldButtons.left || now < actionUntil.left) turn -= 1;
if (heldKeys.right || heldButtons.right || now < actionUntil.right) turn += 1;
for (const player of players) {
const value = player.actions.turn?.value ?? 0;
if (Math.abs(value) > 0.2) turn += value;
}
return Math.max(-1, Math.min(1, turn));
}
// ---------- shooting, mice and splats ----------
function shoot(): void {
if (weapon === 'beam') {
ammo.beam = 0;
beamLeft = BEAM_TIME;
shotGap = SHOT_GAP;
return;
}
const dx = Math.sin(gunAngle);
const dy = -Math.cos(gunAngle);
const muzzle = GUNS[weapon].muzzle;
const angles = weapon === 'shotgun' ? [-SHOTGUN_SPREAD, 0, SHOTGUN_SPREAD] : [0];
for (const spread of angles) {
const bx = Math.sin(gunAngle + spread);
const by = -Math.cos(gunAngle + spread);
const rocket = weapon === 'rocket';
const speed = rocket ? ROCKET_SPEED : BULLET_SPEED;
bullets.push({ x: dx * muzzle, y: gunY() + dy * muzzle, dx: bx * speed, dy: by * speed, rocket });
}
ammo[weapon]--;
if (ammo[weapon] === 0) {
// A gun with no reload is ready again right away (only the short gap between shots).
if (GUNS[weapon].reload > 0) reloadLeft[weapon] = GUNS[weapon].reload;
else ammo[weapon] = GUNS[weapon].shots;
}
shotGap = SHOT_GAP;
}
const canShoot = () => shotGap <= 0 && reloadLeft[weapon] <= 0 && ammo[weapon] > 0;
/** Put a mouse at a random hole, running out onto the table. */
function comeOutOfHole(mouse: Mouse): void {
const index = Math.floor(Math.random() * 3);
const hole = holes()[index];
mouse.x = hole.x;
mouse.y = hole.y;
mouse.heading = hole.inward + (Math.random() - 0.5);
mouse.turn = 0;
mouse.hole = index;
mouse.state = 'scurry';
mouse.timer = scurryTime();
}
/** A new mouse runs out of one of the holes. */
function spawnMouse(): void {
const mouse: Mouse = {
x: 0,
y: 0,
heading: 0,
speed: (1.2 + Math.random() * 1.3) * (1 + SPEED_UP_PER_LEVEL * (level - 1)),
turn: 0,
wiggle: Math.random() * 10,
state: 'scurry',
timer: 0,
hole: 0,
};
comeOutOfHole(mouse);
mice.push(mouse);
}
/** Where the beam starts (the gun's tip) and which way it points. */
function beamStart(): { x: number; y: number; dx: number; dy: number } {
const dx = Math.sin(gunAngle);
const dy = -Math.cos(gunAngle);
return { x: dx * GUNS.beam.muzzle, y: gunY() + dy * GUNS.beam.muzzle, dx, dy };
}
/** Every mouse touching the beam dies. It goes right through them, all the way across the table. */
function zapMice(): void {
const b = beamStart();
for (let i = mice.length - 1; i >= 0; i--) {
const mouse = mice[i];
if (mouse.state === 'hiding') continue;
const along = (mouse.x - b.x) * b.dx + (mouse.y - b.y) * b.dy;
const across = Math.abs((mouse.x - b.x) * b.dy - (mouse.y - b.y) * b.dx);
if (along > 0 && across < MOUSE_RADIUS + BEAM_WIDTH / 2) {
addSplat(mouse.x, mouse.y);
mice.splice(i, 1);
}
}
}
/** Boom! Every mouse near the explosion dies (except ones hiding in holes). */
function explode(x: number, y: number): void {
const puffs: Puff[] = [];
for (let i = 0; i < 22; i++) {
const a = Math.random() * Math.PI * 2;
const d = Math.random() * BLAST_RADIUS * 0.35;
const speed = 0.5 + Math.random() * 2.2;
puffs.push({
x: Math.cos(a) * d,
y: Math.sin(a) * d,
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
size: 0.45 + Math.random() * 0.6,
delay: Math.random() * 0.12,
});
}
const sparks: Spark[] = [];
for (let i = 0; i < 26; i++) {
const a = Math.random() * Math.PI * 2;
const speed = 4 + Math.random() * 6;
sparks.push({ vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, life: 0.25 + Math.random() * 0.35 });
}
blasts.push({ x, y, age: 0, puffs, sparks });
const blobs = [{ x: 0, y: 0, r: BLAST_RADIUS * 0.45 }];
for (let i = 0; i < 8; i++) {
const a = Math.random() * Math.PI * 2;
const d = BLAST_RADIUS * (0.2 + Math.random() * 0.3);
blobs.push({ x: Math.cos(a) * d, y: Math.sin(a) * d, r: 0.15 + Math.random() * 0.3 });
}
scorches.push({ x, y, blobs });
if (scorches.length > 12) scorches.shift();
shakeLeft = SHAKE_TIME;
for (let i = mice.length - 1; i >= 0; i--) {
const mouse = mice[i];
if (mouse.state !== 'hiding' && Math.hypot(mouse.x - x, mouse.y - y) < BLAST_RADIUS) {
addSplat(mouse.x, mouse.y);
mice.splice(i, 1);
}
}
}
function addSplat(x: number, y: number): void {
const blobs = [{ x: 0, y: 0, r: 0.32 }];
for (let i = 0; i < 9; i++) {
const a = Math.random() * Math.PI * 2;
const d = 0.2 + Math.random() * 0.45;
blobs.push({ x: Math.cos(a) * d, y: Math.sin(a) * d, r: 0.04 + Math.random() * 0.12 });
}
splats.push({ x, y, blobs });
if (splats.length > MAX_SPLATS) splats.shift();
}
const roundOver = () => miceStillToCome === 0 && mice.length === 0;
/** Clean the table and send in the mice for the next level. After the last level, start over. */
function newRound(): void {
level = level >= LEVELS ? 1 : level + 1;
weapon = newestGun();
for (const gun of WEAPONS) {
ammo[gun] = GUNS[gun].shots;
reloadLeft[gun] = 0;
}
splats.length = 0;
bullets.length = 0;
blasts.length = 0;
scorches.length = 0;
miceStillToCome = miceForLevel(level);
nextMouseIn = 0.5;
// Short pause so the shot that started the level doesn't also fire.
shotGap = 0.3;
beamLeft = 0;
}
function updateWorld(dt: number): void {
shotGap -= dt;
for (const gun of WEAPONS) {
if (reloadLeft[gun] <= 0) continue;
reloadLeft[gun] -= dt;
if (reloadLeft[gun] <= 0) ammo[gun] = GUNS[gun].shots;
}
if (shootQueued) {
if (roundOver()) {
if (shotGap <= 0) newRound();
} else if (canShoot()) shoot();
}
shootQueued = false;
nextMouseIn -= dt;
if (nextMouseIn <= 0 && miceStillToCome > 0) {
spawnMouse();
miceStillToCome--;
nextMouseIn = 0.4 + Math.random() * 0.8;
}
for (const mouse of mice) {
mouse.timer -= dt;
if (mouse.state === 'hiding') {
if (mouse.timer <= 0) comeOutOfHole(mouse);
continue;
}
if (mouse.state === 'scurry' && mouse.timer <= 0) {
// Time's up: run for the nearest hole, but never the one it came out of.
const all = holes();
let nearest = -1;
all.forEach((hole, i) => {
if (i === mouse.hole) return;
const distance = Math.hypot(hole.x - mouse.x, hole.y - mouse.y);
if (nearest < 0 || distance < Math.hypot(all[nearest].x - mouse.x, all[nearest].y - mouse.y)) nearest = i;
});
mouse.hole = nearest;
mouse.state = 'toHole';
}
if (mouse.state === 'toHole') {
const hole = holes()[mouse.hole];
const dx = hole.x - mouse.x;
const dy = hole.y - mouse.y;
const step = mouse.speed * dt;
mouse.wiggle += dt * mouse.speed * 6;
if (Math.hypot(dx, dy) <= step) {
mouse.state = 'hiding';
mouse.timer = hideTime();
continue;
}
mouse.heading = Math.atan2(dy, dx);
mouse.x += Math.cos(mouse.heading) * step;
mouse.y += Math.sin(mouse.heading) * step;
continue;
}
// Now and then pick a new way to scurry.
if (Math.random() < dt * 1.2) mouse.turn = (Math.random() - 0.5) * 4;
mouse.heading += mouse.turn * dt;
mouse.x += Math.cos(mouse.heading) * mouse.speed * dt;
mouse.y += Math.sin(mouse.heading) * mouse.speed * dt;
mouse.wiggle += dt * mouse.speed * 6;
// Bounce off the table edges.
if (Math.abs(mouse.x) > tableHalfW - 0.3) {
mouse.x = Math.sign(mouse.x) * (tableHalfW - 0.3);
mouse.heading = Math.PI - mouse.heading;
}
if (Math.abs(mouse.y) > tableHalfH - 0.3) {
mouse.y = Math.sign(mouse.y) * (tableHalfH - 0.3);
mouse.heading = -mouse.heading;
}
// Keep away from the rifle.
const awayY = mouse.y - gunY();
const fromGun = Math.hypot(mouse.x, awayY);
if (fromGun < 1.2) {
mouse.x = (mouse.x / fromGun) * 1.2;
mouse.y = gunY() + (awayY / fromGun) * 1.2;
mouse.heading = Math.atan2(awayY, mouse.x);
}
}
for (let b = bullets.length - 1; b >= 0; b--) {
const bullet = bullets[b];
bullet.x += bullet.dx * dt;
bullet.y += bullet.dy * dt;
const hit = mice.findIndex((m) => m.state !== 'hiding' && Math.hypot(m.x - bullet.x, m.y - bullet.y) < MOUSE_RADIUS);
if (bullet.rocket) {
// Rockets blow up when they hit a mouse or the edge of the table.
const atEdge = Math.abs(bullet.x) > tableHalfW || Math.abs(bullet.y) > tableHalfH;
if (hit >= 0 || atEdge) {
explode(
Math.max(-tableHalfW, Math.min(tableHalfW, bullet.x)),
Math.max(-tableHalfH, Math.min(tableHalfH, bullet.y)),
);
bullets.splice(b, 1);
}
} else if (hit >= 0) {
addSplat(mice[hit].x, mice[hit].y);
mice.splice(hit, 1);
bullets.splice(b, 1);
} else if (Math.abs(bullet.x) > tableHalfW + 1 || Math.abs(bullet.y) > tableHalfH + 1) {
bullets.splice(b, 1);
}
}
if (beamLeft > 0) {
zapMice();
if (beamLeft <= dt) stopBeam();
else beamLeft -= dt;
}
shakeLeft = Math.max(0, shakeLeft - dt);
for (let i = blasts.length - 1; i >= 0; i--) {
blasts[i].age += dt;
for (const puff of blasts[i].puffs) {
if (blasts[i].age < puff.delay) continue;
puff.x += puff.vx * dt;
puff.y += puff.vy * dt;
// Puffs slow down as they spread out, like real smoke.
puff.vx *= Math.exp(-3 * dt);
puff.vy *= Math.exp(-3 * dt);
}
if (blasts[i].age > BLAST_TIME) blasts.splice(i, 1);
}
}
// ---------- drawing ----------
function resize(): void {
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * ratio);
canvas.height = Math.round(canvas.clientHeight * ratio);
}
window.addEventListener('resize', resize);
resize();
function drawTable(w: number, h: number): void {
// Wooden tabletop with planks.
ctx.fillStyle = '#8a5a34';
ctx.fillRect(0, 0, w, h);
const plank = Math.max(40, h / 8);
for (let y = 0, i = 0; y < h; y += plank, i++) {
ctx.fillStyle = i % 2 ? '#7f5230' : '#93613a';
ctx.fillRect(0, y, w, plank - 2);
ctx.fillStyle = '#5e3b20';
ctx.fillRect(0, y + plank - 2, w, 2);
}
// Dark edge so it feels like you're looking down at a table.
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);
edge.addColorStop(0, 'rgba(0,0,0,0)');
edge.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = edge;
ctx.fillRect(0, 0, w, h);
}
/** Fill and outline a rectangle with rounded corners. */
function block(x: number, y: number, w: number, h: number, r: number, fill: string, line: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = '#15171a';
ctx.lineWidth = line;
ctx.stroke();
}
function drawGun(x: number, y: number, size: number): void {
if (weapon === 'shotgun') drawShotgun(x, y, size);
else if (weapon === 'revolver') drawRevolver(x, y, size);
else if (weapon === 'rocket') drawRocketLauncher(x, y, size);
else if (weapon === 'beam') drawBeamGun(x, y, size);
else drawRifle(x, y, size);
}
/** A sci-fi energy gun seen from above, pointing up, with glowing rings. */
function drawBeamGun(x: number, y: number, size: number): void {
const s = size;
const line = s * 0.04;
const ready = reloadLeft.beam <= 0;
const glow = beamLeft > 0 ? '#e8fdff' : ready ? '#4de8ff' : '#2a6670';
ctx.save();
ctx.translate(x, y);
ctx.rotate(gunAngle);
// Shadow on the table.
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(-s * 0.2 + s * 0.08, -s * 1.5 + s * 0.1, s * 0.4, s * 2.3);
// Grip and back end.
block(-s * 0.13, s * 0.2, s * 0.26, s * 0.6, s * 0.08, '#2b2f3a', line);
// Main body.
block(-s * 0.2, -s * 0.6, s * 0.4, s * 0.9, s * 0.12, '#5b4b8a', line);
// Barrel with glowing rings.
block(-s * 0.1, -s * 1.45, s * 0.2, s * 0.9, s * 0.05, '#3a3550', line);
for (let i = 0; i < 3; i++) block(-s * 0.15, -s * 1.3 + i * s * 0.25, s * 0.3, s * 0.08, s * 0.03, glow, line);
// Glowing tip.
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, -s * 1.48, s * 0.09, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Power window on the body.
block(-s * 0.08, -s * 0.4, s * 0.16, s * 0.45, s * 0.04, glow, line);
ctx.restore();
}
/** The energy beam: a wide glow with a bright flickering core. */
function drawBeam(): void {
if (beamLeft <= 0) return;
const b = beamStart();
const length = 60;
const flicker = 0.85 + Math.random() * 0.3;
// It fades in quickly at the start and out at the very end.
const fade = Math.min(1, (BEAM_TIME - beamLeft) / 0.05, beamLeft / 0.1);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(Math.atan2(b.dy, b.dx));
ctx.globalAlpha = fade;
for (const [width, color] of [
[BEAM_WIDTH * 3.2, 'rgba(77,232,255,0.15)'],
[BEAM_WIDTH * 1.8, 'rgba(77,232,255,0.35)'],
[BEAM_WIDTH, 'rgba(160,245,255,0.85)'],
[BEAM_WIDTH * 0.4, 'rgba(255,255,255,1)'],
] as const) {
const w = width * flicker;
ctx.fillStyle = color;
ctx.fillRect(0, -w / 2, length, w);
}
// Bright glow at the gun's tip.
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, BEAM_WIDTH * 3);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(1, 'rgba(77,232,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, BEAM_WIDTH * 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** A rocket launcher seen from above, pointing up: a big green tube with a rocket peeking out. */
function drawRocketLauncher(x: number, y: number, size: number): void {
const s = size;
const line = s * 0.04;
ctx.save();
ctx.translate(x, y);
ctx.rotate(gunAngle);
// Shadow on the table.
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(-s * 0.2 + s * 0.08, -s * 1.6 + s * 0.1, s * 0.4, s * 2.4);
// Main tube.
block(-s * 0.18, -s * 1.5, s * 0.36, s * 2.2, s * 0.08, '#5d6b3a', line);
// Wider ends.
block(-s * 0.22, -s * 1.6, s * 0.44, s * 0.18, s * 0.05, '#3f4a26', line);
block(-s * 0.22, s * 0.58, s * 0.44, s * 0.18, s * 0.05, '#3f4a26', line);
// Red rocket tip ready in the front.
if (ammo.rocket > 0) {
ctx.fillStyle = '#c0392b';
ctx.beginPath();
ctx.moveTo(-s * 0.12, -s * 1.6);
ctx.lineTo(0, -s * 1.78);
ctx.lineTo(s * 0.12, -s * 1.6);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// Sight on the side, and a yellow danger stripe.
block(s * 0.18, -s * 0.55, s * 0.12, s * 0.3, s * 0.03, '#23272b', line);
ctx.fillStyle = '#e0b23c';
ctx.fillRect(-s * 0.18, -s * 1.15, s * 0.36, s * 0.08);
ctx.restore();
}
/** A revolver seen from above, pointing up: short barrel, a round cylinder and a wooden grip. */
function drawRevolver(x: number, y: number, size: number): void {
const s = size;
const line = s * 0.04;
ctx.save();
ctx.translate(x, y);
ctx.rotate(gunAngle);
// Shadow on the table.
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(-s * 0.12 + s * 0.08, -s * 1.25 + s * 0.1, s * 0.3, s * 2.0);
// Wooden grip at the back, curving a little.
ctx.beginPath();
ctx.moveTo(-s * 0.11, s * 0.15);
ctx.lineTo(s * 0.11, s * 0.15);
ctx.quadraticCurveTo(s * 0.2, s * 0.55, s * 0.14, s * 0.8);
ctx.quadraticCurveTo(0, s * 0.9, -s * 0.14, s * 0.8);
ctx.quadraticCurveTo(-s * 0.2, s * 0.55, -s * 0.11, s * 0.15);
ctx.closePath();
ctx.fillStyle = '#6b3a1f';
ctx.fill();
ctx.strokeStyle = '#15171a';
ctx.lineWidth = line;
ctx.stroke();
// Barrel and front sight.
block(-s * 0.06, -s * 1.25, s * 0.12, s * 1.05, s * 0.03, '#9aa3ab', line);
block(-s * 0.03, -s * 1.22, s * 0.06, s * 0.1, s * 0.02, '#4c535a', line);
// Frame and the hammer at the back.
block(-s * 0.12, -s * 0.3, s * 0.24, s * 0.5, s * 0.05, '#7d868e', line);
block(-s * 0.04, s * 0.12, s * 0.08, s * 0.14, s * 0.02, '#4c535a', line);
// Round cylinder with 6 holes. Gold ones still have a bullet in them.
ctx.fillStyle = '#8e979f';
ctx.beginPath();
ctx.arc(0, -s * 0.05, s * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 - Math.PI / 2;
ctx.fillStyle = i < ammo.revolver ? '#e0b23c' : '#1b1d20';
ctx.beginPath();
ctx.arc(Math.cos(a) * s * 0.13, -s * 0.05 + Math.sin(a) * s * 0.13, s * 0.045, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
/** A pump shotgun seen from above, pointing up: shorter, with two fat barrels. */
function drawShotgun(x: number, y: number, size: number): void {
const s = size;
const line = s * 0.04;
ctx.save();
ctx.translate(x, y);
ctx.rotate(gunAngle);
// Shadow on the table.
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(-s * 0.18 + s * 0.08, -s * 1.5 + s * 0.1, s * 0.4, s * 2.6);
// Wooden stock at the back.
ctx.beginPath();
ctx.moveTo(-s * 0.13, s * 0.2);
ctx.lineTo(s * 0.13, s * 0.2);
ctx.lineTo(s * 0.21, s * 1.0);
ctx.quadraticCurveTo(0, s * 1.1, -s * 0.21, s * 1.0);
ctx.closePath();
ctx.fillStyle = '#7a4320';
ctx.fill();
ctx.strokeStyle = '#15171a';
ctx.lineWidth = line;
ctx.stroke();
block(-s * 0.22, s * 0.94, s * 0.44, s * 0.12, s * 0.05, '#23272b', line);
// Two barrels side by side.
block(-s * 0.12, -s * 1.55, s * 0.12, s * 1.35, s * 0.03, '#3a3f45', line);
block(0, -s * 1.55, s * 0.12, s * 1.35, s * 0.03, '#3a3f45', line);
// Dark barrel holes at the front.
ctx.fillStyle = '#0c0d0f';
ctx.fillRect(-s * 0.09, -s * 1.53, s * 0.06, s * 0.05);
ctx.fillRect(s * 0.03, -s * 1.53, s * 0.06, s * 0.05);
// Wooden pump under the barrels.
block(-s * 0.17, -s * 0.95, s * 0.34, s * 0.45, s * 0.07, '#8a5228', line);
for (let i = 0; i < 4; i++) {
ctx.fillStyle = '#5a3218';
ctx.fillRect(-s * 0.14, -s * 0.88 + i * s * 0.1, s * 0.28, s * 0.03);
}
// Metal body in the middle, with a red stripe so it looks different from the rifle.
block(-s * 0.15, -s * 0.25, s * 0.3, s * 0.5, s * 0.05, '#4c535a', line);
ctx.fillStyle = '#c0392b';
ctx.fillRect(-s * 0.15, -s * 0.05, s * 0.3, s * 0.08);
ctx.restore();
}
/** A rifle seen from above, pointing up, turning around its middle. */
function drawRifle(x: number, y: number, size: number): void {
const s = size;
const line = s * 0.04;
ctx.save();
ctx.translate(x, y);
ctx.rotate(gunAngle);
// Shadow on the table.
ctx.save();
ctx.translate(s * 0.08, s * 0.1);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(-s * 0.2, -s * 1.9, s * 0.4, s * 3.0);
ctx.restore();
// Wooden stock at the back.
ctx.beginPath();
ctx.moveTo(-s * 0.13, s * 0.2);
ctx.lineTo(s * 0.13, s * 0.2);
ctx.lineTo(s * 0.2, s * 1.05);
ctx.quadraticCurveTo(0, s * 1.15, -s * 0.2, s * 1.05);
ctx.closePath();
ctx.fillStyle = '#5a2e17';
ctx.fill();
ctx.strokeStyle = '#15171a';
ctx.lineWidth = line;
ctx.stroke();
// Butt pad.
block(-s * 0.21, s * 0.98, s * 0.42, s * 0.12, s * 0.05, '#23272b', line);
// Wooden grip under the barrel.
block(-s * 0.12, -s * 0.85, s * 0.24, s * 0.7, s * 0.06, '#6b3a1f', line);
// Long barrel.
block(-s * 0.05, -s * 1.85, s * 0.1, s * 1.2, s * 0.02, '#3a3f45', line);
// Front sight and muzzle.
block(-s * 0.08, -s * 1.95, s * 0.16, s * 0.12, s * 0.02, '#23272b', line);
// Metal body in the middle.
block(-s * 0.14, -s * 0.25, s * 0.28, s * 0.5, s * 0.05, '#4c535a', line);
// Bolt handle sticking out the side.
block(s * 0.12, -s * 0.05, s * 0.18, s * 0.07, s * 0.03, '#4c535a', line);
ctx.fillStyle = '#4c535a';
ctx.beginPath();
ctx.arc(s * 0.32, -s * 0.015, s * 0.06, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Scope on top.
block(-s * 0.08, -s * 0.55, s * 0.16, s * 0.6, s * 0.06, '#1f2226', line);
block(-s * 0.1, -s * 0.6, s * 0.2, s * 0.1, s * 0.03, '#2c3035', line);
// Shiny glass at the front of the scope.
ctx.fillStyle = '#7fc3e8';
ctx.fillRect(-s * 0.06, -s * 0.58, s * 0.12, s * 0.04);
ctx.restore();
}
/** Dark arched holes in the table edges. */
function drawHoles(): void {
for (const hole of holes()) {
ctx.save();
ctx.translate(hole.x, hole.y);
ctx.rotate(hole.inward);
// The flat side sits against the edge, the round side faces the table.
ctx.beginPath();
ctx.arc(-HOLE_INSET, 0, HOLE_SIZE, -Math.PI / 2, Math.PI / 2);
ctx.closePath();
ctx.fillStyle = '#4a2c17';
ctx.fill();
ctx.beginPath();
ctx.arc(-HOLE_INSET, 0, HOLE_SIZE * 0.8, -Math.PI / 2, Math.PI / 2);
ctx.closePath();
ctx.fillStyle = '#120a05';
ctx.fill();
ctx.restore();
}
}
function drawSplats(): void {
for (const splat of splats) {
ctx.fillStyle = '#9e0f12';
for (const blob of splat.blobs) {
ctx.beginPath();
ctx.arc(splat.x + blob.x, splat.y + blob.y, blob.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.beginPath();
ctx.arc(splat.x - 0.1, splat.y - 0.1, 0.08, 0, Math.PI * 2);
ctx.fill();
}
}
/** A mouse seen from above, nose pointing the way it runs. */
function drawMouse(mouse: Mouse): void {
ctx.save();
ctx.translate(mouse.x, mouse.y);
ctx.rotate(mouse.heading);
ctx.lineWidth = 0.03;
ctx.strokeStyle = '#3a3533';
// Wiggly tail.
ctx.beginPath();
ctx.moveTo(-0.25, 0);
const sway = Math.sin(mouse.wiggle) * 0.12;
ctx.quadraticCurveTo(-0.45, sway, -0.65, -sway);
ctx.strokeStyle = '#d99a9a';
ctx.lineWidth = 0.04;
ctx.stroke();
// Body.
ctx.fillStyle = '#8d8682';
ctx.strokeStyle = '#3a3533';
ctx.lineWidth = 0.03;
ctx.beginPath();
ctx.ellipse(0, 0, 0.28, 0.17, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Ears.
for (const side of [-1, 1]) {
ctx.fillStyle = '#e8a5a5';
ctx.beginPath();
ctx.arc(0.15, side * 0.12, 0.07, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Head and pink nose.
ctx.fillStyle = '#8d8682';
ctx.beginPath();
ctx.ellipse(0.27, 0, 0.12, 0.09, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f07a8a';
ctx.beginPath();
ctx.arc(0.39, 0, 0.03, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawBullets(): void {
for (const bullet of bullets) {
if (bullet.rocket) {
drawRocket(bullet);
continue;
}
ctx.fillStyle = '#ffd34d';
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, 0.06, 0, Math.PI * 2);
ctx.fill();
}
}
/** A little rocket with a flickering flame out the back. */
function drawRocket(rocket: Bullet): void {
ctx.save();
ctx.translate(rocket.x, rocket.y);
ctx.rotate(Math.atan2(rocket.dy, rocket.dx));
ctx.scale(1.7, 1.7);
ctx.fillStyle = Math.random() < 0.5 ? '#ffb02e' : '#ff5a1f';
ctx.beginPath();
ctx.moveTo(-0.15, -0.06);
ctx.lineTo(-0.3 - Math.random() * 0.15, 0);
ctx.lineTo(-0.15, 0.06);
ctx.fill();
ctx.fillStyle = '#5d6b3a';
ctx.fillRect(-0.15, -0.06, 0.25, 0.12);
ctx.fillStyle = '#c0392b';
ctx.beginPath();
ctx.moveTo(0.1, -0.06);
ctx.lineTo(0.2, 0);
ctx.lineTo(0.1, 0.06);
ctx.fill();
ctx.restore();
}
/** Black burn marks where rockets went off. */
function drawScorches(): void {
for (const scorch of scorches) {
for (const blob of scorch.blobs) {
const x = scorch.x + blob.x;
const y = scorch.y + blob.y;
const g = ctx.createRadialGradient(x, y, 0, x, y, blob.r);
g.addColorStop(0, 'rgba(20,12,6,0.6)');
g.addColorStop(1, 'rgba(20,12,6,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, blob.r, 0, Math.PI * 2);
ctx.fill();
}
}
}
/** Fire color for a puff: white-hot, then yellow, orange, red, and finally dark smoke. */
function puffColor(heat: number): [number, number, number] {
const stops: [number, [number, number, number]][] = [
[1, [255, 250, 220]],
[0.8, [255, 210, 80]],
[0.6, [255, 130, 30]],
[0.4, [190, 50, 20]],
[0.2, [70, 60, 55]],
[0, [40, 38, 36]],
];
for (let i = 1; i < stops.length; i++) {
const [h1, c1] = stops[i - 1];
const [h2, c2] = stops[i];
if (heat >= h2) {
const k = (heat - h2) / (h1 - h2);
return [0, 1, 2].map((j) => Math.round(c2[j] + (c1[j] - c2[j]) * k)) as [number, number, number];
}
}
return stops[stops.length - 1][1];
}
/** Explosions: a flash, a shock ring, flying sparks, and puffs of fire that turn into smoke. */
function drawBlasts(): void {
for (const blast of blasts) {
const t = blast.age;
// Bright flash right at the start.
if (t < 0.12) {
const flash = 1 - t / 0.12;
const g = ctx.createRadialGradient(blast.x, blast.y, 0, blast.x, blast.y, BLAST_RADIUS * 1.6);
g.addColorStop(0, `rgba(255,255,230,${flash})`);
g.addColorStop(1, 'rgba(255,200,120,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(blast.x, blast.y, BLAST_RADIUS * 1.6, 0, Math.PI * 2);
ctx.fill();
}
// Shock ring racing outward.
if (t < 0.3) {
const k = t / 0.3;
ctx.strokeStyle = `rgba(255,240,210,${0.7 * (1 - k)})`;
ctx.lineWidth = 0.12 * (1 - k) + 0.02;
ctx.beginPath();
ctx.arc(blast.x, blast.y, BLAST_RADIUS * (0.3 + 0.9 * k), 0, Math.PI * 2);
ctx.stroke();
}
// Smoke and fire puffs. Drawn oldest-looking (smoke) first so fire stays on top.
for (const puff of blast.puffs) {
const age = t - puff.delay;
if (age < 0) continue;
const life = age / (BLAST_TIME - puff.delay);
if (life >= 1) continue;
const heat = Math.max(0, 1 - life * 1.8 - puff.size * 0.1);
const [r, g, b] = puffColor(heat);
const radius = puff.size * (0.5 + 1.1 * Math.sqrt(life));
const alpha = (heat > 0.2 ? 0.95 : 0.6) * (1 - life);
const x = blast.x + puff.x;
const y = blast.y + puff.y;
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
grad.addColorStop(0, `rgba(${r},${g},${b},${alpha})`);
grad.addColorStop(0.6, `rgba(${r},${g},${b},${alpha * 0.7})`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
// Sparks: short glowing streaks flying out.
ctx.lineCap = 'round';
for (const spark of blast.sparks) {
if (t > spark.life) continue;
const k = t / spark.life;
const x = blast.x + spark.vx * t;
const y = blast.y + spark.vy * t;
ctx.strokeStyle = `rgba(255,${Math.round(220 - 120 * k)},80,${1 - k})`;
ctx.lineWidth = 0.05;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - spark.vx * 0.04, y - spark.vy * 0.04);
ctx.stroke();
}
ctx.lineCap = 'butt';
}
}
function drawRoundOver(w: number, h: number): void {
const big = Math.max(18, Math.min(w, h) * 0.07);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(0,0,0,0.45)';
const won = level >= LEVELS;
const newGun = won ? undefined : WEAPONS.find((gun) => GUNS[gun].unlock === level + 1);
ctx.fillRect(0, h * 0.3, w, big * (newGun ? 4.3 : 3));
ctx.fillStyle = '#f4e3c1';
ctx.font = `bold ${big}px sans-serif`;
ctx.fillText(won ? `You beat all ${LEVELS} levels!` : `Level ${level} done!`, w / 2, h * 0.3 + big);
ctx.font = `${big * 0.5}px sans-serif`;
ctx.fillText(won ? 'Shoot to play again' : `Shoot to start level ${level + 1}`, w / 2, h * 0.3 + big * 2.1);
if (newGun) {
ctx.fillStyle = '#ffd34d';
ctx.font = `bold ${big * 0.55}px sans-serif`;
ctx.fillText(`You unlocked the ${GUNS[newGun].name.toUpperCase()}!`, w / 2, h * 0.3 + big * 3.6);
}
}
/** Level and mice left, in the top left corner. */
function drawLevel(w: number, h: number): void {
const text = Math.max(14, Math.min(w, h) * 0.04);
const pad = text * 0.6;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.font = `bold ${text}px sans-serif`;
const left = miceStillToCome + mice.length;
const reloading = reloadLeft[weapon] > 0 || beamLeft > 0;
const gun = GUNS[weapon];
const lines = [
`Level ${level}/${LEVELS}`,
`Mice left: ${left}`,
beamLeft > 0
? 'Firing beam!'
: reloading
? `Reloading: ${Math.ceil(reloadLeft[weapon])}s`
: `Ready to shoot!${gun.shots > 1 ? ` (${ammo[weapon]}/${gun.shots})` : ''}`,
];
lines.push(`Gun: ${gun.name}`);
const boxW = Math.max(...lines.map((line) => ctx.measureText(line).width)) + pad * 2;
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath();
ctx.roundRect(pad * 0.5, pad * 0.5, boxW, text * 1.3 * lines.length + pad, pad * 0.6);
ctx.fill();
lines.forEach((line, i) => {
ctx.fillStyle = i === 2 ? (reloading ? '#ff8a6b' : '#9be37a') : '#f4e3c1';
ctx.fillText(line, pad * 1.5, pad + i * text * 1.3);
});
}
// ---------- game loop ----------
let last = performance.now();
function frame(now: number): void {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const w = canvas.width;
const h = canvas.height;
const size = Math.min(w, h) * 0.12;
tableHalfW = w / 2 / size;
tableHalfH = h / 2 / size;
const controllers = pad.poll();
const players = controllers.status === 'ready' ? controllers.players : [];
if (players.some((player) => player.actions.pause?.pressed)) togglePause();
if (paused) {
// On the menu, A or Start picks the highlighted button.
if (players.some((player) => player.actions.shoot?.pressed) && menu.contains(document.activeElement)) {
(document.activeElement as HTMLButtonElement).click();
}
shootQueued = false;
gunSpin = 0;
last = now;
drawScene(w, h, size);
requestAnimationFrame(frame);
return;
}
if (players.some((player) => player.actions.shoot?.pressed)) shootQueued = true;
const wantSpin = turnInput(now, players) * TURN_SPEED;
gunSpin += (wantSpin - gunSpin) * Math.min(1, SPIN_EASE * dt);
gunAngle += gunSpin * dt;
if (Math.abs(gunAngle) > MAX_TURN) {
gunAngle = Math.sign(gunAngle) * MAX_TURN;
gunSpin = 0;
}
updateWorld(dt);
drawScene(w, h, size);
requestAnimationFrame(frame);
}
function drawScene(w: number, h: number, size: number): void {
// Shake everything for a moment after an explosion.
ctx.save();
if (shakeLeft > 0) {
const shake = size * 0.15 * (shakeLeft / SHAKE_TIME);
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
}
drawTable(w, h);
// Splats, mice and bullets are drawn in gun sizes around the middle of the table.
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.scale(size, size);
drawHoles();
drawScorches();
drawSplats();
for (const mouse of mice) if (mouse.state !== 'hiding') drawMouse(mouse);
drawBullets();
drawBlasts();
drawBeam();
ctx.restore();
drawGun(w / 2, h / 2 + gunY() * size, size);
ctx.restore();
drawLevel(w, h);
if (roundOver() && !paused) drawRoundOver(w, h);
}
requestAnimationFrame(frame);
showMenu();
