diff --git a/README.md b/README.md index f078a9a..8ceea6f 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver | | `moshcode site`
`serve` | hosting | install web-server config for a Moshpit name | | `moshcode template`
`templates` | hosting | scaffold a stack for a Moshpit-hosted service | -| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — eight games, no menus | +| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus | | `moshcode pwd`
`where` | system | show the current directory and git context | | `moshcode engines` | engines | list engines and installation status | | `moshcode tools` | tools | list workflow tools and installation status | @@ -584,7 +584,7 @@ the composer URL instead. ## The arcade (`/games`) -Eight games, in the pit or straight from a shell. There are no menus, no options +Twenty-two games, in the pit or straight from a shell. There are no menus, no options screens and no difficulty prompts — `/games tetris` is already playing. ```sh @@ -611,7 +611,21 @@ moshcode games --json # the roster, for a machine | `tetris` | stack the bricks, clear the lines, outrun gravity | | `snake` | eat, grow, and try not to eat yourself | | `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible | +| `invaders` | forty of them, and the last one moves fastest | +| `centipede` | shoot it in the middle and now there are two of them | | `asteroids` | turn, thrust, shoot — every rock you break becomes two | +| `breakout` | dig a channel up the side and let the ball do the rest | +| `pong` | first to seven, and the angle is all in where you hit it | +| `tank` | two tanks, one yard, five hits — line it up and let go | +| `digdug` | dig the tunnels, pump the monsters, drop rocks on the rest | +| `frogger` | the road kills what it touches, the river kills what it doesn't | +| `kong` | five girders, four ladders, and a barrel with your name on it | +| `pitfall` | jump the logs, swing the pits, and get the gold before dark | +| `choplifter` | fly out, land, fill the back, and get them home | +| `spyhunter` | keep it on the tarmac, shoot the ones shooting back | +| `outrun` | a road that bends, traffic that doesn't, and a clock that always wins | +| `excitebike` | turbo until it cooks, and land the way you took off | +| `stagedive` | run the barricade, hop the gear, duck the crowd, take the picks | | `tictactoe` | three in a row against an opponent that cannot be beaten | | `blackjack` | hit, stand, double, split — dealer stands on 17, and pays 3:2 | | `chess` | full rules — castling, en passant, promotion — and it plays back | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 7fae237..7359a58 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -396,7 +396,7 @@ export const CORE_CLI_COMMANDS = [ { name: "games", group: "arcade", - description: "the moshcode arcade — eight games, no menus", + description: "the moshcode arcade — twenty-two games, no menus", synopsis: [ ["moshcode games", "the cabinet, and what each one is"], ["moshcode games ", "play it, right here in the terminal"], @@ -409,6 +409,8 @@ export const CORE_CLI_COMMANDS = [ ["moshcode games pacman", "dots, ghosts, three lives"], ["moshcode games asteroids", "turn, thrust, shoot"], ["moshcode games 21", "blackjack, 100 chips, 3:2"], + ["moshcode games invaders", "forty of them, coming down"], + ["moshcode games stagedive", "jump the gear, take the picks"], ], seeAlso: ["help"], note: "every game works the same way: arrows move, q quits, r starts another. " @@ -932,7 +934,7 @@ export const PIT_COMMANDS = [ { name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin", description: "install moshcode's slash commands into Claude Code" }, { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games", - description: "the arcade — tetris, snake, pac-man, asteroids, blackjack, chess and more" }, + description: "the arcade — tetris, invaders, pac-man, frogger, kong, outrun, chess and more" }, { name: "socials", aliases: ["social"], pitOnly: true, description: "list social networks available for posting" }, { name: "post", args: ' "message"', pitOnly: true, diff --git a/src/games-breakout.mjs b/src/games-breakout.mjs new file mode 100644 index 0000000..798e21b --- /dev/null +++ b/src/games-breakout.mjs @@ -0,0 +1,186 @@ +// Breakout. A wall, a paddle, and one ball that is always your fault. +// +// The bounce off the paddle is not a mirror: where the ball lands on the paddle +// decides the angle it leaves at, so the paddle is a steering wheel rather than +// a wall. Without that you cannot dig a channel up the side of the wall, and +// digging a channel is the entire reason anybody still plays this. +import { acid, amber, bone, danger, rgb } from "./ui.mjs"; + +export const WIDTH = 40; +export const HEIGHT = 17; + +export const BRICK_W = 4; +export const BRICK_COLS = WIDTH / BRICK_W; // 10 +export const BRICK_ROWS = 5; +export const BRICK_TOP = 1; + +export const PADDLE_W = 7; +export const PADDLE_ROW = HEIGHT - 1; +const PADDLE_STEP = 2; + +const LIVES = 3; +const BASE_VX = 0.62; +const BASE_VY = 0.34; // rows per tick — half of vx, because a row is two columns +const SPIN = 0.5; +const LEVEL_UP = 1.12; + +/** Top rows are worth more, which is what makes the ball worth risking. */ +export const ROW_POINTS = [50, 40, 30, 20, 10]; +const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)]; + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +/** A full wall: every brick standing. */ +export const buildWall = () => Array.from({ length: BRICK_ROWS }, () => Array.from({ length: BRICK_COLS }, () => true)); + +export const bricksLeft = (wall) => wall.reduce((n, row) => n + row.filter(Boolean).length, 0); + +/** The brick under a cell, or null. */ +export function brickAt(wall, x, y) { + const row = y - BRICK_TOP; + if (row < 0 || row >= BRICK_ROWS) return null; + const col = Math.floor(x / BRICK_W); + if (col < 0 || col >= BRICK_COLS || !wall[row]?.[col]) return null; + return { row, col }; +} + +/** The ball sitting on the paddle, waiting for space. */ +function rest(state) { + state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 }; + state.stuck = true; + return state; +} + +export function launch(state) { + if (!state.stuck) return state; + state.stuck = false; + state.ball.vx = (state.rng() < 0.5 ? -1 : 1) * BASE_VX * state.pace; + state.ball.vy = -BASE_VY * state.pace; + return state; +} + +/** One tick. Exported so a test can clear a whole wall with no clock. */ +export function step(state) { + if (state.stuck) { + // A ball that has not been launched rides the paddle, so moving before you + // serve aims the serve. + state.ball.x = state.paddle + PADDLE_W / 2; + return state; + } + + const ball = state.ball; + const wasCol = Math.round(ball.x); + const wasRow = Math.round(ball.y); + ball.x += ball.vx; + ball.y += ball.vy; + + if (ball.x < 0) { ball.x = -ball.x; ball.vx = Math.abs(ball.vx); } + if (ball.x > WIDTH - 1) { ball.x = 2 * (WIDTH - 1) - ball.x; ball.vx = -Math.abs(ball.vx); } + if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); } + + const col = Math.round(ball.x); + const row = Math.round(ball.y); + const brick = brickAt(state.wall, col, row); + if (brick) { + state.wall[brick.row][brick.col] = false; + state.score += ROW_POINTS[brick.row]; + // Which way it bounces depends on which way it came in: through a row means + // the ball flips vertically, along a row means it flips sideways. + if (row !== wasRow) ball.vy = -ball.vy; + else if (col !== wasCol) ball.vx = -ball.vx; + else ball.vy = -ball.vy; + if (!bricksLeft(state.wall)) return cleared(state); + } + + if (ball.vy > 0 && ball.y >= PADDLE_ROW - 1) { + const off = ball.x - (state.paddle + (PADDLE_W - 1) / 2); + if (Math.abs(off) <= PADDLE_W / 2 + 0.5) { + ball.y = PADDLE_ROW - 1; + ball.vy = -Math.abs(ball.vy); + // The steering wheel: the further out you take it, the flatter it leaves. + ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -1.4, 1.4); + if (Math.abs(ball.vx) < 0.15) ball.vx = ball.vx < 0 ? -0.15 : 0.15; + } + } + + if (ball.y > PADDLE_ROW) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `out of balls · ${state.score} points`; + return state; + } + rest(state); + } + return state; +} + +function cleared(state) { + state.level++; + state.pace *= LEVEL_UP; + state.wall = buildWall(); + state.score += 100; + return rest(state); +} + +export const BREAKOUT = { + key: "breakout", + aliases: ["arkanoid", "wall"], + title: "BREAKOUT", + blurb: "dig a channel up the side and let the ball do the rest", + keys: "← → paddle · space launch · q quit", + tickMs: 50, + + create({ rng = Math.random } = {}) { + const state = { + wall: buildWall(), + paddle: Math.floor((WIDTH - PADDLE_W) / 2), + score: 0, + lives: LIVES, + level: 1, + pace: 1, + over: null, + rng, + }; + return rest(state); + }, + + tick: step, + + onKey(state, key) { + if (key === "left") state.paddle = clamp(state.paddle - PADDLE_STEP, 0, WIDTH - PADDLE_W); + else if (key === "right") state.paddle = clamp(state.paddle + PADDLE_STEP, 0, WIDTH - PADDLE_W); + else if (key === "space" || key === "up" || key === "enter") launch(state); + return state; + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · level ${state.level} · ${"●".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (let row = 0; row < BRICK_ROWS; row++) { + for (let col = 0; col < BRICK_COLS; col++) { + if (!state.wall[row][col]) continue; + // A brick is drawn exactly as wide as it is hit, with a seam so the wall + // reads as bricks rather than as one solid slab. + for (let i = 0; i < BRICK_W; i++) { + put(col * BRICK_W + i, BRICK_TOP + row, ROW_COLOR[row](i === BRICK_W - 1 ? "▓" : "█")); + } + } + } + + for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀")); + put(Math.round(state.ball.x), Math.round(state.ball.y), state.stuck ? amber("●") : bone("●")); + + return grid.map((row) => row.map((cell) => cell ?? " ").join("")); + }, +}; diff --git a/src/games-centipede.mjs b/src/games-centipede.mjs new file mode 100644 index 0000000..2bd73a1 --- /dev/null +++ b/src/games-centipede.mjs @@ -0,0 +1,241 @@ +// Centipede. It comes down through the mushrooms, and every piece you shoot out +// of the middle leaves you two of them. +// +// The trick that makes this cheap: the centipede is not a linked body, it is a +// list of segments that each obey the same rule — walk sideways, and when +// something is in the way, drop a row and turn round. Kept that way, a shot to +// the middle needs no surgery at all. You remove one segment and the ones +// behind it simply carry on, which is exactly what splitting looks like. +import { acid, amber, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 38; +export const HEIGHT = 18; + +/** The bottom strip is yours; the centipede comes down into it. */ +export const ZONE_TOP = HEIGHT - 5; +const MUSHROOM_HP = 4; +const LIVES = 3; +const SHOTS = 3; +const SHOT_SPEED = 2; + +const shroom = rgb(120, 190, 40); +const MUSH_ART = ["▁", "▄", "▆", "█"]; // fuller the healthier + +const key = (x, y) => `${x},${y}`; + +/** A field of mushrooms, none of them in the row you stand on. */ +export function seedField(rng, count = 34) { + const field = new Map(); + for (let i = 0; i < count; i++) { + const x = Math.floor(rng() * WIDTH); + const y = 1 + Math.floor(rng() * (HEIGHT - 3)); + if (y >= HEIGHT - 1) continue; + field.set(key(x, y), MUSHROOM_HP); + } + return field; +} + +/** Chip a mushroom, and say whether one was there. A dead one is worth points. */ +export function bite(field, x, y) { + const hp = field.get(key(x, y)); + if (!hp) return 0; + if (hp <= 1) { field.delete(key(x, y)); return 5; } + field.set(key(x, y), hp - 1); + return 1; +} + +/** A fresh centipede, strung out along the top row. */ +export function newCentipede(length = 10) { + return Array.from({ length }, (_, i) => ({ x: length - 1 - i, y: 0, dir: 1, down: 1 })); +} + +const blocked = (state, x, y) => x < 0 || x >= WIDTH || state.field.has(key(x, y)); + +/** Walk one segment: sideways if it can, otherwise down a row and about turn. */ +export function walk(state, seg) { + if (!blocked(state, seg.x + seg.dir, seg.y)) { seg.x += seg.dir; return seg; } + seg.dir *= -1; + seg.y += seg.down; + // It bounces off the floor and climbs back up rather than vanishing, which is + // what keeps the bottom of the board dangerous instead of a safe corner. + if (seg.y >= HEIGHT - 1) { seg.y = HEIGHT - 1; seg.down = -1; } + if (seg.y <= 0) { seg.y = 0; seg.down = 1; } + return seg; +} + +/** How many ticks between steps — shorter as the wave goes on. */ +export const cadence = (state) => Math.max(2, 7 - state.wave); + +/** + * The spider: in from the side, bouncing diagonally through your strip, eating + * mushrooms as it goes. + * + * It is here because without it the bottom of the board is safe. The centipede + * only reaches your row on the sweeps it happens to end on, so a player who + * simply never moves can survive a long time — which is not a game. The spider + * is the reason you cannot stand still. + */ +export function spiderStep(state) { + const spider = state.spider; + if (!spider) return state; + spider.x += spider.dx; + spider.y += spider.dy; + if (spider.y < ZONE_TOP) { spider.y = ZONE_TOP; spider.dy = 1; } + if (spider.y > HEIGHT - 1) { spider.y = HEIGHT - 1; spider.dy = -1; } + // It leaves the way it came in rather than turning round at the wall, so it + // is a visitor and not a permanent resident. + if (spider.x < 0 || spider.x >= WIDTH) { state.spider = null; return state; } + state.field.delete(key(spider.x, spider.y)); + return state; +} + +export function spawnSpider(state) { + const fromLeft = state.rng() < 0.5; + state.spider = { + x: fromLeft ? 0 : WIDTH - 1, + y: HEIGHT - 1 - Math.floor(state.rng() * 3), + dx: fromLeft ? 1 : -1, + dy: state.rng() < 0.5 ? -1 : 1, + }; + return state; +} + +function hitPlayer(state) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `eaten · ${state.score} points`; + return state; + } + state.centipede = newCentipede(10); + state.shots = []; + state.spider = null; + state.player = { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 }; + return state; +} + +/** One tick. Exported so a test can clear a wave with no clock. */ +export function step(state) { + for (let i = 0; i < SHOT_SPEED; i++) { + for (const shot of [...state.shots]) { + shot.y -= 1; + if (shot.y < 0) { state.shots = state.shots.filter((s) => s !== shot); continue; } + const points = bite(state.field, shot.x, shot.y); + if (points) { state.score += points; state.shots = state.shots.filter((s) => s !== shot); continue; } + if (state.spider && state.spider.x === shot.x && state.spider.y === shot.y) { + state.spider = null; + state.shots = state.shots.filter((s) => s !== shot); + state.score += 300; + continue; + } + const seg = state.centipede.find((s) => s.x === shot.x && s.y === shot.y); + if (!seg) continue; + state.shots = state.shots.filter((s) => s !== shot); + state.centipede = state.centipede.filter((s) => s !== seg); + state.score += 10; + // Every piece you take out of it leaves a mushroom where it fell, which + // is how the field thickens and the next wave gets harder for free. + state.field.set(key(seg.x, seg.y), MUSHROOM_HP); + } + } + + if (!state.centipede.length) { + state.wave++; + state.centipede = newCentipede(10); + state.score += 100; + return state; + } + + state.clock++; + if (state.clock >= cadence(state)) { + state.clock = 0; + for (const seg of state.centipede) walk(state, seg); + if (state.centipede.some((s) => s.x === state.player.x && s.y === state.player.y)) return hitPlayer(state); + } + + state.spiderClock++; + if (state.spiderClock % 3 === 0) { + spiderStep(state); + const spider = state.spider; + if (spider && spider.x === state.player.x && spider.y === state.player.y) return hitPlayer(state); + } + if (!state.spider && state.rng() < 0.02) spawnSpider(state); + return state; +} + +export const CENTIPEDE = { + key: "centipede", + aliases: ["cent", "bug", "millipede"], + title: "CENTIPEDE", + blurb: "shoot it in the middle and now there are two of them", + keys: "← ↑ ↓ → move · space fire · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + field: seedField(rng), + centipede: newCentipede(10), + player: { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 }, + shots: [], + spider: null, + spiderClock: 0, + score: 0, + lives: LIVES, + wave: 1, + clock: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + const p = state.player; + // You are free in the bottom strip and nowhere else — the whole game is + // fought in five rows. + if (pressed === "left") p.x = Math.max(0, p.x - 1); + else if (pressed === "right") p.x = Math.min(WIDTH - 1, p.x + 1); + else if (pressed === "up") p.y = Math.max(ZONE_TOP, p.y - 1); + else if (pressed === "down") p.y = Math.min(HEIGHT - 1, p.y + 1); + else if (pressed === "space" || pressed === "enter") { + if (state.shots.length < SHOTS) state.shots.push({ x: p.x, y: p.y - 1 }); + return state; + } + // Walking into a mushroom is walking into a wall. + if (state.field.has(key(p.x, p.y))) { + if (pressed === "left") p.x += 1; + else if (pressed === "right") p.x -= 1; + else if (pressed === "up") p.y += 1; + else if (pressed === "down") p.y -= 1; + } + return state; + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const [at, hp] of state.field) { + const [x, y] = at.split(",").map(Number); + put(x, y, shroom(MUSH_ART[hp - 1] ?? "▁")); + } + for (const seg of state.centipede) put(seg.x, seg.y, danger("◍")); + if (state.spider) put(state.spider.x, state.spider.y, rgb(190, 130, 255)("✻")); + for (const shot of state.shots) put(shot.x, shot.y, amber("│")); + put(state.player.x, state.player.y, state.over ? danger("✷") : acid("▲")); + + return grid.map((row, y) => row.map((cell) => ( + cell ?? (y === ZONE_TOP - 1 ? dim("┈") : " ") + )).join("")); + }, +}; diff --git a/src/games-choplifter.mjs b/src/games-choplifter.mjs new file mode 100644 index 0000000..c5f09c0 --- /dev/null +++ b/src/games-choplifter.mjs @@ -0,0 +1,179 @@ +// Choplifter. Fly out, land, load them up, fly home. Do it before the tanks +// work out where you are going. +// +// The world is wider than the screen, which is the whole point — the camera +// follows the chopper and the base sits off the left edge, so "get back" is a +// real journey rather than a step. Everything is stored in world columns and +// only turned into screen columns at the very end, in render(). +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 46; // what you can see +export const HEIGHT = 14; +export const WORLD = 150; // how far it actually goes + +export const GROUND = HEIGHT - 2; +export const BASE = 6; // the pad, at the left-hand end of the world +const BASE_W = 7; +export const SEATS = 4; // how many fit in the back +const LIVES = 3; +const SHELL_EVERY = 46; // ticks between a tank taking a shot + +const sand = rgb(200, 170, 110); + +export const onPad = (x) => x >= BASE - 1 && x <= BASE + BASE_W; + +/** Hostages waiting in the desert, and the tanks that would rather they stayed. */ +export function populate(rng) { + const people = []; + for (let i = 0; i < 12; i++) { + people.push({ x: 40 + Math.floor(rng() * (WORLD - 55)), waving: true }); + } + const tanks = []; + for (let i = 0; i < 4; i++) { + tanks.push({ x: 55 + Math.floor(rng() * (WORLD - 70)), dir: rng() < 0.5 ? -1 : 1 }); + } + return { people, tanks }; +} + +function hit(state, why) { + state.lives--; + // Anybody in the back goes down with it. That is the cost of one more pickup. + state.aboard = 0; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.home} home`; + return state; + } + state.chopper = { x: BASE + 2, y: GROUND - 1, vy: 0 }; + state.shells = []; + return state; +} + +/** One tick. Exported so a test can fly a whole rescue with no clock. */ +export function step(state) { + const chop = state.chopper; + chop.x = Math.max(0, Math.min(WORLD - 1, chop.x + state.throttle)); + chop.y = Math.max(1, Math.min(GROUND, chop.y + chop.vy)); + // Both axes drift to a stop rather than stopping dead, so it hovers when you + // let go instead of dropping out of the sky the moment you stop pressing up. + state.throttle *= 0.72; + if (Math.abs(state.throttle) < 0.05) state.throttle = 0; + chop.vy *= 0.72; + if (Math.abs(chop.vy) < 0.05) chop.vy = 0; + + const landed = chop.y >= GROUND; + + if (landed) { + // On the ground at the base: everybody out, and that is the score. + if (onPad(Math.round(chop.x))) { + state.home += state.aboard; + state.score += state.aboard * 100; + state.aboard = 0; + } else { + // Out in the desert: anybody close enough climbs in. + for (const person of state.people) { + if (state.aboard >= SEATS) break; + if (Math.abs(person.x - chop.x) > 2 || person.rescued) continue; + person.rescued = true; + state.aboard++; + } + state.people = state.people.filter((p) => !p.rescued); + } + } + + state.clock++; + for (const tank of state.tanks) { + if (state.clock % 5 === 0) { + tank.x += tank.dir; + if (tank.x < 30 || tank.x > WORLD - 4) tank.dir *= -1; + } + // A tank shoots when you are overhead, and only then — you can outrun them. + if (state.clock % SHELL_EVERY === 0 && Math.abs(tank.x - chop.x) < 12) { + state.shells.push({ x: tank.x, y: GROUND - 1, vy: -0.55 }); + } + } + + for (const shell of state.shells) shell.y += shell.vy; + state.shells = state.shells.filter((s) => s.y > 0); + const struck = state.shells.find((s) => Math.abs(s.x - chop.x) < 2 && Math.abs(s.y - chop.y) < 1); + if (struck) return hit(state, "shot down"); + + const run_over = state.tanks.find((t) => landed && Math.abs(t.x - chop.x) < 2); + if (run_over) return hit(state, "flattened on the ground"); + + if (!state.people.length && !state.aboard) { + state.over = `everyone out · ${state.home} home`; + } + return state; +} + +export const CHOPLIFTER = { + key: "choplifter", + aliases: ["chopper", "rescue", "heli"], + title: "CHOPLIFTER", + blurb: "fly out, land, fill the back, and get them home", + keys: "← → fly · ↑ ↓ climb and land · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + const { people, tanks } = populate(rng); + return { + chopper: { x: BASE + 2, y: GROUND - 1, vy: 0 }, + throttle: 0, + people, + tanks, + shells: [], + aboard: 0, + home: 0, + score: 0, + lives: LIVES, + clock: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + const chop = state.chopper; + if (pressed === "left") state.throttle = Math.max(-1.4, state.throttle - 0.7); + else if (pressed === "right") state.throttle = Math.min(1.4, state.throttle + 0.7); + else if (pressed === "up") chop.vy = Math.max(-0.7, chop.vy - 0.45); + else if (pressed === "down") chop.vy = Math.min(0.7, chop.vy + 0.45); + return state; + }, + + status(state) { + if (state.over) return state.over; + return `${state.home} home · ${state.aboard}/${SEATS} aboard · ${state.people.length} waiting · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + // The camera keeps the chopper in the middle until the world runs out. + const camera = Math.max(0, Math.min(WORLD - WIDTH, Math.round(state.chopper.x) - Math.floor(WIDTH / 2))); + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (worldX, y, glyph) => { + const x = Math.round(worldX) - camera; + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (let i = 0; i < BASE_W; i++) put(BASE + i, GROUND, acid("═")); + for (const person of state.people) put(person.x, GROUND - 1, bone("Ω")); + for (const tank of state.tanks) put(tank.x, GROUND - 1, danger("▙")); + for (const shell of state.shells) put(shell.x, shell.y, amber("•")); + const chop = state.chopper; + put(chop.x, Math.round(chop.y), state.over ? danger("✷") : acid("╤")); + put(chop.x - 1, Math.round(chop.y), state.over ? danger("✷") : ash("─")); + put(chop.x + 1, Math.round(chop.y), state.over ? danger("✷") : ash("─")); + + return grid.map((row, y) => row.map((cell, x) => { + if (cell) return cell; + if (y === GROUND) return sand("▀"); + if (y > GROUND) return sand("░"); + // A horizon marker every ten columns of world, so flying feels like it. + return (x + camera) % 12 === 0 && y === 1 ? dim("│") : " "; + }).join("")); + }, +}; diff --git a/src/games-digdug.mjs b/src/games-digdug.mjs new file mode 100644 index 0000000..da8d20d --- /dev/null +++ b/src/games-digdug.mjs @@ -0,0 +1,267 @@ +// Dig Dug. You are underground, everything down here wants you, and the only +// wall between you and it is the ground you have not dug yet. +// +// The board is the enemy AI. There is no pathfinding: a monster walks the tunnel +// it is in and turns towards you at a junction, so the shape you dig is the +// shape of the fight. Dig a straight line and they queue up behind you; dig a +// loop and they come round both ends of it. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 40; +export const HEIGHT = 16; +export const SKY = 1; // rows above this are open air + +const LIVES = 3; +const HARPOON = 5; // how far the pump reaches +const POPS_AT = 3; // pumps to burst a monster +const MONSTER_EVERY = 9; // ticks between monster steps +const ROCK_EVERY = 3; + +const soil = rgb(150, 100, 60); +const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; + +const key = (x, y) => `${x},${y}`; + +/** Solid ground everywhere below the sky, minus the shafts the level starts with. */ +export function buildGround() { + const ground = new Set(); + for (let y = SKY + 1; y < HEIGHT; y++) for (let x = 0; x < WIDTH; x++) ground.add(key(x, y)); + return ground; +} + +export const isDug = (ground, x, y) => ( + x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT && !ground.has(key(x, y)) +); + +/** Where the monsters and rocks start — spread out, and never on top of you. */ +export function populate(state, level) { + state.monsters = []; + for (let i = 0; i < 3 + Math.min(3, level - 1); i++) { + state.monsters.push({ + x: 6 + Math.floor(state.rng() * (WIDTH - 12)), + y: SKY + 2 + Math.floor(state.rng() * (HEIGHT - SKY - 3)), + dir: state.rng() < 0.5 ? "left" : "right", + pumped: 0, + ghost: 0, + }); + } + state.rocks = []; + for (let i = 0; i < 4; i++) { + state.rocks.push({ + x: 4 + Math.floor(state.rng() * (WIDTH - 8)), + y: SKY + 2 + Math.floor(state.rng() * 5), + falling: false, + }); + } + // Every monster and rock starts buried, so the board opens up only where you + // dig it. + for (const thing of [...state.monsters, ...state.rocks]) state.ground.delete(key(thing.x, thing.y)); + return state; +} + +function lose(state, why) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.score} points`; + return state; + } + state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" }; + state.harpoon = null; + state.ground.delete(key(state.player.x, state.player.y)); + return state; +} + +/** A rock with nothing under it falls, and takes anything under it with it. */ +export function fallRocks(state) { + for (const rock of state.rocks) { + const below = { x: rock.x, y: rock.y + 1 }; + if (!rock.falling && !isDug(state.ground, below.x, below.y)) continue; + if (below.y >= HEIGHT) { rock.falling = false; continue; } + rock.falling = true; + rock.y += 1; + state.ground.delete(key(rock.x, rock.y)); + const squashed = state.monsters.filter((m) => m.x === rock.x && m.y === rock.y); + if (squashed.length) { + state.monsters = state.monsters.filter((m) => !squashed.includes(m)); + state.score += squashed.length * 200; + } + if (state.player.x === rock.x && state.player.y === rock.y) return lose(state, "under a rock"); + } + return state; +} + +/** One monster step: down its own tunnel, turning towards you at a junction. */ +export function walkMonster(state, monster) { + if (monster.pumped) return monster; // a hooked monster is going nowhere + const player = state.player; + + // Once in a while one gives up on the tunnels and comes straight through the + // ground at you. Without it, a player who digs one deep hole is untouchable. + if (monster.ghost > 0) { + monster.ghost--; + monster.x += Math.sign(player.x - monster.x); + if (monster.x === player.x) monster.y += Math.sign(player.y - monster.y); + return monster; + } + if (state.rng() < 0.02) { monster.ghost = 6; return monster; } + + const options = Object.entries(DIRS) + .filter(([, [dx, dy]]) => isDug(state.ground, monster.x + dx, monster.y + dy)) + .sort(([, a], [, b]) => { + const da = Math.abs(monster.x + a[0] - player.x) + Math.abs(monster.y + a[1] - player.y); + const db = Math.abs(monster.x + b[0] - player.x) + Math.abs(monster.y + b[1] - player.y); + return da - db; + }); + if (!options.length) return monster; + // It prefers the way it is already going when that is no worse, so it does not + // jitter on the spot at a crossroads. + const [name, [dx, dy]] = options[0]; + monster.dir = name; + monster.x += dx; + monster.y += dy; + return monster; +} + +/** One tick. Exported so a test can clear a level with no clock. */ +export function step(state) { + state.clock++; + + if (state.clock % ROCK_EVERY === 0) { + fallRocks(state); + if (state.over) return state; + } + + if (state.harpoon) { + // The harpoon holds whatever it caught; it is the pump that does the work. + const hooked = state.monsters.find((m) => m === state.harpoon.on); + if (!hooked) state.harpoon = null; + } + + if (state.clock % MONSTER_EVERY === 0) { + for (const monster of state.monsters) walkMonster(state, monster); + const caught = state.monsters.find((m) => m.x === state.player.x && m.y === state.player.y); + if (caught) return lose(state, "caught underground"); + } + + if (!state.monsters.length) { + state.level++; + state.ground = buildGround(); + state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" }; + state.ground.delete(key(state.player.x, state.player.y)); + state.score += 500; + populate(state, state.level); + } + return state; +} + +/** Fire the harpoon down the tunnel you are facing, or pump what is on it. */ +export function pump(state) { + if (state.harpoon) { + const monster = state.harpoon.on; + monster.pumped++; + if (monster.pumped >= POPS_AT) { + state.monsters = state.monsters.filter((m) => m !== monster); + state.score += 100 * POPS_AT; + state.harpoon = null; + } + return state; + } + const [dx, dy] = DIRS[state.player.dir]; + for (let i = 1; i <= HARPOON; i++) { + const x = state.player.x + dx * i; + const y = state.player.y + dy * i; + if (!isDug(state.ground, x, y)) break; // it does not go through dirt + const monster = state.monsters.find((m) => m.x === x && m.y === y); + if (monster) { + state.harpoon = { on: monster, x, y }; + monster.pumped = 1; + return state; + } + } + return state; +} + +export const DIGDUG = { + key: "digdug", + aliases: ["dig", "pooka"], + title: "DIG DUG", + blurb: "dig the tunnels, pump the monsters, drop rocks on the rest", + keys: "← ↑ ↓ → dig · space pump · q quit", + tickMs: 60, + + create({ rng = Math.random } = {}) { + const state = { + ground: buildGround(), + player: { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" }, + monsters: [], + rocks: [], + harpoon: null, + score: 0, + lives: LIVES, + level: 1, + clock: 0, + over: null, + rng, + }; + state.ground.delete(key(state.player.x, state.player.y)); + return populate(state, 1); + }, + + tick: step, + + onKey(state, pressed) { + if (pressed === "space" || pressed === "enter") return pump(state); + const move = DIRS[pressed]; + if (!move) return state; + // Moving is digging: the tunnel is wherever you have been. + state.harpoon = null; + state.player.dir = pressed; + const x = state.player.x + move[0]; + const y = state.player.y + move[1]; + if (x < 0 || x >= WIDTH || y <= SKY || y >= HEIGHT) return state; + if (state.rocks.some((r) => r.x === x && r.y === y && !r.falling)) return state; + if (state.ground.delete(key(x, y))) state.score += 1; + state.player.x = x; + state.player.y = y; + return state; + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · level ${state.level} · ${state.monsters.length} left · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, (_, y) => Array.from({ length: WIDTH }, (_, x) => ( + state.ground.has(key(x, y)) ? soil("▒") : null + ))); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const rock of state.rocks) put(rock.x, rock.y, ash("▣")); + for (const monster of state.monsters) { + // A monster part-way through being pumped is visibly bigger, which is the + // only feedback the pump gives you. + const art = monster.pumped >= 2 ? "◯" : monster.pumped ? "◎" : "◉"; + put(monster.x, monster.y, (monster.ghost ? amber : danger)(art)); + } + if (state.harpoon) { + const [dx, dy] = DIRS[state.player.dir]; + for (let i = 1; i < HARPOON; i++) { + const x = state.player.x + dx * i; + const y = state.player.y + dy * i; + if (x === state.harpoon.x && y === state.harpoon.y) break; + put(x, y, bone(dx ? "─" : "│")); + } + } + put(state.player.x, state.player.y, state.over ? danger("✷") : acid("◈")); + + return grid.map((row, y) => row.map((cell) => ( + cell ?? (y <= SKY ? dim("·") : " ") + )).join("")); + }, +}; diff --git a/src/games-excitebike.mjs b/src/games-excitebike.mjs new file mode 100644 index 0000000..89b8581 --- /dev/null +++ b/src/games-excitebike.mjs @@ -0,0 +1,206 @@ +// Excitebike. The throttle is not the interesting part — the temperature gauge +// is, and so is what your front wheel is doing when you land. +// +// Two things make this game rather than a side-scroller with ramps. Turbo is +// free until it isn't: heat climbs while you hold it and the engine seizes at +// the top of the gauge, so the fast lap is the one that cools off in the right +// places. And a jump is only as good as its landing: you pitch the bike in the +// air, and coming down nose-first puts you over the handlebars. +import { acid, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 46; +export const HEIGHT = 12; + +export const GROUND = HEIGHT - 3; +export const RIDER = 8; // your column; the track comes to you + +const BASE_SPEED = 0.35; +const MAX_SPEED = 1.5; +const TURBO = 0.55; // extra columns per tick while it is held +const HEAT_UP = 1.6; +const HEAT_DOWN = 0.8; +export const SEIZE_TICKS = 70; // how long a cooked engine costs you +const PITCH_LIMIT = 2.2; +// The nose drops on its own all the way down. Without this the pitch you leave +// the ramp with is the pitch you land on, and the landing — the whole second +// half of this game — is something you can simply ignore. +const PITCH_DROP = 0.075; +export const LAND_OK = 1.1; // how far from level you may land +const CRASH_TICKS = 45; +export const FINISH = 900; // columns of track in a race +const TIME = 2600; + +const dirt = rgb(170, 130, 90); + +/** A ramp is three columns of take-off; hit one moving and you are airborne. */ +export const RAMP_W = 3; + +export function spawn(state) { + const { rng } = state; + state.things.push({ kind: "ramp", x: WIDTH + 2 }); + state.next = 18 + rng() * 22; + return state; +} + +export const rampSpan = (thing) => { + const x = Math.round(thing.x); + return [x, x + RAMP_W - 1]; +}; + +/** Speed right now, with everything that is holding it back applied. */ +export function speedOf(state) { + if (state.crash > 0) return 0; + if (state.seized > 0) return BASE_SPEED * 0.4; + return Math.min(MAX_SPEED, state.throttle + (state.turbo ? TURBO : 0)); +} + +function crash(state, why) { + state.crash = CRASH_TICKS; + state.air = 0; + state.pitch = 0; + state.throttle = BASE_SPEED; + state.spills++; + state.last = why; + return state; +} + +/** One tick of track. Exported so a test can ride a whole race with no clock. */ +export function step(state) { + state.clock++; + if (state.clock >= TIME) { + state.over = `out of time · ${Math.round(state.dist)} of ${FINISH}`; + return state; + } + if (state.crash > 0) { state.crash--; return state; } + + // The gauge. Turbo is a loan, and this is where it is called in. + if (state.turbo && !state.seized) state.heat = Math.min(100, state.heat + HEAT_UP); + else state.heat = Math.max(0, state.heat - HEAT_DOWN); + if (state.heat >= 100 && !state.seized) { state.seized = SEIZE_TICKS; state.turbo = false; } + if (state.seized > 0) { state.seized--; if (!state.seized) state.heat = 40; } + + const speed = speedOf(state); + state.dist += speed; + for (const thing of state.things) thing.x -= speed; + state.things = state.things.filter((t) => rampSpan(t)[1] > -3); + state.next -= speed; + if (state.next <= 0) spawn(state); + + if (state.air > 0) { + state.air--; + state.pitch = Math.min(PITCH_LIMIT, state.pitch + PITCH_DROP); + if (!state.air) { + // Landing: level enough is a landing, anything else is a tumble. + if (Math.abs(state.pitch) > LAND_OK) return crash(state, "over the handlebars"); + // A flat landing carries the speed; a wobbly one scrubs some off. + state.throttle = Math.min(MAX_SPEED, state.throttle + (Math.abs(state.pitch) < 0.4 ? 0.12 : -0.1)); + state.score += 50; + state.pitch = 0; + } + } else { + const ramp = state.things.find((t) => { + const [from, to] = rampSpan(t); + return RIDER >= from && RIDER <= to; + }); + if (ramp && !ramp.used) { + ramp.used = true; + // The faster you hit it, the longer you are in the air — and the more time + // you have to get the pitch wrong. + state.air = Math.round(10 + speed * 14); + state.pitch = 0.6; // it launches you nose-up, and you ride it down + } + } + + if (state.dist >= FINISH) { + state.race++; + state.score += Math.max(200, 2000 - state.clock); + state.dist = 0; + state.clock = 0; + state.things = []; + state.heat = 0; + } + return state; +} + +export const EXCITEBIKE = { + key: "excitebike", + aliases: ["bike", "moto", "excite"], + title: "EXCITEBIKE", + blurb: "turbo until it cooks, and land the way you took off", + keys: "← → throttle · space turbo · ↑ ↓ pitch in the air · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + things: [], + next: 16, + throttle: BASE_SPEED, + turbo: false, + heat: 0, + seized: 0, + air: 0, + pitch: 0, + crash: 0, + spills: 0, + dist: 0, + race: 1, + clock: 0, + score: 0, + last: null, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + if (state.crash > 0) return state; + if (pressed === "right") state.throttle = Math.min(MAX_SPEED, state.throttle + 0.12); + else if (pressed === "left") state.throttle = Math.max(BASE_SPEED * 0.5, state.throttle - 0.12); + else if (pressed === "space" || pressed === "enter") state.turbo = !state.turbo; + else if (pressed === "up" || pressed === "down") { + // Pitch only means anything off the ground; on it, this does nothing at + // all, which is the honest answer. + if (!state.air) return state; + state.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, state.pitch + (pressed === "up" ? -0.35 : 0.35))); + } + return state; + }, + + status(state) { + if (state.over) return state.over; + const gauge = Math.round(state.heat / 10); + const bar = `${"█".repeat(gauge)}${"░".repeat(10 - gauge)}`; + return `race ${state.race} · ${Math.round(state.dist)}/${FINISH} · heat ${bar}${state.seized ? " seized" : ""}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const thing of state.things) { + const [from] = rampSpan(thing); + // A ramp climbs, so it is drawn climbing. + [...("▁▄█")].forEach((c, i) => put(from + i, GROUND - (i > 1 ? 1 : 0), dirt(c))); + } + + const height = state.air ? Math.min(4, 1 + Math.round(state.air / 6)) : 0; + const nose = state.pitch < -LAND_OK ? "◜" : state.pitch > LAND_OK ? "◞" : state.air ? "◠" : "◉"; + put(RIDER, GROUND - 1 - height, state.crash ? danger("✷") : acid(nose)); + if (!state.air && !state.crash) put(RIDER + 1, GROUND - 1, ash("·")); + + return grid.map((row, y) => row.map((cell, x) => { + if (cell) return cell; + if (y === GROUND) return dirt("▀"); + if (y > GROUND) return dim("░"); + // The finish, coming up the track. + const toGo = FINISH - state.dist; + if (toGo < WIDTH - RIDER && x === RIDER + Math.round(toGo)) return bone("┋"); + return " "; + }).join("")); + }, +}; diff --git a/src/games-frogger.mjs b/src/games-frogger.mjs new file mode 100644 index 0000000..95faacc --- /dev/null +++ b/src/games-frogger.mjs @@ -0,0 +1,194 @@ +// Frogger. Five lanes of traffic that kill you if they touch you, then five of +// river that kill you if they don't. +// +// That inversion is the whole game and it is worth stating plainly in the code: +// on the road, being on something is death; on the river, being on nothing is. +// Everything else here — the lanes, the hops, the homes — is bookkeeping. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 40; + +/** + * The board, bottom to top. Row 0 is the top (the homes), and the frog starts + * on the bank at the bottom. + */ +export const HOME_ROW = 0; +export const RIVER = [1, 2, 3, 4, 5]; +export const MEDIAN = 6; +export const ROAD = [7, 8, 9, 10, 11]; +export const BANK = 12; +export const HEIGHT = BANK + 1; + +/** Five places to get to, evenly spaced along the top. */ +export const HOMES = [3, 11, 19, 27, 35]; +const HOME_W = 3; + +const LIVES = 3; +const water = rgb(60, 130, 220); +const log = rgb(150, 100, 60); + +/** Each lane: which way it runs, how fast, and how thick the things in it are. */ +export const LANES = { + 1: { dir: 1, speed: 0.16, len: 4, gap: 11, kind: "log" }, + 2: { dir: -1, speed: 0.22, len: 3, gap: 9, kind: "turtle" }, + 3: { dir: 1, speed: 0.13, len: 6, gap: 14, kind: "log" }, + 4: { dir: -1, speed: 0.28, len: 3, gap: 10, kind: "turtle" }, + 5: { dir: 1, speed: 0.2, len: 5, gap: 13, kind: "log" }, + 7: { dir: -1, speed: 0.26, len: 2, gap: 9, kind: "car" }, + 8: { dir: 1, speed: 0.19, len: 3, gap: 11, kind: "truck" }, + 9: { dir: -1, speed: 0.33, len: 2, gap: 12, kind: "car" }, + 10: { dir: 1, speed: 0.15, len: 4, gap: 13, kind: "truck" }, + 11: { dir: -1, speed: 0.24, len: 2, gap: 10, kind: "car" }, +}; + +const ART = { + car: { paint: danger, art: "▄▄▄▄▄▄" }, + truck: { paint: amber, art: "██████" }, + log: { paint: log, art: "▓▓▓▓▓▓" }, + turtle: { paint: acid, art: "◠◠◠◠◠◠" }, +}; + +/** Every lane laid out end to end, evenly spaced. */ +export function buildTraffic() { + const things = []; + for (const [row, lane] of Object.entries(LANES)) { + for (let x = 0; x < WIDTH + lane.gap; x += lane.gap) { + things.push({ row: Number(row), x, len: lane.len, kind: lane.kind }); + } + } + return things; +} + +/** The thing under a cell, if there is one. */ +export const thingAt = (things, row, x) => things.find( + (t) => t.row === row && x >= Math.round(t.x) && x < Math.round(t.x) + t.len, +) ?? null; + +/** Which home a frog at `x` has reached, or -1. */ +export const homeAt = (x) => HOMES.findIndex((h) => x >= h && x < h + HOME_W); + +const start = () => ({ x: Math.floor(WIDTH / 2), row: BANK }); + +function drown(state, why) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.score} points`; + return state; + } + state.frog = start(); + return state; +} + +/** One tick. Exported so a test can get a frog home with no clock. */ +export function step(state) { + for (const thing of state.traffic) { + const lane = LANES[thing.row]; + thing.x += lane.dir * lane.speed; + if (thing.x > WIDTH + 2) thing.x = -thing.len - 2; + if (thing.x < -thing.len - 2) thing.x = WIDTH + 2; + } + + const frog = state.frog; + if (RIVER.includes(frog.row)) { + // The river carries you. Riding a log off the edge of the world still + // counts as losing the frog, which is the lesson every player learns twice. + const ride = thingAt(state.traffic, frog.row, Math.round(frog.drift ?? frog.x)); + if (!ride) return drown(state, "into the river"); + frog.drift = (frog.drift ?? frog.x) + LANES[frog.row].dir * LANES[frog.row].speed; + frog.x = Math.round(frog.drift); + if (frog.x < 0 || frog.x >= WIDTH) return drown(state, "carried off the edge"); + } else if (ROAD.includes(frog.row)) { + if (thingAt(state.traffic, frog.row, frog.x)) return drown(state, "flattened"); + } + return state; +} + +/** Hop, and settle what the frog landed on. */ +export function hop(state, dx, dy) { + const frog = state.frog; + const row = Math.min(BANK, Math.max(HOME_ROW, frog.row + dy)); + const x = Math.min(WIDTH - 1, Math.max(0, frog.x + dx)); + frog.row = row; + frog.x = x; + frog.drift = RIVER.includes(row) ? x : null; + + if (row === HOME_ROW) { + const home = homeAt(x); + if (home < 0 || state.homes[home]) { + // The bank between the homes is not a home, and neither is one you have + // already filled. + return drown(state, "nowhere to land"); + } + state.homes[home] = true; + state.score += 100; + if (state.homes.every(Boolean)) { + state.level++; + state.homes = HOMES.map(() => false); + state.score += 500; + } + state.frog = start(); + return state; + } + if (dy < 0) state.score += 10; // forwards only, so hopping on the spot pays nothing + return step(state); +} + +export const FROGGER = { + key: "frogger", + aliases: ["frog", "hop"], + title: "FROGGER", + blurb: "the road kills what it touches, the river kills what it doesn't", + keys: "← ↑ ↓ → hop · q quit", + tickMs: 60, + + create() { + return { + traffic: buildTraffic(), + frog: start(), + homes: HOMES.map(() => false), + score: 0, + lives: LIVES, + level: 1, + over: null, + }; + }, + + tick: step, + + onKey(state, pressed) { + const moves = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] }; + const move = moves[pressed]; + if (!move) return state; + return hop(state, move[0], move[1]); + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · level ${state.level} · ${state.homes.filter(Boolean).length}/5 home · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, (_, row) => Array.from({ length: WIDTH }, () => ( + RIVER.includes(row) ? water("░") : ROAD.includes(row) ? dim("·") : null + ))); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const [i, home] of HOMES.entries()) { + for (let j = 0; j < HOME_W; j++) put(home + j, HOME_ROW, state.homes[i] ? acid("▓") : ash("▒")); + } + for (const thing of state.traffic) { + const { paint, art } = ART[thing.kind]; + for (let i = 0; i < thing.len; i++) put(Math.round(thing.x) + i, thing.row, paint(art[i % art.length])); + } + put(state.frog.x, state.frog.row, state.over ? danger("✷") : bone("◉")); + + return grid.map((row, y) => row.map((cell) => ( + cell ?? (y === MEDIAN || y === BANK ? ash("═") : " ") + )).join("")); + }, +}; diff --git a/src/games-invaders.mjs b/src/games-invaders.mjs new file mode 100644 index 0000000..b01cdb5 --- /dev/null +++ b/src/games-invaders.mjs @@ -0,0 +1,258 @@ +// Space Invaders. Forty of them, coming down a row at a time, and the fewer are +// left the faster the rest move — which is the joke the original hardware told by +// accident and every version since has kept on purpose. +// +// Nothing here moves on a fraction of a cell. The fleet steps a whole column at +// a time on a counter, shots move a whole row per tick, and every hit is one +// grid cell against another. A game whose whole tension is "will it get to the +// bottom before I do" should never lose a shot to a rounding error. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 44; +export const HEIGHT = 16; + +export const ROWS = 5; +export const COLS = 8; +const PITCH_X = 4; // an alien is two cells wide, with two between them +// One row per rank. At two the fleet stood nine rows tall on a board with eleven +// above the bunkers, so it "landed" after two drops and no wave was survivable. +const PITCH_Y = 1; + +export const CANNON_ROW = HEIGHT - 2; +const FLOOR_ROW = HEIGHT - 1; +export const BUNKER_ROW = HEIGHT - 4; +const LIVES = 3; +const BOMB_EVERY = 2; // bombs fall on every other tick, so they can be dodged +const SHOT_SPEED = 2; // rows per tick — a slow shot makes the whole game a queue + +/** Top rows are worth more, and look meaner. */ +export const KINDS = [ + { art: "▛▜", points: 30, paint: rgb(190, 130, 255) }, + { art: "▛▜", points: 20, paint: rgb(90, 200, 250) }, + { art: "▙▟", points: 20, paint: acid }, + { art: "▙▟", points: 10, paint: amber }, + { art: "▞▚", points: 10, paint: ash }, +]; + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +/** Where an alien sits, given the fleet's corner. */ +export const alienAt = (fleet, row, col) => ({ + x: fleet.x + col * PITCH_X, + y: fleet.y + row * PITCH_Y, +}); + +export const aliveCount = (fleet) => fleet.alive.reduce((n, row) => n + row.filter(Boolean).length, 0); + +/** + * How many ticks between steps. Forty aliens crawl; the last one sprints. This + * is the entire difficulty curve of the game and it costs one line. + */ +export const cadence = (fleet) => Math.max(2, Math.round(aliveCount(fleet) / 2.4)); + +const newFleet = (wave) => ({ + x: 3, + y: 1 + Math.min(3, wave - 1), // each wave starts closer to the floor + dir: 1, + alive: Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => true)), + clock: 0, +}); + +/** Four bunkers, each cell able to take two hits before it is gone. */ +export function buildBunkers() { + const bunkers = new Map(); + for (let b = 0; b < 4; b++) { + const left = 5 + b * 10; + for (let i = 0; i < 5; i++) bunkers.set(left + i, 2); + } + return bunkers; +} + +/** Chip a bunker cell, and say whether there was one there to chip. */ +export function chip(bunkers, x, y) { + if (y !== BUNKER_ROW) return false; + const hp = bunkers.get(x); + if (!hp) return false; + if (hp <= 1) bunkers.delete(x); else bunkers.set(x, hp - 1); + return true; +} + +/** The lowest live alien in each column — the only ones that can drop a bomb. */ +export function frontLine(fleet) { + const front = []; + for (let col = 0; col < COLS; col++) { + for (let row = ROWS - 1; row >= 0; row--) { + if (fleet.alive[row][col]) { front.push({ row, col }); break; } + } + } + return front; +} + +/** Whether a shot at (x, y) hits an alien, and which one. */ +export function alienHit(fleet, x, y) { + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + if (!fleet.alive[row][col]) continue; + const at = alienAt(fleet, row, col); + if (y === at.y && x >= at.x && x <= at.x + 1) return { row, col }; + } + } + return null; +} + +function marchFleet(state) { + const fleet = state.fleet; + const cols = []; + for (let col = 0; col < COLS; col++) if (fleet.alive.some((row) => row[col])) cols.push(col); + if (!cols.length) return state; + const leftMost = fleet.x + cols[0] * PITCH_X; + const rightMost = fleet.x + cols[cols.length - 1] * PITCH_X + 1; + + if ((fleet.dir > 0 && rightMost >= WIDTH - 1) || (fleet.dir < 0 && leftMost <= 0)) { + fleet.dir *= -1; + fleet.y += 1; + } else { + fleet.x += fleet.dir; + } + + // The fleet landing is the loss condition, and it beats having lives left. + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + if (fleet.alive[row][col] && alienAt(fleet, row, col).y >= BUNKER_ROW) { + state.over = `the fleet landed · ${state.score} points`; + return state; + } + } + } + return state; +} + +/** One tick. Exported so a test can clear a wave with no clock. */ +export function step(state) { + const { rng } = state; + + // The shot climbs a row at a time even though it covers two, so it can never + // skip over the row an alien is standing on. + for (let i = 0; i < SHOT_SPEED && state.shot; i++) { + state.shot.y -= 1; + if (state.shot.y < 0) { state.shot = null; break; } + if (chip(state.bunkers, state.shot.x, state.shot.y)) { state.shot = null; break; } + const hit = alienHit(state.fleet, state.shot.x, state.shot.y); + if (!hit) continue; + state.fleet.alive[hit.row][hit.col] = false; + state.score += KINDS[hit.row].points; + state.shot = null; + if (!aliveCount(state.fleet)) { + state.wave++; + state.fleet = newFleet(state.wave); + state.bombs = []; + return state; + } + } + + state.tick++; + if (state.tick % BOMB_EVERY === 0) { + for (const bomb of state.bombs) bomb.y += 1; + state.bombs = state.bombs.filter((bomb) => { + if (chip(state.bunkers, bomb.x, bomb.y)) return false; + if (bomb.y >= FLOOR_ROW) return false; + if (bomb.y === CANNON_ROW && Math.abs(bomb.x - state.cannon) <= 1) { + state.lives--; + if (state.lives <= 0) { state.lives = 0; state.over = `out of cannons · ${state.score} points`; } + return false; + } + return true; + }); + } + if (state.over) return state; + + // Somebody on the front line lets one go, more often the fewer are left. + const front = frontLine(state.fleet); + if (front.length && state.bombs.length < 3 && rng() < 0.02 + (COLS - front.length) * 0.004) { + const from = front[Math.floor(rng() * front.length) % front.length]; + const at = alienAt(state.fleet, from.row, from.col); + state.bombs.push({ x: at.x, y: at.y + 1 }); + } + + state.fleet.clock++; + if (state.fleet.clock >= cadence(state.fleet)) { + state.fleet.clock = 0; + marchFleet(state); + } + return state; +} + +export const INVADERS = { + key: "invaders", + aliases: ["spaceinvaders", "space", "aliens"], + title: "SPACE INVADERS", + blurb: "forty of them, and the last one moves fastest", + keys: "← → move · space fire · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + fleet: newFleet(1), + bunkers: buildBunkers(), + cannon: Math.floor(WIDTH / 2), + shot: null, + bombs: [], + score: 0, + lives: LIVES, + wave: 1, + tick: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, key) { + if (key === "left") state.cannon = clamp(state.cannon - 1, 1, WIDTH - 2); + else if (key === "right") state.cannon = clamp(state.cannon + 1, 1, WIDTH - 2); + else if (key === "space" || key === "up" || key === "enter") { + // One shot in the air at a time. Everything about the pacing of this game + // comes from that single rule. + if (!state.shot) state.shot = { x: state.cannon, y: CANNON_ROW - 1 }; + } + return state; + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + if (!state.fleet.alive[row][col]) continue; + const at = alienAt(state.fleet, row, col); + const kind = KINDS[row]; + [...kind.art].forEach((c, i) => put(at.x + i, at.y, kind.paint(c))); + } + } + + for (const [x, hp] of state.bunkers) put(x, BUNKER_ROW, hp > 1 ? acid("█") : dim("▓")); + for (const bomb of state.bombs) put(bomb.x, bomb.y, danger("╽")); + if (state.shot) put(state.shot.x, state.shot.y, bone("│")); + + if (state.over) { + put(state.cannon, CANNON_ROW, danger("✷")); + } else { + [...("▟█▙")].forEach((c, i) => put(state.cannon - 1 + i, CANNON_ROW, acid(c))); + } + + return grid.map((row, y) => row.map((cell) => ( + cell ?? (y === FLOOR_ROW ? ash("═") : " ") + )).join("")); + }, +}; diff --git a/src/games-kong.mjs b/src/games-kong.mjs new file mode 100644 index 0000000..a2f1198 --- /dev/null +++ b/src/games-kong.mjs @@ -0,0 +1,191 @@ +// Kong. Girders, ladders, barrels, and a climb you have done before. +// +// The girders here are flat. The originals slope, and a slope is the one thing +// this board cannot honestly draw — a terminal row is a terminal row, and faking +// it with half-blocks would make a game that looks like the arcade and plays +// like a bug report. What the slope actually *does* is kept: each girder has a +// direction, they alternate, and the ladder down sits at the far end of each +// one. So barrels cross a whole girder and drop, and a player climbing the same +// ladders walks every girder the opposite way — which is why you meet them head +// on instead of following them around. +import { acid, amber, ash, bone, danger, rgb } from "./ui.mjs"; + +export const WIDTH = 34; +export const HEIGHT = 17; + +/** + * Top to bottom: the row, the way barrels roll along it, the column of the + * ladder barrels come down, and a second ladder that only you use. + * + * The second one is not decoration. With a single ladder per girder, the only + * way up is the chute the barrels fall down, and climbing it is a coin toss you + * cannot jump out of — the board becomes unplayable rather than hard. + */ +export const GIRDERS = [ + { y: 3, dir: -1, ladder: 3, climb: WIDTH - 12 }, + { y: 6, dir: 1, ladder: WIDTH - 4, climb: 8 }, + { y: 9, dir: -1, ladder: 3, climb: WIDTH - 12 }, + { y: 12, dir: 1, ladder: WIDTH - 4, climb: 8 }, + { y: 15, dir: -1, ladder: null, climb: null }, +]; + +export const TOP = GIRDERS[0].y; +export const FLOOR = GIRDERS[GIRDERS.length - 1].y; + +/** Where Kong stands and throws from, and the way out beside him. */ +export const KONG_X = WIDTH - 3; +const GOAL_X = WIDTH - 6; + +const LIVES = 3; +const JUMP_TICKS = 6; +const kong = rgb(190, 130, 255); + +export const girderAt = (y) => GIRDERS.find((g) => g.y === y) ?? null; + +/** The ladders, derived from the girders so the two can never disagree. */ +export const LADDERS = GIRDERS.slice(0, -1).flatMap((g, i) => ( + [g.ladder, g.climb].map((x) => ({ x, top: g.y, bottom: GIRDERS[i + 1].y, barrels: x === g.ladder })) +)); + +export const ladderAt = (x, y) => LADDERS.find((l) => l.x === x && y >= l.top && y <= l.bottom) ?? null; + +/** A barrel starts at Kong's end of the top girder and works its way down. */ +export const newBarrel = () => ({ x: KONG_X, y: TOP, falling: null, jumped: false }); + +/** + * One barrel step: along its girder in that girder's direction, and down the + * ladder at the end of it. Rolling off the bottom girder is how a barrel leaves. + */ +export function rollBarrel(state, barrel) { + if (barrel.falling !== null) { + barrel.y += 1; + if (barrel.y >= barrel.falling) barrel.falling = null; + return barrel; + } + const girder = girderAt(barrel.y); + if (!girder) { barrel.done = true; return barrel; } + if (girder.ladder !== null && barrel.x === girder.ladder) { + // Mostly it takes the ladder; sometimes it carries on and rolls off the end, + // which is the only thing that makes two barrels behave differently. + if (state.rng() < 0.8) { + barrel.falling = GIRDERS[GIRDERS.indexOf(girder) + 1].y; + return barrel; + } + } + barrel.x += girder.dir; + if (barrel.x < 0 || barrel.x >= WIDTH) barrel.done = true; + return barrel; +} + +function lose(state, why) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.score} points`; + return state; + } + state.player = { x: 1, y: FLOOR, jump: 0 }; + state.barrels = []; + return state; +} + +/** Barrels roll this often, and a level makes them quicker. */ +export const rollEvery = (state) => Math.max(2, 5 - Math.floor(state.level / 2)); +export const throwEvery = (state) => Math.max(14, 40 - state.level * 5); + +/** One tick. Exported so a test can climb the whole board with no clock. */ +export function step(state) { + state.clock++; + if (state.player.jump > 0) state.player.jump--; + + if (state.clock % rollEvery(state) === 0) { + for (const barrel of state.barrels) rollBarrel(state, barrel); + state.barrels = state.barrels.filter((b) => !b.done); + } + if (state.clock % throwEvery(state) === 0) state.barrels.push(newBarrel()); + + for (const barrel of state.barrels) { + if (barrel.x !== state.player.x || barrel.y !== state.player.y) continue; + // A barrel you are in the air over is a barrel you have jumped. + if (!state.player.jump) return lose(state, "flattened by a barrel"); + if (!barrel.jumped) { barrel.jumped = true; state.score += 100; } + } + + if (state.player.y === TOP && state.player.x >= GOAL_X) { + state.level++; + state.score += 1000; + state.player = { x: 1, y: FLOOR, jump: 0 }; + state.barrels = []; + } + return state; +} + +export const KONG = { + key: "kong", + aliases: ["dk", "barrels", "climb"], + title: "KONG", + blurb: "five girders, four ladders, and a barrel with your name on it", + keys: "← → walk · ↑ ↓ ladders · space jump · q quit", + tickMs: 60, + + create({ rng = Math.random } = {}) { + return { + player: { x: 1, y: FLOOR, jump: 0 }, + barrels: [], + score: 0, + lives: LIVES, + level: 1, + clock: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + const p = state.player; + if (pressed === "space" || pressed === "enter") { + if (!p.jump) p.jump = JUMP_TICKS; + return state; + } + if (pressed === "left" && p.x > 0) p.x -= 1; + else if (pressed === "right" && p.x < WIDTH - 1) p.x += 1; + else if (pressed === "up" || pressed === "down") { + // Ladders are the only way between girders, and you have to be standing on + // one to use it. + const ladder = ladderAt(p.x, p.y); + if (!ladder) return state; + const next = pressed === "up" ? p.y - 1 : p.y + 1; + if (next >= ladder.top && next <= ladder.bottom) p.y = next; + } + return state; + }, + + status(state) { + return state.over + ? state.over + : `${state.score} · level ${state.level} · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const girder of GIRDERS) for (let x = 0; x < WIDTH; x++) put(x, girder.y, ash("═")); + for (const ladder of LADDERS) { + for (let y = ladder.top; y <= ladder.bottom; y++) put(ladder.x, y, bone("╫")); + } + put(KONG_X, TOP - 1, kong("♜")); + put(GOAL_X, TOP - 1, amber("♥")); + + for (const barrel of state.barrels) put(barrel.x, barrel.y, danger("◍")); + const p = state.player; + put(p.x, p.jump ? p.y - 1 : p.y, state.over ? danger("✷") : acid(p.jump ? "⌃" : "◉")); + + return grid.map((row) => row.map((cell) => cell ?? " ").join("")); + }, +}; diff --git a/src/games-outrun.mjs b/src/games-outrun.mjs new file mode 100644 index 0000000..f43e12d --- /dev/null +++ b/src/games-outrun.mjs @@ -0,0 +1,208 @@ +// OutRun. A road drawn in perspective, a clock that is always losing, and a +// checkpoint that gives you a bit of it back. +// +// This is the one game in the cabinet that fakes a third dimension, and it does +// it the way the eighties did: the road is not an object, it is a rule for +// drawing each row. Rows near the top are far away, so the tarmac is narrow +// there and the bend is wide; rows near the bottom are under your bumper, so the +// tarmac is wide and dead centre. Nothing is ever transformed — `roadHalf` and +// `centreAt` are the whole renderer, and the same two functions decide what you +// have hit. +import { acid, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 48; +export const HEIGHT = 16; + +const NEAR_HALF = 20; // half the road's width under your bumper +const FAR_HALF = 3; // and up at the horizon +const BEND = 15; // how far a full-lock corner throws the far end sideways + +const MAX_SPEED = 1.9; +const ACCEL = 0.06; +const BRAKE = 0.12; +const DRAG = 0.012; +const OFF_ROAD = 0.55; // the fastest you will ever go with two wheels on the grass +const DRIFT = 0.5; // how hard a corner pushes you towards the outside +const START_TIME = 900; // ticks +const CHECKPOINT = 320; // and how far apart the checkpoints are +const CHECK_BONUS = 220; // flat out, a checkpoint takes about 170 ticks to reach + +const grass = rgb(60, 140, 70); +const road = rgb(90, 90, 95); + +/** How far from the middle the tarmac reaches on a given row. */ +export const roadHalf = (row) => { + const p = (row + 1) / HEIGHT; + return FAR_HALF + (NEAR_HALF - FAR_HALF) * p ** 1.7; +}; + +/** + * Where the middle of the road is on a given row. + * + * The bend is squared against distance so it opens up towards the horizon and + * closes to nothing under the car — which is exactly what a corner looks like + * from the driver's seat, and why the bottom row never moves. + */ +export const centreAt = (row, curve) => { + const p = (row + 1) / HEIGHT; + return WIDTH / 2 + curve * BEND * (1 - p) ** 2; +}; + +export const PLAYER_ROW = HEIGHT - 1; +export const CAR_W = 3; + +/** A stretch of road: how long it runs, and how hard it bends. */ +export function nextSegment(rng) { + return { left: 40 + Math.floor(rng() * 60), curve: (rng() * 2 - 1) * (rng() < 0.35 ? 1 : 0.45) }; +} + +/** Whether the car is on the tarmac at all. */ +export const onRoad = (x, curve) => Math.abs(x - centreAt(PLAYER_ROW, curve)) <= roadHalf(PLAYER_ROW) - 1; + +/** Where a car at depth `z` (1 at the horizon, 0 at your bumper) is drawn. */ +export const rowAt = (z) => Math.round(PLAYER_ROW - z * (PLAYER_ROW - 1)); + +function spin(state) { + state.speed = 0; + state.spins++; + state.stunned = 30; + return state; +} + +/** One tick of road. Exported so a test can drive the whole stage with no clock. */ +export function step(state) { + state.clock--; + if (state.clock <= 0) { + state.clock = 0; + state.over = `time up · ${Math.round(state.dist)} miles`; + return state; + } + if (state.stunned > 0) state.stunned--; + + // The road ahead, one segment at a time, eased towards rather than snapped to. + state.segment.left -= state.speed; + if (state.segment.left <= 0) state.segment = nextSegment(state.rng); + state.curve += (state.segment.curve - state.curve) * 0.04; + + state.speed = Math.max(0, state.speed - DRAG); + const off = !onRoad(state.car, state.curve); + if (off) state.speed = Math.min(state.speed, OFF_ROAD); + if (state.stunned) state.speed = Math.min(state.speed, 0.2); + + state.dist += state.speed; + // A corner throws you at the outside of it. Steering is how you stay in. + state.car += state.curve * state.speed * DRIFT; + state.car = Math.max(0, Math.min(WIDTH - 1, state.car)); + + for (const car of state.traffic) car.z -= (state.speed - car.speed) * 0.012; + state.traffic = state.traffic.filter((c) => c.z > -0.05 && c.z < 1.2); + if (state.traffic.length < 3 && state.rng() < 0.03) { + state.traffic.push({ z: 1.1, lane: state.rng() * 1.4 - 0.7, speed: 0.35 + state.rng() * 0.5 }); + } + + if (!state.stunned) { + const hit = state.traffic.find((c) => { + if (c.z > 0.08) return false; + const at = centreAt(PLAYER_ROW, state.curve) + c.lane * roadHalf(PLAYER_ROW); + return Math.abs(at - state.car) < CAR_W; + }); + if (hit) { + state.traffic = state.traffic.filter((c) => c !== hit); + spin(state); + } + } + + if (state.dist >= state.nextCheck) { + state.nextCheck += CHECKPOINT; + state.clock += CHECK_BONUS; + state.checks++; + state.score += 1000; + } + state.score += Math.floor(state.dist) - state.scored; + state.scored = Math.floor(state.dist); + return state; +} + +export const OUTRUN = { + key: "outrun", + aliases: ["run", "coast", "racer"], + title: "OUTRUN", + blurb: "a road that bends, traffic that doesn't, and a clock that always wins", + keys: "← → steer · ↑ throttle · ↓ brake · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + car: WIDTH / 2, + speed: 0, + curve: 0, + segment: nextSegment(rng), + traffic: [], + dist: 0, + scored: 0, + nextCheck: CHECKPOINT, + checks: 0, + clock: START_TIME, + stunned: 0, + spins: 0, + score: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + if (pressed === "left") state.car -= 0.9; + else if (pressed === "right") state.car += 0.9; + else if (pressed === "up") state.speed = Math.min(MAX_SPEED, state.speed + ACCEL * 4); + else if (pressed === "down") state.speed = Math.max(0, state.speed - BRAKE * 2); + state.car = Math.max(0, Math.min(WIDTH - 1, state.car)); + return state; + }, + + status(state) { + if (state.over) return state.over; + const kph = Math.round(state.speed * 120); + return `${kph} kph · ${Math.round(state.clock / 20)}s · check ${state.checks} · ${Math.round(state.dist)} mi`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const car of state.traffic) { + const row = rowAt(car.z); + if (row < 1 || row > PLAYER_ROW) continue; + const at = centreAt(row, state.curve) + car.lane * roadHalf(row); + // Cars shrink with distance, the same way the road does. + const w = Math.max(1, Math.round(CAR_W * ((row + 1) / HEIGHT))); + for (let i = 0; i < w; i++) put(Math.round(at) - Math.floor(w / 2) + i, row, danger("▀")); + } + + const nose = state.stunned ? danger("✷") : acid("▟▙"); + put(Math.round(state.car) - 1, PLAYER_ROW, state.stunned ? nose : acid("▟")); + put(Math.round(state.car), PLAYER_ROW, state.stunned ? nose : acid("█")); + put(Math.round(state.car) + 1, PLAYER_ROW, state.stunned ? nose : acid("▙")); + + return grid.map((row, y) => { + const centre = centreAt(y, state.curve); + const half = roadHalf(y); + return row.map((cell, x) => { + if (cell) return cell; + const from = centre - half; + const to = centre + half; + if (x < from || x > to) return grass("░"); + // Kerbs, and a centre line that moves with you so the road runs. + if (x < from + 1 || x > to - 1) return ((y + Math.floor(state.dist)) % 4 < 2 ? bone : danger)("│"); + const middle = Math.round(centre); + if (x === middle && (y + Math.floor(state.dist * 1.5)) % 4 < 2) return dim("┆"); + return road(" "); + }).join(""); + }); + }, +}; diff --git a/src/games-pitfall.mjs b/src/games-pitfall.mjs new file mode 100644 index 0000000..c9be46e --- /dev/null +++ b/src/games-pitfall.mjs @@ -0,0 +1,211 @@ +// Pitfall. The jungle goes past whether you are ready or not: pits, logs, +// scorpions, and a vine over the worst of them. +// +// The vine is the reason this is not just another jumping game. A jump is short +// and you commit to it the moment you press it; a swing is long, and you can +// only start one where a vine is hanging — so the hazards that are too wide to +// jump are the ones that have a vine over them, and reading which is which as +// it comes at you is the game. +import { acid, ash, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 46; +export const HEIGHT = 13; + +export const GROUND = 9; // the row you run along +const CANOPY = 2; // where the vines hang from +export const RUNNER = 9; // your column; the jungle moves, you do not + +const JUMP = 10; // ticks in the air — about five columns of jungle +const SWING = 21; // ticks on a vine — a five-wide pit takes about sixteen +const REACH = 1; // how close to a vine you must be to catch it +const LIVES = 3; +const SPEED = 0.55; // columns of jungle per tick +const TIME = 2400; // ticks on the clock + +const jungle = rgb(60, 140, 70); +const gold = rgb(255, 200, 60); + +/** What the jungle throws at you, and what gets you past it. */ +export const HAZARDS = { + pit: { w: 5, art: " ", paint: ash, cleared: "swing", death: "down a pit" }, + log: { w: 2, art: "◙◙", paint: rgb(150, 100, 60), cleared: "jump", death: "rolled over by a log" }, + scorpion: { w: 1, art: "%", paint: danger, cleared: "jump", death: "stung" }, +}; + +/** The columns a thing covers. A vine and a bar of gold are one cell each. */ +export const span = (thing) => { + const x = Math.round(thing.x); + return [x, x + (HAZARDS[thing.kind]?.w ?? 1) - 1]; +}; + +const touching = (thing) => { + const [from, to] = span(thing); + return RUNNER >= from && RUNNER <= to; +}; + +/** + * The next thing down the trail, and how far behind it the one after that will + * be. A pit always comes with a vine over it: it is five columns wide and a + * jump covers three, so a pit with no vine is a pit nobody gets past. + */ +export function spawn(state) { + const { rng } = state; + const edge = WIDTH + 2; + const roll = rng(); + if (roll < 0.22) { + state.things.push({ kind: "treasure", x: edge }); + state.next = 8 + rng() * 8; + } else if (roll < 0.5) { + state.things.push({ kind: "pit", x: edge }); + state.things.push({ kind: "vine", x: edge - 3 }); + state.next = 22 + rng() * 10; + } else if (roll < 0.78) { + state.things.push({ kind: "log", x: edge }); + state.next = 16 + rng() * 10; + } else { + state.things.push({ kind: "scorpion", x: edge }); + state.next = 16 + rng() * 10; + } + return state; +} + +function lose(state, why) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.treasure} treasure`; + return state; + } + state.things = state.things.filter((t) => span(t)[1] < RUNNER - 2 || span(t)[0] > RUNNER + 12); + state.air = 0; + state.swinging = false; + state.clock = Math.max(0, state.clock - 120); // a fall costs you time as well + return state; +} + +/** One tick of jungle. Exported so a test can run the trail with no clock. */ +export function step(state) { + state.clock++; + state.dist += SPEED; + if (state.clock >= TIME) { + state.over = `out of daylight · ${state.treasure} treasure`; + return state; + } + + if (state.air > 0) { + state.air--; + if (!state.air) state.swinging = false; + } + + for (const thing of state.things) thing.x -= SPEED; + state.things = state.things.filter((t) => !t.taken && span(t)[1] > -3); + + state.next -= SPEED; + if (state.next <= 0) spawn(state); + + for (const thing of state.things) { + if (!touching(thing)) continue; + if (thing.kind === "vine") continue; // a vine is scenery until you grab it + if (thing.kind === "treasure") { + if (state.air) continue; // you cannot scoop it up mid-swing + thing.taken = true; + state.treasure++; + state.score += 500; + continue; + } + // In the air is past it, whichever way you got there. + if (state.air > 0) continue; + return lose(state, HAZARDS[thing.kind].death); + } + return state; +} + +/** Grab the vine you are under, if there is one. */ +export function grab(state) { + if (state.air) return state; + const vine = state.things.find((t) => t.kind === "vine" && Math.abs(Math.round(t.x) - RUNNER) <= REACH); + if (!vine) return state; + state.air = SWING; + state.swinging = true; + return state; +} + +export const PITFALL = { + key: "pitfall", + aliases: ["jungle", "vine"], + title: "PITFALL", + blurb: "jump the logs, swing the pits, and get the gold before dark", + keys: "space jump · ↑ grab a vine · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + things: [], + air: 0, + swinging: false, + next: 20, + dist: 0, + clock: 0, + treasure: 0, + score: 0, + lives: LIVES, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, pressed) { + if (pressed === "up") return grab(state); + if (pressed === "space" || pressed === "enter") { + if (!state.air) state.air = JUMP; + } + return state; + }, + + status(state) { + if (state.over) return state.over; + const left = Math.max(0, Math.round((TIME - state.clock) / 20)); + return `${state.score} · ${state.treasure} gold · ${left}s · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const thing of state.things) { + const [from] = span(thing); + if (thing.kind === "vine") { + for (let y = CANOPY; y < GROUND - 2; y++) put(from, y, jungle("│")); + continue; + } + if (thing.kind === "treasure") { put(from, GROUND - 1, gold("▮")); continue; } + const { art, paint, w } = HAZARDS[thing.kind]; + for (let i = 0; i < w; i++) { + // A pit is a hole in the floor rather than something drawn on it. + if (thing.kind === "pit") put(from + i, GROUND, dim(" ")); + else put(from + i, GROUND - 1, paint(art[i])); + } + } + + const y = state.swinging ? GROUND - 4 : state.air ? GROUND - 3 : GROUND - 1; + put(RUNNER, y, state.over ? danger("✷") : acid(state.swinging ? "⌾" : "◉")); + if (state.swinging) for (let v = CANOPY; v < y; v++) put(RUNNER, v, jungle("│")); + + return grid.map((row, ry) => row.map((cell, x) => { + if (cell) return cell; + if (ry === GROUND) { + // The floor, with the pits left out of it. + const overPit = state.things.some((t) => t.kind === "pit" && x >= span(t)[0] && x <= span(t)[1]); + return overPit ? " " : jungle("▀"); + } + if (ry === CANOPY - 1) return dim("╌"); + if (ry > GROUND) return ash("░"); + return " "; + }).join("")); + }, +}; diff --git a/src/games-pong.mjs b/src/games-pong.mjs new file mode 100644 index 0000000..f78287f --- /dev/null +++ b/src/games-pong.mjs @@ -0,0 +1,147 @@ +// Pong. The oldest one in the cabinet, and still the one that explains itself +// fastest: you are the left paddle, the ball is going that way, do something. +// +// The machine on the right is deliberately not perfect. It waits until the ball +// crosses the halfway line before it starts tracking, and then it moves slowly +// enough that it cannot reach a corner from the middle in the time it has left. +// A flat return it will always get; one taken off the end of your paddle it will +// not. That is the whole game, and it is why the angle off the paddle depends on +// where the ball hit it. +import { acid, bone, danger, dim } from "./ui.mjs"; + +export const WIDTH = 44; +export const HEIGHT = 16; + +/** A row is worth two columns, so the ball travels at the angle it looks like. */ +const ASPECT = 0.5; + +export const PADDLE = 4; // rows tall +export const YOU_COL = 2; +export const THEM_COL = WIDTH - 3; +export const TARGET = 7; // first to this many + +const SERVE_SPEED = 0.85; +const MAX_SPEED = 1.7; +const SPIN = 0.55; // how much the edge of the paddle bends the ball +const THEM_SPEED = 0.3; // slow enough that a ball into the corner beats it +const YOU_STEP = 1; + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +/** A ball in the middle, heading at whoever just lost the point. */ +export function serve(state, toward) { + state.ball = { + x: WIDTH / 2, + y: HEIGHT / 2, + vx: toward * SERVE_SPEED, + // Never dead flat: a ball with no angle is a rally nobody can lose. + vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2), + }; + return state; +} + +/** Where a paddle's rows are, given its top row. */ +export const paddleRows = (top) => Array.from({ length: PADDLE }, (_, i) => Math.round(top) + i); + +const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5; + +/** + * Bounce off a paddle, steeper the further from its middle you take it. This is + * the only way a player gets to aim, so it does more work than the physics. + */ +function returned(ball, top, dir) { + const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2); + ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06); + ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -0.5, 0.5); + // Never let a rally go flat. A ball with no angle is one the machine can park + // in front of forever, and a rally that cannot end is not a game. + if (Math.abs(ball.vy) < 0.08) ball.vy = (ball.vy < 0 ? -1 : 1) * 0.12; + return ball; +} + +/** One tick of rally. Exported so a test can play a whole match with no clock. */ +export function step(state) { + const ball = state.ball; + ball.x += ball.vx; + ball.y += ball.vy; + + // The top and bottom are walls, and the ball is put back inside rather than + // just reflected — at speed, a reflection alone can leave it outside. + if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); } + if (ball.y > HEIGHT - 1) { ball.y = 2 * (HEIGHT - 1) - ball.y; ball.vy = -Math.abs(ball.vy); } + + if (ball.vx < 0 && ball.x <= YOU_COL) { + if (catches(state.you, ball.y)) { ball.x = YOU_COL; returned(ball, state.you, 1); } + } else if (ball.vx > 0 && ball.x >= THEM_COL) { + if (catches(state.them, ball.y)) { ball.x = THEM_COL; returned(ball, state.them, -1); } + } + + if (ball.x < 0) { state.theirs++; point(state, 1); } + else if (ball.x > WIDTH - 1) { state.yours++; point(state, -1); } + + // The machine: idle in the middle until the ball is on its half, then chase + // the ball's row. Perfect tracking here would make the game unloseable for it, + // which is the same thing as unplayable. + const chasing = state.ball.vx > 0 && state.ball.x > WIDTH * 0.4; + const want = chasing ? state.ball.y - (PADDLE - 1) / 2 : (HEIGHT - PADDLE) / 2; + const move = clamp(want - state.them, -THEM_SPEED, chasing ? THEM_SPEED : THEM_SPEED / 2); + state.them = clamp(state.them + move, 0, HEIGHT - PADDLE); + + return state; +} + +function point(state, toward) { + if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`; + else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`; + else serve(state, toward); +} + +export const PONG = { + key: "pong", + aliases: ["tennis", "paddle"], + title: "PONG", + blurb: "first to seven, and the angle is all in where you hit it", + keys: "↑ ↓ move · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + const state = { + you: (HEIGHT - PADDLE) / 2, + them: (HEIGHT - PADDLE) / 2, + yours: 0, + theirs: 0, + over: null, + rng, + }; + return serve(state, rng() < 0.5 ? -1 : 1); + }, + + tick: step, + + onKey(state, key) { + if (key === "up") state.you = clamp(state.you - YOU_STEP, 0, HEIGHT - PADDLE); + if (key === "down") state.you = clamp(state.you + YOU_STEP, 0, HEIGHT - PADDLE); + return state; + }, + + status(state) { + return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█")); + for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█")); + put(Math.round(state.ball.x), Math.round(state.ball.y), bone("●")); + + return grid.map((row, y) => row.map((cell, x) => ( + // The net, which is only there so the middle of the table has a middle. + cell ?? (x === Math.floor(WIDTH / 2) && y % 2 === 0 ? dim("┊") : " ") + )).join("")); + }, +}; diff --git a/src/games-spyhunter.mjs b/src/games-spyhunter.mjs new file mode 100644 index 0000000..6e6c18a --- /dev/null +++ b/src/games-spyhunter.mjs @@ -0,0 +1,230 @@ +// Spy Hunter. A road that will not hold still, traffic that will not get out of +// the way, and a gun. Stay on the tarmac, shoot the ones shooting back, and do +// not shoot the ones just driving home. +// +// The road is a list of rows, each one a left and a right edge, scrolled down +// under a car that only ever moves sideways. Generating the next row from the +// last one — rather than from a function of distance — is what makes the verge +// bend instead of zig-zag, and it is the only reason it reads as a road. +import { acid, amber, ash, bone, danger, dim } from "./ui.mjs"; + +export const WIDTH = 40; +export const HEIGHT = 18; + +/** The row your car is on. It never changes; the road comes to you. */ +export const CAR_ROW = HEIGHT - 3; +export const CAR_W = 2; + +const MIN_ROAD = 13; +const MAX_ROAD = 24; +const BASE_SPEED = 0.34; // rows of road per tick +const MAX_SPEED = 0.75; +const LIVES = 3; +const GRACE = 25; // ticks of "the road is clear" after a wreck +const SHOT_SPEED = 1.6; // rows per tick, travelled in halves so nothing is skipped + +/** The cars that are not you. */ +export const TRAFFIC = { + enemy: { art: "▜▛", paint: danger, points: 50, homing: 0.06 }, + civilian: { art: "▐▌", paint: bone, points: -100, homing: 0 }, +}; + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); + +/** + * The next row of road, bent a little from the one before it. + * + * The bend is random but pulled towards the middle of the screen, in proportion + * to how far out it already is. A drift with no pull is a random walk, and a + * random walk parks the road against one edge and leaves it there — which looks + * less like a road than like a bug. + */ +export function nextRow(prev, rng) { + const width = clamp(prev.right - prev.left + (rng() < 0.3 ? (rng() < 0.5 ? -1 : 1) : 0), MIN_ROAD, MAX_ROAD); + const centre = (prev.left + prev.right) / 2; + const pull = clamp((WIDTH / 2 - centre) / 8, -0.45, 0.45); + const roll = rng() * 2 - 1 + pull; + const drift = Math.abs(roll) < 0.55 ? 0 : Math.sign(roll); + // One cell of verge on each side always stays on screen, so "the road bends" + // never reads as "the road ends". + const left = clamp(prev.left + drift, 1, WIDTH - width - 2); + return { left, right: left + width }; +} + +/** A straight run of road to start on, so the first thing you meet is not a bend. */ +export function openRoad() { + const left = Math.floor((WIDTH - 20) / 2); + return Array.from({ length: HEIGHT }, () => ({ left, right: left + 20 })); +} + +export const onRoad = (row, x) => row && x >= row.left && x + CAR_W - 1 <= row.right; + +/** Whether two cars, each CAR_W wide, are in the same place. */ +export const overlaps = (ax, bx) => Math.abs(Math.round(ax) - Math.round(bx)) < CAR_W; + +function wreck(state, why) { + state.lives--; + if (state.lives <= 0) { + state.lives = 0; + state.over = `${why} · ${state.score} points`; + return state; + } + // A wreck clears the road ahead, or you respawn straight into the car that + // just got you and lose the rest of your lives in three ticks. + state.traffic = []; + state.grace = GRACE; + const row = state.road[CAR_ROW]; + state.car = Math.round((row.left + row.right) / 2) - 1; + return state; +} + +/** One tick of road. Exported so a test can drive a whole run with no clock. */ +export function step(state) { + const { rng } = state; + state.dist += state.speed; + state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 900); + if (state.grace > 0) state.grace--; + + // Scroll: the road only shifts on whole rows, so the verge never shimmers. + state.scroll += state.speed; + while (state.scroll >= 1) { + state.scroll -= 1; + state.road.pop(); + state.road.unshift(nextRow(state.road[0], rng)); + for (const car of state.traffic) car.y += 1; + for (const shot of state.shots) shot.y += 1; + } + + // Shots move faster than a car is tall, so they travel in half-steps and are + // checked against the traffic after each one. Moving the whole way in one go + // lets a shot pass clean through a car that was between the two positions. + for (let half = 0; half < 2; half++) { + for (const shot of state.shots) shot.y -= SHOT_SPEED / 2; + hitTraffic(state); + } + state.shots = state.shots.filter((shot) => shot.y > -1); + + for (const car of state.traffic) { + car.y += state.speed - car.speed; + // An enemy leans towards you; traffic just drives. + if (TRAFFIC[car.kind].homing) { + car.x += Math.sign(state.car - car.x) * TRAFFIC[car.kind].homing; + } + const row = state.road[Math.round(car.y)]; + if (row) car.x = clamp(car.x, row.left, row.right - CAR_W + 1); + } + state.traffic = state.traffic.filter((car) => car.y < HEIGHT + 1 && car.y > -3); + + if (!state.grace) { + const row = state.road[CAR_ROW]; + if (!onRoad(row, state.car)) return wreck(state, "off the road"); + const rammed = state.traffic.find((car) => Math.round(car.y) === CAR_ROW && overlaps(car.x, state.car)); + if (rammed) return wreck(state, `rammed ${rammed.kind === "enemy" ? "an enemy" : "a civilian"}`); + } + + if (!state.grace && state.traffic.length < 4 && rng() < 0.05) { + const row = state.road[0]; + const kind = rng() < 0.6 ? "enemy" : "civilian"; + state.traffic.push({ + kind, + x: row.left + Math.floor(rng() * (row.right - row.left - CAR_W + 1)), + y: 0, + // Slower than you, or the road behind would never catch anybody up. + speed: 0.1 + rng() * 0.18, + }); + } + + state.score += Math.floor(state.dist / 10) - state.miles; + state.miles = Math.floor(state.dist / 10); + return state; +} + +/** Shots meet traffic. A civilian you shoot is a civilian you pay for. */ +function hitTraffic(state) { + for (const shot of [...state.shots]) { + const hit = state.traffic.find((car) => Math.abs(car.y - shot.y) < 0.9 && overlaps(car.x, shot.x - 0.5)); + if (!hit) continue; + state.shots = state.shots.filter((s) => s !== shot); + state.traffic = state.traffic.filter((c) => c !== hit); + state.score = Math.max(0, state.score + TRAFFIC[hit.kind].points); + } + return state; +} + +export const SPYHUNTER = { + key: "spyhunter", + aliases: ["spy", "chase", "hunter"], + title: "SPY HUNTER", + blurb: "keep it on the tarmac, shoot the ones shooting back", + keys: "← → steer · space fire · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + const road = openRoad(); + return { + road, + traffic: [], + shots: [], + car: Math.round((road[CAR_ROW].left + road[CAR_ROW].right) / 2) - 1, + speed: BASE_SPEED, + scroll: 0, + dist: 0, + miles: 0, + score: 0, + lives: LIVES, + grace: 0, + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, key) { + if (key === "left") state.car -= 1; + else if (key === "right") state.car += 1; + else if (key === "space" || key === "up" || key === "enter") { + if (state.shots.length < 3) state.shots.push({ x: state.car + 0.5, y: CAR_ROW - 1 }); + } + // Steering off the edge of the screen is a wreck like any other, so the car + // is only kept on the board, not on the road. + state.car = clamp(state.car, 0, WIDTH - CAR_W); + return state; + }, + + status(state) { + if (state.over) return state.over; + return `${state.score} · ${state.miles} mi · ${"▲".repeat(state.lives)}`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const car of state.traffic) { + const kind = TRAFFIC[car.kind]; + [...kind.art].forEach((c, i) => put(Math.round(car.x) + i, Math.round(car.y), kind.paint(c))); + } + for (const shot of state.shots) put(Math.round(shot.x), Math.round(shot.y), amber("•")); + if (state.over) { + [...("✷✷")].forEach((c, i) => put(state.car + i, CAR_ROW, danger(c))); + } else if (!state.grace || Math.floor(state.grace / 3) % 2) { + [...("▟▙")].forEach((c, i) => put(state.car + i, CAR_ROW, acid(c))); + } + + return grid.map((row, y) => { + const edge = state.road[y]; + return row.map((cell, x) => { + if (cell) return cell; + if (x < edge.left || x > edge.right) return ash("▒"); + // The centre line, dashed, and moving — without it the road is a + // stationary corridor and you cannot tell you are going anywhere. + const middle = Math.round((edge.left + edge.right) / 2); + return x === middle && (y + Math.floor(state.dist)) % 4 < 2 ? dim("┆") : " "; + }).join(""); + }); + }, +}; diff --git a/src/games-stagedive.mjs b/src/games-stagedive.mjs new file mode 100644 index 0000000..8198f10 --- /dev/null +++ b/src/games-stagedive.mjs @@ -0,0 +1,206 @@ +// Stagedive. You are running the barricade, the stage is coming at you, and it +// does not stop. Hop the monitor wedges and the amp stacks, duck the +// crowdsurfers, take the picks. One mistake is the whole run. +// +// A side-scroller in a terminal is really a scrolling list: the runner never +// moves along the x axis at all. Everything else slides left past a fixed +// column, which is why `speed` is measured in columns per tick and why the gap +// between hazards is multiplied by it — a jump lasts a fixed number of ticks, so +// a fair gap has to get longer as the stage gets faster. +import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs"; + +export const WIDTH = 50; +export const HEIGHT = 9; + +/** The row the runner's feet are on, and the stage edge under it. */ +export const GROUND = 7; +const FLOOR = GROUND + 1; + +/** The runner's column. It never changes — the stage moves, not you. */ +export const RUNNER = 8; + +const BASE_SPEED = 0.85; +const MAX_SPEED = 1.75; +const GRAVITY = 0.15; +const JUMP = -1.3; // ~5 rows up and ~17 ticks in the air +const DUCK_TICKS = 8; // a tap of ↓ stays crouched this long, so a repeat holds it +const SLAM = 0.7; // ↓ in mid-air comes down early, which is how you save a bad jump + +const crowd = rgb(255, 120, 180); + +/** + * What is on the stage. `art` is one row's worth of cells and `rows` is which + * rows it fills, so its width and its hitbox are the same number by + * construction — a hazard that is drawn wider than it hits is the oldest bug in + * the genre. `death` is what the status line says when it gets you. + */ +export const HAZARDS = { + wedge: { art: "██", rows: [GROUND], paint: ash, death: "tripped over a monitor wedge" }, + stack: { art: "███", rows: [GROUND - 1, GROUND], paint: bone, death: "ran into an amp stack" }, + // Head height: a standing runner wears it, a crouched one does not. + surfer: { art: "╾●╼", rows: [GROUND - 1], paint: crowd, death: "wore a crowdsurfer" }, +}; + +const PICK = amber("♦"); + +/** The cells a thing covers, so a hit is decided by what the screen showed. */ +export function cells(thing) { + const x = Math.round(thing.x); + if (thing.kind === "pick") return { cols: [x, x], rows: [thing.row] }; + const { art, rows } = HAZARDS[thing.kind]; + return { cols: [x, x + art.length - 1], rows }; +} + +/** The runner: two rows standing, one crouched — which is the whole point of ↓. */ +export function runnerRows(state) { + const y = Math.round(state.y); + if (state.duck > 0 && !state.airborne) return [GROUND]; + return [y - 1, y]; +} + +const hits = (thing, rows) => { + const { cols, rows: theirs } = cells(thing); + return RUNNER >= cols[0] && RUNNER <= cols[1] && theirs.some((r) => rows.includes(r)); +}; + +/** + * Put the next thing on the far edge, and decide how far behind it the one + * after that will be. The gap scales with speed because a jump is a fixed + * number of ticks: without that, the stage eventually outruns the jump and the + * game stops being losable-by-mistake and starts being unfair. + */ +export function spawn(state) { + const { rng } = state; + const edge = WIDTH + 2; + const roll = rng(); + + if (roll < 0.3) { + // A line of picks. Low ones are free; a high arc is paid for with a jump. + const high = rng() < 0.55; + const count = 3 + Math.floor(rng() * 3); + for (let i = 0; i < count; i++) { + state.things.push({ kind: "pick", x: edge + i * 2, row: high ? (i === 0 || i === count - 1 ? 5 : 4) : GROUND }); + } + state.next = (10 + rng() * 8) * state.speed; + return state; + } + + const kind = roll < 0.55 ? "wedge" : roll < 0.8 ? "stack" : "surfer"; + state.things.push({ kind, x: edge }); + state.next = (16 + rng() * 14) * state.speed; + return state; +} + +export const meters = (state) => Math.floor(state.dist / 2); + +/** One tick of stage. Exported so a test can run the whole set without a clock. */ +export function step(state) { + state.dist += state.speed; + state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 2200); + + if (state.airborne) { + state.vy += GRAVITY; + state.y += state.vy; + if (state.y >= GROUND) { state.y = GROUND; state.vy = 0; state.airborne = false; } + } + if (state.duck > 0) state.duck--; + + for (const thing of state.things) thing.x -= state.speed; + state.things = state.things.filter((t) => !t.taken && t.x > -4); + + state.next -= state.speed; + if (state.next <= 0) spawn(state); + + const rows = runnerRows(state); + for (const thing of state.things) { + if (!hits(thing, rows)) continue; + if (thing.kind === "pick") { thing.taken = true; state.picks++; continue; } + state.over = `${HAZARDS[thing.kind].death} · ${meters(state)} m`; + return state; + } + return state; +} + +export const STAGEDIVE = { + key: "stagedive", + aliases: ["dive", "runner", "stage"], + title: "STAGEDIVE", + blurb: "run the barricade, hop the gear, duck the crowd, take the picks", + keys: "↑ jump · ↓ duck (and slam) · space jump · q quit", + tickMs: 55, + + create({ rng = Math.random } = {}) { + return { + y: GROUND, + vy: 0, + airborne: false, + duck: 0, + dist: 0, + speed: BASE_SPEED, + picks: 0, + things: [], + next: 24, // a moment of clear stage before the first thing arrives + over: null, + rng, + }; + }, + + tick: step, + + onKey(state, key) { + if (key === "up" || key === "space" || key === "enter") { + if (state.airborne) return state; // no second jump; the floor is the rule + state.vy = JUMP; + state.airborne = true; + state.duck = 0; + return state; + } + if (key === "down") { + // In the air this is a slam, on the ground it is a crouch. Both are the + // same key because both are "get low", and one key is easier to mean. + if (state.airborne) state.vy = Math.max(state.vy, SLAM); + else state.duck = DUCK_TICKS; + } + return state; + }, + + status(state) { + return state.over + ? `${state.over} · ${state.picks} picks` + : `${meters(state)} m · ${state.picks} picks`; + }, + + render(state) { + const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null)); + const put = (x, y, glyph) => { + if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return; + grid[y][x] = glyph; + }; + + for (const thing of state.things) { + const { cols, rows } = cells(thing); + if (thing.kind === "pick") { put(cols[0], rows[0], PICK); continue; } + const { art, paint } = HAZARDS[thing.kind]; + for (const row of rows) [...art].forEach((c, i) => put(cols[0] + i, row, paint(c))); + } + + const y = Math.round(state.y); + if (state.over) { + put(RUNNER, GROUND, danger("✷")); + } else if (state.duck > 0 && !state.airborne) { + put(RUNNER, GROUND, acid("▄")); + } else { + put(RUNNER, y - 1, acid("○")); + // The legs alternate with the stage, so standing still looks like running. + put(RUNNER, y, acid(state.airborne ? "⋏" : Math.floor(state.dist) % 2 ? "⋀" : "⋏")); + } + + return grid.map((row, ry) => row.map((cell, x) => { + if (cell) return cell; + if (ry !== FLOOR) return " "; + // The stage edge, with a mark every few cells so the speed is visible even + // when nothing else is on screen. + return (x + Math.floor(state.dist)) % 7 === 0 ? dim("╪") : ash("═"); + }).join("")); + }, +}; diff --git a/src/games-tank.mjs b/src/games-tank.mjs new file mode 100644 index 0000000..f9d52f3 --- /dev/null +++ b/src/games-tank.mjs @@ -0,0 +1,230 @@ +// Tank. Two of them in a walled yard, one shell each in the air at a time, five +// hits and it is over. +// +// Everything is on the grid and turned in quarter turns, because a tank you +// cannot line up is a tank you cannot aim, and lining up *is* the shot. Your +// keys are one action each — a press turns you or moves you one cell — so +// holding an arrow drives, and tapping it nudges. +import { acid, ash, bone, danger, dim } from "./ui.mjs"; + +export const TARGET = 5; + +/** + * The yard. `#` is wall, and the outer ring is closed — a shell that leaves the + * board is a shell nobody saw stop. + */ +export const YARD = [ + "##########################################", + "# #", + "# #### ###### #### #", + "# # # # #", + "# # #### # #### # #", + "# # # # # #", + "# ##### # # #### # # #### #", + "# # # # # #", + "# # #### # #### # #", + "# # # # #", + "# #### ###### #### #", + "# #", + "##########################################", +]; + +export const isWall = (x, y) => (YARD[y]?.[x] ?? "#") === "#"; + +// Derived from the yard rather than declared beside it, so the two can never +// disagree about how big the board is. +export const WIDTH = YARD[0].length; +export const HEIGHT = YARD.length; + +/** Quarter turns, and the glyph a tank wears pointing that way. */ +export const HEADINGS = [ + { dx: 0, dy: -1, glyph: "▲" }, + { dx: 1, dy: 0, glyph: "▶" }, + { dx: 0, dy: 1, glyph: "▼" }, + { dx: -1, dy: 0, glyph: "◀" }, +]; + +const SHELL_SPEED = 1; // cells per tick +const THEM_EVERY = 4; // the machine gets a move every this many ticks + +const spawnYou = () => ({ x: 2, y: 6, dir: 1, cool: 0 }); +const spawnThem = () => ({ x: WIDTH - 3, y: 6, dir: 3, cool: 0 }); + +/** Move a tank one cell if there is floor there. Walls simply refuse. */ +export function drive(tank, sign) { + const { dx, dy } = HEADINGS[tank.dir]; + const x = tank.x + dx * sign; + const y = tank.y + dy * sign; + if (isWall(x, y)) return false; + tank.x = x; + tank.y = y; + return true; +} + +export const fire = (tank, owner) => ({ + x: tank.x + HEADINGS[tank.dir].dx, + y: tank.y + HEADINGS[tank.dir].dy, + dir: tank.dir, + owner, +}); + +/** + * Whether a tank can see another down the barrel: same row or column, nothing + * but floor in between. This is both how the machine decides to shoot and the + * only thing it is good at. + */ +export function lineOfSight(from, to) { + const { dx, dy } = HEADINGS[from.dir]; + let x = from.x + dx; + let y = from.y + dy; + for (let i = 0; i < Math.max(WIDTH, HEIGHT); i++) { + if (isWall(x, y)) return false; + if (x === to.x && y === to.y) return true; + x += dx; + y += dy; + } + return false; +} + +/** + * The next cell on the shortest way from one point to another, and the heading + * that gets there. + * + * This is a breadth-first search of the whole yard on every decision, which + * sounds extravagant for 546 cells and is not: it is the difference between a + * tank that comes around the block after you and one that drives into the same + * wall forever, which is what "just turn towards the enemy" does the moment + * there is anything between the two of you. + */ +export function stepToward(from, to) { + if (from.x === to.x && from.y === to.y) return null; + const seen = new Set([`${from.x},${from.y}`]); + const queue = [{ x: from.x, y: from.y, first: null }]; + for (let head = 0; head < queue.length; head++) { + const cur = queue[head]; + for (let dir = 0; dir < HEADINGS.length; dir++) { + const x = cur.x + HEADINGS[dir].dx; + const y = cur.y + HEADINGS[dir].dy; + const key = `${x},${y}`; + if (isWall(x, y) || seen.has(key)) continue; + seen.add(key); + const first = cur.first ?? { x, y, dir }; + if (x === to.x && y === to.y) return first; + queue.push({ x, y, first }); + } + } + return null; +} + +/** One quarter turn from `dir` towards `want`, the short way round. */ +export const quarterTurn = (dir, want) => (dir + ((want - dir + 4) % 4 === 3 ? 3 : 1)) % 4; + +function hit(state, who) { + if (who === "you") state.yours++; else state.theirs++; + state.shells = []; + state.you = spawnYou(); + state.them = spawnThem(); + if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`; + else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`; + return state; +} + +/** One tick: shells first, then the machine takes its turn. */ +export function step(state) { + for (const shell of [...state.shells]) { + for (let i = 0; i < SHELL_SPEED; i++) { + const { dx, dy } = HEADINGS[shell.dir]; + shell.x += dx; + shell.y += dy; + if (isWall(shell.x, shell.y)) { state.shells = state.shells.filter((s) => s !== shell); break; } + const target = shell.owner === "you" ? state.them : state.you; + if (shell.x === target.x && shell.y === target.y) { + state.shells = state.shells.filter((s) => s !== shell); + hit(state, shell.owner); + return state; + } + } + } + if (state.over) return state; + + if (state.you.cool > 0) state.you.cool--; + if (state.them.cool > 0) state.them.cool--; + + state.clock++; + if (state.clock % THEM_EVERY) return state; + + // The machine: shoot if it is looking at you, otherwise turn towards you, and + // drive when it is already pointed the right way. It is not clever, but it is + // relentless, and in a yard this size that is enough. + const them = state.them; + if (lineOfSight(them, state.you)) { + if (!them.cool && !state.shells.some((s) => s.owner === "them")) { + state.shells.push(fire(them, "them")); + them.cool = 6; + } + return state; + } + const next = stepToward(them, state.you); + if (!next) return state; + if (them.dir !== next.dir) them.dir = quarterTurn(them.dir, next.dir); + else drive(them, 1); + return state; +} + +export const TANK = { + key: "tank", + aliases: ["tanks", "combat"], + title: "TANK", + blurb: "two tanks, one yard, five hits — line it up and let go", + keys: "← → turn · ↑ drive · ↓ reverse · space fire · q quit", + tickMs: 60, + + create() { + return { + you: spawnYou(), + them: spawnThem(), + shells: [], + yours: 0, + theirs: 0, + clock: 0, + over: null, + }; + }, + + tick: step, + + onKey(state, key) { + const you = state.you; + if (key === "left") you.dir = (you.dir + 3) % 4; + else if (key === "right") you.dir = (you.dir + 1) % 4; + else if (key === "up") drive(you, 1); + else if (key === "down") drive(you, -1); + else if (key === "space" || key === "enter") { + // One shell of yours in the air at a time, same as the machine. Two would + // turn a duel into a hosepipe. + if (you.cool || state.shells.some((s) => s.owner === "you")) return state; + const shell = fire(you, "you"); + if (!isWall(shell.x, shell.y)) state.shells.push(shell); + you.cool = 4; + } + return state; + }, + + status(state) { + return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`; + }, + + render(state) { + const grid = YARD.map((row) => [...row].map((c) => (c === "#" ? ash("█") : null))); + const put = (x, y, glyph) => { + if (!grid[y] || x < 0 || x >= grid[y].length) return; + grid[y][x] = glyph; + }; + + for (const shell of state.shells) put(shell.x, shell.y, (shell.owner === "you" ? bone : danger)("•")); + put(state.you.x, state.you.y, acid(HEADINGS[state.you.dir].glyph)); + put(state.them.x, state.them.y, danger(HEADINGS[state.them.dir].glyph)); + + return grid.map((row) => row.map((cell) => cell ?? dim("·")).join("")); + }, +}; diff --git a/src/games.mjs b/src/games.mjs index 551d71d..68510e8 100644 --- a/src/games.mjs +++ b/src/games.mjs @@ -1,8 +1,8 @@ // The moshcode arcade — `/games` in the pit, `moshcode games` from a shell. // -// Eight games, one frame. Every game here is the same shape (see GAME_SHAPE +// Twenty-two games, one frame. Every game here is the same shape (see GAME_SHAPE // below) and is drawn by the same `frame()`, so they look like one arcade -// rather than eight weekend projects: a title, a status line, a boxed board, and +// rather than twenty-two weekend projects: a title, a status line, a boxed board, and // one line of keys along the bottom. There is no menu, no options screen and no // difficulty prompt — `/games tetris` is already playing by the time the frame // lands, and `q` is always the way out. @@ -20,6 +20,20 @@ import { HANGMAN } from "./games-hangman.mjs"; import { CHESS } from "./games-chess.mjs"; import { ASTEROIDS } from "./games-asteroids.mjs"; import { BLACKJACK } from "./games-blackjack.mjs"; +import { STAGEDIVE } from "./games-stagedive.mjs"; +import { INVADERS } from "./games-invaders.mjs"; +import { BREAKOUT } from "./games-breakout.mjs"; +import { PONG } from "./games-pong.mjs"; +import { TANK } from "./games-tank.mjs"; +import { SPYHUNTER } from "./games-spyhunter.mjs"; +import { CENTIPEDE } from "./games-centipede.mjs"; +import { FROGGER } from "./games-frogger.mjs"; +import { DIGDUG } from "./games-digdug.mjs"; +import { KONG } from "./games-kong.mjs"; +import { PITFALL } from "./games-pitfall.mjs"; +import { CHOPLIFTER } from "./games-choplifter.mjs"; +import { EXCITEBIKE } from "./games-excitebike.mjs"; +import { OUTRUN } from "./games-outrun.mjs"; /** * @typedef {object} Game — the whole contract, so a seventh game is an import. @@ -39,7 +53,11 @@ import { BLACKJACK } from "./games-blackjack.mjs"; */ /** The cabinet. Order is the order `/games` lists them. */ -export const GAMES = [TETRIS, SNAKE, PACMAN, ASTEROIDS, TICTACTOE, BLACKJACK, CHESS, HANGMAN]; +export const GAMES = [ + TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG, + FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE, + TICTACTOE, BLACKJACK, CHESS, HANGMAN, +]; /** Games by name, following aliases. Case- and slash-insensitive. */ export function resolveGame(name) { diff --git a/test/games.test.mjs b/test/games.test.mjs index de7b299..b8c20ef 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -19,6 +19,59 @@ import { PACMAN, MAZE, isWall, pellets, WIDTH as P_WIDTH, HEIGHT as P_HEIGHT } f import { ASTEROIDS, ROCKS, rock, spawnWave, span, star, WIDTH as A_WIDTH, HEIGHT as A_HEIGHT, } from "../src/games-asteroids.mjs"; +import { + STAGEDIVE, HAZARDS, GROUND, RUNNER, cells, meters, runnerRows, spawn, + WIDTH as D_WIDTH, HEIGHT as D_HEIGHT, +} from "../src/games-stagedive.mjs"; +import { + INVADERS, KINDS, ROWS, COLS, BUNKER_ROW, CANNON_ROW, alienAt, aliveCount, cadence, chip, frontLine, + WIDTH as I_WIDTH, HEIGHT as I_HEIGHT, +} from "../src/games-invaders.mjs"; +import { + BREAKOUT, BRICK_ROWS, BRICK_COLS, BRICK_TOP, BRICK_W, PADDLE_W, PADDLE_ROW, ROW_POINTS, + brickAt, bricksLeft, buildWall, WIDTH as WIDTH_B, HEIGHT as HEIGHT_B, +} from "../src/games-breakout.mjs"; +import { + PONG, PADDLE, YOU_COL, TARGET as PONG_TARGET, WIDTH as WIDTH_P, HEIGHT as HEIGHT_P, +} from "../src/games-pong.mjs"; +import { + TANK, TARGET as TANK_TARGET, drive as driveTank, isWall as isYardWall, lineOfSight, quarterTurn, stepToward, + WIDTH as WIDTH_T, HEIGHT as HEIGHT_T, +} from "../src/games-tank.mjs"; +import { + SPYHUNTER, CAR_ROW, CAR_W, TRAFFIC, nextRow, onRoad, openRoad, overlaps, + WIDTH as SH_WIDTH, HEIGHT as SH_HEIGHT, +} from "../src/games-spyhunter.mjs"; +import { + CENTIPEDE, ZONE_TOP, bite, cadence as centCadence, newCentipede, spiderStep, walk, + WIDTH as C_WIDTH, HEIGHT as C_HEIGHT, +} from "../src/games-centipede.mjs"; +import { + FROGGER, BANK, HOMES, HOME_ROW, RIVER, ROAD, homeAt, thingAt, WIDTH as F_WIDTH, +} from "../src/games-frogger.mjs"; +import { + DIGDUG, SKY, fallRocks, isDug, pump, walkMonster, + WIDTH as DD_WIDTH, HEIGHT as DD_HEIGHT, +} from "../src/games-digdug.mjs"; +import { + KONG, GIRDERS, LADDERS, TOP as TOP_K, FLOOR as FLOOR_K, newBarrel, rollBarrel, throwEvery, + WIDTH as WIDTH_K, HEIGHT as HEIGHT_K, +} from "../src/games-kong.mjs"; +import { + PITFALL, RUNNER as PF_RUNNER, grab, span as pfSpan, spawn as spawnPitfall, + WIDTH as PF_WIDTH, HEIGHT as PF_HEIGHT, +} from "../src/games-pitfall.mjs"; +import { + CHOPLIFTER, BASE, SEATS, WORLD, GROUND as GROUND_CH, onPad, + WIDTH as CH_WIDTH, HEIGHT as CH_HEIGHT, +} from "../src/games-choplifter.mjs"; +import { + EXCITEBIKE, LAND_OK, RIDER, speedOf, WIDTH as EB_WIDTH, HEIGHT as EB_HEIGHT, +} from "../src/games-excitebike.mjs"; +import { + OUTRUN, PLAYER_ROW, centreAt, onRoad as onTarmac, roadHalf, + WIDTH as OR_WIDTH, HEIGHT as OR_HEIGHT, +} from "../src/games-outrun.mjs"; import { TICTACTOE, bestMove, emptyBoard as emptyGrid, winner } from "../src/games-tictactoe.mjs"; import { BLACKJACK, MIN_BET, canSplit, freshDeck, handValue, isBlackjack, settle, @@ -475,6 +528,1490 @@ test("the asteroids board is drawn to size", () => { assert.ok(byRow.size >= 8, "a sky with no stars in it"); }); +/* --------------------------------------------------------------- invaders */ + +/** Run a game's own tick, calling `act(state, i)` first. Used by all five below. */ +function drive(game, state, ticks, act = () => {}) { + for (let i = 0; i < ticks && !state.over; i++) { + act(state, i); + state = game.tick(state); + } + return state; +} + +test("the fleet has room to come down before it lands", () => { + // The bug this replaces: five ranks two rows apart made the fleet nine rows + // tall on a board with eleven above the bunkers, so it "landed" after two + // drops and no wave was ever survivable. + const state = INVADERS.create({ rng: seeded(1) }); + const bottom = alienAt(state.fleet, ROWS - 1, 0).y; + assert.ok(BUNKER_ROW - bottom >= 6, `only ${BUNKER_ROW - bottom} drops before the fleet lands`); + assert.ok(bottom < BUNKER_ROW, "the fleet starts on top of the bunkers"); +}); + +test("a shot takes the alien it reaches, and is worth that rank", () => { + const state = INVADERS.create({ rng: seeded(2) }); + state.bunkers = new Map(); // your own cover eats your own shots; not this test + const target = alienAt(state.fleet, ROWS - 1, 3); + state.cannon = target.x; + INVADERS.onKey(state, "space"); + const after = drive(INVADERS, state, 12, (s) => { s.bombs = []; }); + assert.equal(after.fleet.alive[ROWS - 1][3], false, "the alien should be gone"); + assert.equal(after.score, KINDS[ROWS - 1].points); + assert.equal(after.shot, null, "and the shot is spent"); +}); + +test("a shot moving two rows a tick cannot skip the rank it passes", () => { + const state = INVADERS.create({ rng: seeded(3) }); + const alien = alienAt(state.fleet, ROWS - 1, 2); + // Start the shot an odd number of rows below, so a naive two-row step lands + // above the alien without ever standing on it. + state.shot = { x: alien.x, y: alien.y + 3 }; + state.bombs = []; + INVADERS.tick(state); + INVADERS.tick(state); + assert.equal(state.fleet.alive[ROWS - 1][2], false, "the shot went straight through it"); +}); + +test("the fewer are left, the faster they come", () => { + const state = INVADERS.create({ rng: seeded(4) }); + const full = cadence(state.fleet); + for (let col = 0; col < COLS; col++) for (let row = 0; row < ROWS - 1; row++) state.fleet.alive[row][col] = false; + for (let col = 1; col < COLS; col++) state.fleet.alive[ROWS - 1][col] = false; + assert.equal(aliveCount(state.fleet), 1); + assert.ok(cadence(state.fleet) < full, "the last one should be the fastest"); + assert.ok(cadence(state.fleet) >= 2, "but never faster than the clock"); +}); + +test("the fleet turns round at the wall and drops a row", () => { + const state = INVADERS.create({ rng: seeded(5) }); + state.bombs = []; + const startY = state.fleet.y; + const startDir = state.fleet.dir; + let turns = 0; + let was = startDir; + drive(INVADERS, state, 600, (s) => { if (s.fleet.dir !== was) { turns++; was = s.fleet.dir; } }); + assert.ok(state.fleet.y > startY, "it should have come down a row"); + assert.ok(turns > 0, "and turned round to do it"); + assert.equal(state.fleet.y - startY, turns, "one row down per turn, no more"); +}); + +test("a bunker takes two hits from either side, then it is gone", () => { + const state = INVADERS.create({ rng: seeded(6) }); + const [x] = [...state.bunkers.keys()]; + assert.equal(chip(state.bunkers, x, BUNKER_ROW), true); + assert.equal(state.bunkers.get(x), 1, "chipped, not gone"); + assert.equal(chip(state.bunkers, x, BUNKER_ROW), true); + assert.equal(state.bunkers.has(x), false, "gone"); + assert.equal(chip(state.bunkers, x, BUNKER_ROW), false, "and nothing left to chip"); + assert.equal(chip(state.bunkers, x, BUNKER_ROW - 1), false, "bunkers are only on their own row"); +}); + +test("a bomb takes a life, and the last one ends it", () => { + const state = INVADERS.create({ rng: seeded(7) }); + state.bunkers = new Map(); + state.lives = 2; + state.bombs = [{ x: state.cannon, y: CANNON_ROW - 1 }]; + drive(INVADERS, state, 6, (s) => { if (!s.bombs.length && !s.over) s.bombs = [{ x: s.cannon, y: CANNON_ROW - 1 }]; }); + assert.ok(state.lives < 2, "a bomb on your head should cost you"); + const last = drive(INVADERS, state, 40, (s) => { if (!s.bombs.length && !s.over) s.bombs = [{ x: s.cannon, y: CANNON_ROW - 1 }]; }); + assert.match(last.over, /out of cannons/); +}); + +test("clearing the fleet brings a new one, lower down", () => { + const state = INVADERS.create({ rng: seeded(8) }); + for (let row = 0; row < ROWS; row++) for (let col = 0; col < COLS; col++) state.fleet.alive[row][col] = false; + state.fleet.alive[0][0] = true; + const at = alienAt(state.fleet, 0, 0); + const startY = state.fleet.y; + state.shot = { x: at.x, y: at.y + 2 }; + INVADERS.tick(state); + assert.equal(state.wave, 2); + assert.equal(aliveCount(state.fleet), ROWS * COLS, "a whole new fleet"); + assert.ok(state.fleet.y > startY, "and it starts closer to you"); +}); + +test("one shot in the air at a time", () => { + const state = INVADERS.create({ rng: seeded(9) }); + INVADERS.onKey(state, "space"); + const first = state.shot; + INVADERS.onKey(state, "space"); + assert.equal(state.shot, first, "the second press should do nothing"); +}); + +test("the fleet cannot out-march somebody aiming at it", () => { + // A player who picks the nearest alien on the front line and stays on it + // until it is dead. It does not dodge a single bomb, which is exactly why the + // interesting result below is *how* it dies. + const aiming = (state) => { + const front = frontLine(state.fleet); + if (!front.length) return; + if (!state.aim || !state.fleet.alive[state.aim.row]?.[state.aim.col]) { + state.aim = front.slice().sort((a, b) => ( + Math.abs(alienAt(state.fleet, a.row, a.col).x - state.cannon) + - Math.abs(alienAt(state.fleet, b.row, b.col).x - state.cannon) + ))[0]; + } + const want = alienAt(state.fleet, state.aim.row, state.aim.col).x; + if (state.cannon < want) INVADERS.onKey(state, "right"); + else if (state.cannon > want) INVADERS.onKey(state, "left"); + else INVADERS.onKey(state, "space"); + }; + + let cleared = 0; + for (let seed = 1; seed <= 8; seed++) { + const state = drive(INVADERS, INVADERS.create({ rng: seeded(seed) }), 3000, aiming); + // The regression that matters: every one of these runs ends with the bombs + // getting you, never with the fleet walking over you. When the ranks were + // spaced two rows apart the fleet landed on every seed instead, and no + // amount of shooting could stop it. + assert.match(state.over ?? "", /out of cannons/, `seed ${seed} ended: ${state.over}`); + assert.ok(state.score >= 500, `seed ${seed} only managed ${state.score} of a 720-point wave`); + if (state.wave > 1) cleared++; + } + assert.ok(cleared >= 4, `only ${cleared} of 8 waves fell to somebody aiming`); + + for (let seed = 1; seed <= 8; seed++) { + const idle = drive(INVADERS, INVADERS.create({ rng: seeded(seed) }), 2500); + assert.ok(idle.over, `seed ${seed} survived without firing a shot`); + } +}); + +test("the invaders board is drawn to size", () => { + const state = drive(INVADERS, INVADERS.create({ rng: seeded(10) }), 120); + const rows = INVADERS.render(state); + assert.equal(rows.length, I_HEIGHT); + for (const row of rows) assert.equal(visible(row), I_WIDTH); +}); + +/* -------------------------------------------------------------- centipede */ + +test("a shot to the middle leaves a mushroom and two centipedes", () => { + const state = CENTIPEDE.create({ rng: seeded(1) }); + state.field = new Map(); + state.centipede = newCentipede(6); + const middle = state.centipede[3]; + state.shots = [{ x: middle.x, y: middle.y + 1 }]; + CENTIPEDE.tick(state); + assert.equal(state.centipede.length, 5, "the segment is gone"); + assert.equal(state.centipede.includes(middle), false); + assert.ok(state.field.size >= 1, "and it left a mushroom where it fell"); + assert.equal(state.score >= 10, true); + // The pieces either side carry on independently, which is what splitting is. + const before = state.centipede.map((s) => s.x); + for (let i = 0; i < 20; i++) CENTIPEDE.tick(state); + assert.notDeepEqual(state.centipede.map((s) => s.x), before, "both halves should still be walking"); +}); + +test("a mushroom takes four hits", () => { + const field = new Map(); + field.set("5,5", 4); + assert.equal(bite(field, 5, 5), 1); + assert.equal(bite(field, 5, 5), 1); + assert.equal(bite(field, 5, 5), 1); + assert.equal(bite(field, 5, 5), 5, "the last hit is the one worth points"); + assert.equal(field.has("5,5"), false); + assert.equal(bite(field, 5, 5), 0, "and nothing is left to shoot"); +}); + +test("it turns and drops at a wall, at a mushroom, and off the floor", () => { + const state = CENTIPEDE.create({ rng: seeded(2) }); + state.field = new Map(); + const seg = { x: C_WIDTH - 1, y: 3, dir: 1, down: 1 }; + walk(state, seg); + assert.deepEqual([seg.x, seg.y, seg.dir], [C_WIDTH - 1, 4, -1], "the wall turns it and drops it"); + + state.field.set(`${seg.x - 1},${seg.y}`, 4); + walk(state, seg); + assert.equal(seg.y, 5, "a mushroom does the same thing a wall does"); + + const floor = { x: 5, y: C_HEIGHT - 1, dir: 1, down: 1 }; + state.field = new Map([[`6,${C_HEIGHT - 1}`, 4]]); + walk(state, floor); + assert.equal(floor.down, -1, "off the floor it starts climbing back up"); +}); + +test("you are confined to the bottom strip", () => { + const state = CENTIPEDE.create({ rng: seeded(3) }); + state.field = new Map(); + for (let i = 0; i < 20; i++) CENTIPEDE.onKey(state, "up"); + assert.equal(state.player.y, ZONE_TOP, "the strip is as far up as you go"); + for (let i = 0; i < 60; i++) CENTIPEDE.onKey(state, "left"); + assert.equal(state.player.x, 0); +}); + +test("a segment that reaches you costs a life, and the last one ends it", () => { + const state = CENTIPEDE.create({ rng: seeded(4) }); + state.field = new Map(); + state.lives = 1; + state.centipede = [{ x: state.player.x - 1, y: state.player.y, dir: 1, down: 1 }]; + state.clock = 99; + CENTIPEDE.tick(state); + assert.match(state.over, /eaten/); +}); + +test("clearing it brings a faster wave", () => { + const state = CENTIPEDE.create({ rng: seeded(5) }); + const slow = centCadence(state); + state.centipede = []; + CENTIPEDE.tick(state); + assert.equal(state.wave, 2); + assert.equal(state.centipede.length, 10, "a whole new one"); + assert.ok(centCadence(state) < slow, "and it comes down quicker"); +}); + +test("the spider crosses your strip, eats what it walks over, and leaves", () => { + const state = CENTIPEDE.create({ rng: seeded(7) }); + state.spider = { x: 0, y: C_HEIGHT - 2, dx: 1, dy: 1 }; + state.field.set(`1,${C_HEIGHT - 1}`, 4); + spiderStep(state); + assert.equal(state.field.has(`1,${C_HEIGHT - 1}`), false, "a mushroom it walks over is gone whole"); + for (let i = 0; i < C_WIDTH + 2; i++) spiderStep(state); + assert.equal(state.spider, null, "and it walks out the far side rather than living there"); + + const shot = CENTIPEDE.create({ rng: seeded(8) }); + shot.field = new Map(); + shot.spider = { x: 10, y: C_HEIGHT - 2, dx: 1, dy: 1 }; + shot.shots = [{ x: 10, y: C_HEIGHT - 1 }]; + CENTIPEDE.tick(shot); + assert.equal(shot.spider, null); + assert.equal(shot.score, 300, "and it is the best thing on the board to shoot"); +}); + +test("the centipede board is drawn to size", () => { + const state = CENTIPEDE.create({ rng: seeded(6) }); + for (let i = 0; i < 80; i++) CENTIPEDE.tick(state); + const rows = CENTIPEDE.render(state); + assert.equal(rows.length, C_HEIGHT); + for (const row of rows) assert.equal(visible(row), C_WIDTH); +}); + +test("centipede can be cleared by shooting, and not by standing there", () => { + // Chase the nearest segment's column and keep firing. + const shooting = (state) => { + const near = state.centipede.slice().sort((a, b) => ( + Math.abs(a.x - state.player.x) - Math.abs(b.x - state.player.x)))[0]; + if (!near) return; + if (state.player.x < near.x) CENTIPEDE.onKey(state, "right"); + else if (state.player.x > near.x) CENTIPEDE.onKey(state, "left"); + CENTIPEDE.onKey(state, "space"); + }; + let cleared = 0; + for (let seed = 1; seed <= 6; seed++) { + const state = drive(CENTIPEDE, CENTIPEDE.create({ rng: seeded(seed) }), 2000, shooting); + if (state.wave > 1) cleared++; + } + assert.ok(cleared >= 5, `only ${cleared} of 6 waves fell to somebody shooting`); + for (let seed = 1; seed <= 6; seed++) { + const idle = drive(CENTIPEDE, CENTIPEDE.create({ rng: seeded(seed) }), 3000); + assert.ok(idle.over, `seed ${seed} survived without firing`); + } +}); + +/* ----------------------------------------------------------------- digdug */ + +test("moving is digging, and the tunnel is wherever you have been", () => { + const state = DIGDUG.create({ rng: seeded(1) }); + const { x, y } = state.player; + assert.equal(isDug(state.ground, x, y), true, "you start in a hole of your own"); + assert.equal(isDug(state.ground, x, y + 1), false, "and everything under you is solid"); + DIGDUG.onKey(state, "down"); + assert.equal(isDug(state.ground, x, y + 1), true, "one step down is one cell dug"); + assert.equal(state.player.dir, "down", "and you are facing the way you dug"); +}); + +test("the harpoon only travels down a tunnel", () => { + const state = DIGDUG.create({ rng: seeded(2) }); + state.monsters = [{ x: state.player.x + 3, y: state.player.y, dir: "left", pumped: 0, ghost: 0 }]; + state.player.dir = "right"; + pump(state); + assert.equal(state.harpoon, null, "three cells of solid ground stops it"); + + for (let i = 1; i <= 3; i++) state.ground.delete(`${state.player.x + i},${state.player.y}`); + pump(state); + assert.ok(state.harpoon, "dug out, it reaches"); + assert.equal(state.monsters[0].pumped, 1); +}); + +test("three pumps pops a monster, and moving lets it go", () => { + const state = DIGDUG.create({ rng: seeded(3) }); + const monster = { x: state.player.x + 1, y: state.player.y, dir: "left", pumped: 0, ghost: 0 }; + state.monsters = [monster]; + state.ground.delete(`${monster.x},${monster.y}`); + state.player.dir = "right"; + pump(state); + assert.equal(monster.pumped, 1); + pump(state); + pump(state); + assert.equal(state.monsters.length, 0, "the third pump is the last one"); + assert.ok(state.score >= 300); + + const let_go = DIGDUG.create({ rng: seeded(4) }); + const other = { x: let_go.player.x + 1, y: let_go.player.y, dir: "left", pumped: 0, ghost: 0 }; + let_go.monsters = [other]; + let_go.ground.delete(`${other.x},${other.y}`); + let_go.player.dir = "right"; + pump(let_go); + DIGDUG.onKey(let_go, "left"); + assert.equal(let_go.harpoon, null, "walking away drops the harpoon"); +}); + +test("a hooked monster stops moving", () => { + const state = DIGDUG.create({ rng: seeded(5) }); + const monster = { x: state.player.x + 1, y: state.player.y, dir: "left", pumped: 2, ghost: 0 }; + state.monsters = [monster]; + const at = { x: monster.x, y: monster.y }; + for (let i = 0; i < 30; i++) walkMonster(state, monster); + assert.deepEqual({ x: monster.x, y: monster.y }, at, "being pumped is being pinned"); +}); + +test("a rock with nothing under it falls, and lands on what is below", () => { + const state = DIGDUG.create({ rng: seeded(6) }); + state.monsters = []; + const rock = { x: 10, y: SKY + 2, falling: false }; + state.rocks = [rock]; + state.ground.delete(`10,${SKY + 3}`); + state.monsters = [{ x: 10, y: SKY + 3, dir: "left", pumped: 0, ghost: 0 }]; + fallRocks(state); + assert.equal(rock.y, SKY + 3, "it came down a row"); + assert.equal(state.monsters.length, 0, "onto the monster underneath"); + assert.ok(state.score >= 200); +}); + +test("the digdug board is drawn to size", () => { + const state = drive(DIGDUG, DIGDUG.create({ rng: seeded(7) }), 100); + const rows = DIGDUG.render(state); + assert.equal(rows.length, DD_HEIGHT); + for (const row of rows) assert.equal(visible(row), DD_WIDTH); +}); + +test("a level can be dug out, and standing still is not a plan", () => { + const hunting = (state) => { + const near = state.monsters.slice().sort((a, b) => ( + Math.abs(a.x - state.player.x) + Math.abs(a.y - state.player.y) + - Math.abs(b.x - state.player.x) - Math.abs(b.y - state.player.y)))[0]; + if (!near) return; + const dx = near.x - state.player.x; + const dy = near.y - state.player.y; + const facing = { left: dx < 0 && dy === 0, right: dx > 0 && dy === 0, up: dy < 0 && dx === 0, down: dy > 0 && dx === 0 }; + const lined = (dx === 0 || dy === 0) && Math.abs(dx) + Math.abs(dy) <= 5; + if (state.harpoon || (lined && facing[state.player.dir])) DIGDUG.onKey(state, "space"); + else if (Math.abs(dx) > Math.abs(dy)) DIGDUG.onKey(state, dx > 0 ? "right" : "left"); + else DIGDUG.onKey(state, dy > 0 ? "down" : "up"); + }; + let cleared = 0; + for (let seed = 1; seed <= 6; seed++) { + const state = drive(DIGDUG, DIGDUG.create({ rng: seeded(seed) }), 4000, hunting); + if (state.level > 1) cleared++; + } + assert.ok(cleared >= 3, `only ${cleared} of 6 levels were dug out`); + for (let seed = 1; seed <= 6; seed++) { + const idle = drive(DIGDUG, DIGDUG.create({ rng: seeded(seed) }), 3000); + assert.ok(idle.over, `seed ${seed} survived standing in its hole`); + } +}); + +/* ------------------------------------------------------------------- kong */ + +test("every girder has a way off it, and the ladders line up with the girders", () => { + for (const [i, girder] of GIRDERS.entries()) { + if (i === GIRDERS.length - 1) { + assert.equal(girder.ladder, null, "the floor has nowhere further down"); + continue; + } + const down = LADDERS.filter((l) => l.top === girder.y); + assert.equal(down.length, 2, `girder ${girder.y} should have a barrel ladder and one of yours`); + for (const ladder of down) assert.equal(ladder.bottom, GIRDERS[i + 1].y, "a ladder must reach the next girder"); + assert.equal(down.filter((l) => l.barrels).length, 1, "and only one of them is the barrel chute"); + } + // The two directions alternate, which is what makes you walk into the barrels + // rather than after them. + for (let i = 1; i < GIRDERS.length; i++) { + assert.notEqual(GIRDERS[i].dir, GIRDERS[i - 1].dir, "girders should alternate"); + } +}); + +test("a barrel crosses a girder and takes the chute down", () => { + const state = KONG.create({ rng: () => 0 }); // always takes the ladder + const barrel = newBarrel(); + state.barrels = [barrel]; + const top = GIRDERS[0]; + // Roll until it has both crossed the girder and finished coming down. + for (let i = 0; i < WIDTH_K * 3 && (barrel.y === top.y || barrel.falling !== null); i++) { + rollBarrel(state, barrel); + } + assert.equal(barrel.y, GIRDERS[1].y, "it should have come down to the next girder"); + assert.equal(barrel.x, top.ladder, "down the chute, not off the end"); +}); + +test("a barrel that misses the chute rolls off the world", () => { + const state = KONG.create({ rng: () => 0.99 }); // never takes the ladder + const barrel = newBarrel(); + state.barrels = [barrel]; + for (let i = 0; i < WIDTH_K * 2 && !barrel.done; i++) rollBarrel(state, barrel); + assert.equal(barrel.done, true); +}); + +test("ladders are the only way up, and only from on one", () => { + const state = KONG.create({ rng: seeded(1) }); + const start = state.player.y; + KONG.onKey(state, "up"); + assert.equal(state.player.y, start, "you cannot climb thin air"); + const ladder = LADDERS.find((l) => l.bottom === start); + state.player.x = ladder.x; + KONG.onKey(state, "up"); + assert.equal(state.player.y, start - 1, "on a ladder you can"); + for (let i = 0; i < 10; i++) KONG.onKey(state, "up"); + assert.equal(state.player.y, ladder.top, "and it stops at the girder above"); +}); + +test("a barrel flattens you unless you are over it", () => { + const flat = KONG.create({ rng: seeded(2) }); + flat.barrels = [{ x: flat.player.x, y: flat.player.y, falling: null }]; + KONG.tick(flat); + assert.equal(flat.lives, 2); + + const jumped = KONG.create({ rng: seeded(2) }); + jumped.barrels = [{ x: jumped.player.x, y: jumped.player.y, falling: null }]; + KONG.onKey(jumped, "space"); + KONG.tick(jumped); + assert.equal(jumped.lives, 3, "in the air it goes under you"); + assert.equal(jumped.score, 100, "and it is worth something"); +}); + +test("reaching the top is the next level, and faster", () => { + const state = KONG.create({ rng: seeded(3) }); + const slow = throwEvery(state); + state.player = { x: WIDTH_K - 6, y: TOP_K, jump: 0 }; + KONG.tick(state); + assert.equal(state.level, 2); + assert.ok(state.score >= 1000); + assert.equal(state.player.y, FLOOR_K, "and you start again at the bottom"); + assert.ok(throwEvery(state) < slow, "with barrels coming quicker"); +}); + +test("the climb can be made, and cannot be made by standing at the bottom", () => { + const climbing = (state, i) => { + if (i % 2) return; + const p = state.player; + const near = state.barrels.find((b) => b.y === p.y && Math.abs(b.x - p.x) <= 2); + if (near && !p.jump) { KONG.onKey(state, "space"); return; } + if (p.y === TOP_K) { KONG.onKey(state, "right"); return; } + if (!GIRDERS.some((g) => g.y === p.y)) { KONG.onKey(state, "up"); return; } + // Climb the ladder the barrels do not come down. + const up = LADDERS.filter((l) => l.bottom === p.y).sort((a, b) => a.barrels - b.barrels)[0]; + if (!up) return; + if (p.x === up.x) KONG.onKey(state, "up"); + else KONG.onKey(state, up.x > p.x ? "right" : "left"); + }; + const climbed = drive(KONG, KONG.create({ rng: seeded(1) }), 4000, climbing); + assert.ok(climbed.level > 3, `only got to level ${climbed.level}`); + for (let seed = 1; seed <= 5; seed++) { + // A barrel has to cross four girders to reach the floor, so this takes a + // while — but it always arrives. + const idle = drive(KONG, KONG.create({ rng: seeded(seed) }), 3000); + assert.ok(idle.over, `seed ${seed} survived at the bottom of the board`); + } +}); + +test("the kong board is drawn to size", () => { + const state = drive(KONG, KONG.create({ rng: seeded(4) }), 200); + const rows = KONG.render(state); + assert.equal(rows.length, HEIGHT_K); + for (const row of rows) assert.equal(visible(row), WIDTH_K); +}); + +/* ---------------------------------------------------------------- frogger */ + +test("the road kills what it touches and the river kills what it does not", () => { + const flat = FROGGER.create(); + flat.frog = { x: 0, row: ROAD[0], drift: null }; + flat.traffic = [{ row: ROAD[0], x: 0, len: 2, kind: "car" }]; + FROGGER.tick(flat); + assert.equal(flat.lives, 2, "a car you are standing on is a car that got you"); + + const wet = FROGGER.create(); + wet.frog = { x: 0, row: RIVER[0], drift: 0 }; + wet.traffic = []; + FROGGER.tick(wet); + assert.equal(wet.lives, 2, "and empty water is just as fatal"); + + const dry = FROGGER.create(); + dry.frog = { x: 2, row: RIVER[0], drift: 2 }; + dry.traffic = [{ row: RIVER[0], x: 0, len: 6, kind: "log" }]; + FROGGER.tick(dry); + assert.equal(dry.lives, 3, "a log is dry land"); +}); + +test("a log carries you, including off the end of the world", () => { + const state = FROGGER.create(); + state.traffic = [{ row: RIVER[0], x: 10, len: 6, kind: "log" }]; + state.frog = { x: 12, row: RIVER[0], drift: 12 }; + const before = state.frog.x; + for (let i = 0; i < 20; i++) FROGGER.tick(state); + assert.notEqual(state.frog.x, before, "the river should have moved you"); + + const edge = FROGGER.create(); + edge.traffic = [{ row: RIVER[0], x: F_WIDTH - 4, len: 6, kind: "log" }]; + edge.frog = { x: F_WIDTH - 1, row: RIVER[0], drift: F_WIDTH - 1 }; + for (let i = 0; i < 40 && edge.lives === 3; i++) FROGGER.tick(edge); + assert.equal(edge.lives, 2, "riding it off the edge still loses the frog"); +}); + +test("a home is a home, and the bank between them is not", () => { + const state = FROGGER.create(); + state.frog = { x: HOMES[0], row: HOME_ROW + 1, drift: null }; + state.traffic = []; + FROGGER.onKey(state, "up"); + assert.equal(state.homes[0], true); + assert.ok(state.score >= 100); + assert.equal(state.frog.row, BANK, "and you start again from the bank"); + + const missed = FROGGER.create(); + missed.frog = { x: HOMES[0] + HOMES.length, row: HOME_ROW + 1, drift: null }; + missed.traffic = []; + FROGGER.onKey(missed, "up"); + assert.equal(missed.lives, 2, "landing between the homes is a loss"); + + const taken = FROGGER.create(); + taken.homes[1] = true; + taken.frog = { x: HOMES[1], row: HOME_ROW + 1, drift: null }; + taken.traffic = []; + FROGGER.onKey(taken, "up"); + assert.equal(taken.lives, 2, "and so is one you have already filled"); +}); + +test("five frogs home is the next level", () => { + const state = FROGGER.create(); + state.traffic = []; + for (const home of HOMES) { + state.frog = { x: home, row: HOME_ROW + 1, drift: null }; + FROGGER.onKey(state, "up"); + } + assert.equal(state.level, 2); + assert.deepEqual(state.homes, HOMES.map(() => false), "and five empty homes again"); +}); + +test("the lanes run on for ever", () => { + const state = FROGGER.create(); + state.frog = { x: 0, row: BANK, drift: null }; // out of the way on the bank + for (let i = 0; i < 2000; i++) FROGGER.tick(state); + for (const thing of state.traffic) { + assert.ok(thing.x > -thing.len - 3 && thing.x < F_WIDTH + 3, `a ${thing.kind} escaped to ${thing.x}`); + } +}); + +test("hopping forwards pays, hopping back and forth does not", () => { + const state = FROGGER.create(); + state.traffic = []; + FROGGER.onKey(state, "up"); + const forward = state.score; + assert.ok(forward > 0); + FROGGER.onKey(state, "down"); + FROGGER.onKey(state, "left"); + assert.equal(state.score, forward, "only forwards is progress"); +}); + +test("a frog can be got home, and not by hopping blind", () => { + // Wait for a gap in the lane ahead, then hop. That is the entire game. + const patient = (state) => { + const next = state.frog.row - 1; + if (next === HOME_ROW) { + if (homeAt(state.frog.x) >= 0 && !state.homes[homeAt(state.frog.x)]) FROGGER.onKey(state, "up"); + else FROGGER.onKey(state, state.frog.x < HOMES[0] ? "right" : "left"); + return; + } + const blocked = ROAD.includes(next) && thingAt(state.traffic, next, state.frog.x); + const wet = RIVER.includes(next) && !thingAt(state.traffic, next, state.frog.x); + if (!blocked && !wet) FROGGER.onKey(state, "up"); + }; + const state = drive(FROGGER, FROGGER.create(), 4000, patient); + assert.ok(state.homes.filter(Boolean).length > 0 || state.level > 1, "nobody got home at all"); + assert.ok(state.score >= 100, `only scored ${state.score}`); + + const blind = drive(FROGGER, FROGGER.create(), 400, (s) => FROGGER.onKey(s, "up")); + assert.ok(blind.over, "hopping without looking should not survive"); +}); + +/* --------------------------------------------------------------- breakout */ + +test("a brick is hit exactly where it is drawn", () => { + const wall = buildWall(); + assert.equal(bricksLeft(wall), BRICK_ROWS * BRICK_COLS); + for (let i = 0; i < BRICK_W; i++) { + assert.deepEqual(brickAt(wall, i, BRICK_TOP), { row: 0, col: 0 }, "the whole width of a brick is that brick"); + } + assert.deepEqual(brickAt(wall, BRICK_W, BRICK_TOP), { row: 0, col: 1 }); + assert.equal(brickAt(wall, 0, BRICK_TOP - 1), null, "nothing above the wall"); + assert.equal(brickAt(wall, 0, BRICK_TOP + BRICK_ROWS), null, "nothing below it"); +}); + +test("a brick breaks, pays its row, and turns the ball around", () => { + const state = BREAKOUT.create({ rng: seeded(1) }); + state.stuck = false; + state.ball = { x: 2, y: BRICK_TOP + BRICK_ROWS - 0.4, vx: 0.1, vy: -0.4 }; + BREAKOUT.tick(state); + assert.equal(bricksLeft(state.wall), BRICK_ROWS * BRICK_COLS - 1); + assert.equal(state.score, ROW_POINTS[BRICK_ROWS - 1], "the bottom row is the cheap one"); + assert.ok(state.ball.vy > 0, "and the ball comes back down"); +}); + +test("the last brick starts the next level with a fresh wall", () => { + const state = BREAKOUT.create({ rng: seeded(2) }); + state.wall = state.wall.map((row) => row.map(() => false)); + state.wall[0][0] = true; + state.stuck = false; + state.ball = { x: 1, y: BRICK_TOP + 0.6, vx: 0, vy: -0.5 }; + BREAKOUT.tick(state); + assert.equal(state.level, 2); + assert.equal(bricksLeft(state.wall), BRICK_ROWS * BRICK_COLS, "a whole new wall"); + assert.equal(state.stuck, true, "and the ball is back on the paddle"); + assert.ok(state.pace > 1, "faster than the last one"); +}); + +test("the paddle is a steering wheel, not a wall", () => { + const middle = (vx) => { + const state = BREAKOUT.create({ rng: seeded(3) }); + state.stuck = false; + state.paddle = 10; + state.ball = { x: 10 + (PADDLE_W - 1) / 2, y: PADDLE_ROW - 1.2, vx, vy: 0.4 }; + BREAKOUT.tick(state); + return state.ball.vx; + }; + const edge = () => { + const state = BREAKOUT.create({ rng: seeded(3) }); + state.stuck = false; + state.paddle = 10; + state.ball = { x: 10 + PADDLE_W - 1, y: PADDLE_ROW - 1.2, vx: 0.3, vy: 0.4 }; + BREAKOUT.tick(state); + return state.ball.vx; + }; + assert.ok(edge() > middle(0.3), "taking it off the end should send it wider"); +}); + +test("missing the ball costs a life, and the third ends it", () => { + const state = BREAKOUT.create({ rng: seeded(4) }); + state.stuck = false; + state.paddle = 0; + state.ball = { x: WIDTH_B - 1, y: PADDLE_ROW, vx: 0, vy: 0.5 }; + BREAKOUT.tick(state); + assert.equal(state.lives, 2); + assert.equal(state.stuck, true, "and the next ball waits on the paddle"); + + state.lives = 1; + state.stuck = false; + state.ball = { x: WIDTH_B - 1, y: PADDLE_ROW, vx: 0, vy: 0.5 }; + BREAKOUT.tick(state); + assert.match(state.over, /out of balls/); +}); + +test("the ball rides the paddle until it is launched", () => { + const state = BREAKOUT.create({ rng: seeded(5) }); + BREAKOUT.onKey(state, "left"); + BREAKOUT.tick(state); + assert.equal(state.ball.x, state.paddle + PADDLE_W / 2, "aiming the serve is done with the paddle"); + BREAKOUT.onKey(state, "space"); + assert.equal(state.stuck, false); + assert.ok(state.ball.vy < 0, "and it goes up"); +}); + +test("a wall can be cleared, and cannot be cleared by leaving the paddle alone", () => { + const tracking = (state) => { + if (state.stuck) BREAKOUT.onKey(state, "space"); + const want = Math.round(state.ball.x) - Math.floor(PADDLE_W / 2); + if (want < state.paddle) BREAKOUT.onKey(state, "left"); + else if (want > state.paddle) BREAKOUT.onKey(state, "right"); + }; + for (let seed = 1; seed <= 5; seed++) { + const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 6000, tracking); + assert.ok(state.level > 1, `seed ${seed} never cleared a wall`); + } + for (let seed = 1; seed <= 5; seed++) { + const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 3000, (s) => { + if (s.stuck) BREAKOUT.onKey(s, "space"); + }); + assert.ok(state.over, `seed ${seed} survived without touching the paddle`); + } +}); + +test("the breakout board is drawn to size", () => { + const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(6) }), 60, (s) => { + if (s.stuck) BREAKOUT.onKey(s, "space"); + }); + const rows = BREAKOUT.render(state); + assert.equal(rows.length, HEIGHT_B); + for (const row of rows) assert.equal(visible(row), WIDTH_B); +}); + +/* ------------------------------------------------------------------- pong */ + +test("a serve is never dead flat", () => { + for (let seed = 1; seed <= 20; seed++) { + const state = PONG.create({ rng: seeded(seed) }); + assert.ok(Math.abs(state.ball.vy) > 0.1, "a flat serve is a rally nobody can lose"); + assert.ok(Math.abs(state.ball.vx) > 0); + } +}); + +test("the ball stays on the table", () => { + const state = PONG.create({ rng: seeded(2) }); + const seen = drive(PONG, state, 4000, (s) => { + assert.ok(s.ball.y >= -0.5 && s.ball.y <= HEIGHT_P - 0.5, `ball left the table at ${s.ball.y}`); + }); + assert.ok(seen.yours + seen.theirs > 0, "somebody should have scored by now"); +}); + +test("where it hits the paddle is where it goes", () => { + const bounce = (at) => { + const state = PONG.create({ rng: seeded(3) }); + state.you = 5; + state.ball = { x: YOU_COL + 0.4, y: at, vx: -0.9, vy: 0 }; + PONG.tick(state); + return state.ball.vy; + }; + assert.ok(bounce(5) < 0, "off the top of the paddle sends it up"); + assert.ok(bounce(8) > 0, "off the bottom sends it down"); + assert.ok(Math.abs(bounce(6.5)) > 0, "and never dead flat, even off the middle"); +}); + +test("a ball past the paddle is a point, and seven of them is the match", () => { + const state = PONG.create({ rng: seeded(4) }); + state.you = 0; + state.ball = { x: 0.5, y: HEIGHT_P - 1, vx: -1, vy: 0 }; + PONG.tick(state); + assert.equal(state.theirs, 1, "missing it should cost a point"); + + state.theirs = PONG_TARGET - 1; + state.you = 0; + state.ball = { x: 0.5, y: HEIGHT_P - 1, vx: -1, vy: 0 }; + PONG.tick(state); + assert.match(state.over, /the machine takes it/); +}); + +test("the machine can be beaten, but not by doing nothing", () => { + const tracking = (state) => { + const want = state.ball.y - (PADDLE - 1) / 2; + if (want < state.you - 0.5) PONG.onKey(state, "up"); + else if (want > state.you + 0.5) PONG.onKey(state, "down"); + }; + for (let seed = 1; seed <= 6; seed++) { + const won = drive(PONG, PONG.create({ rng: seeded(seed) }), 30000, tracking); + assert.match(won.over ?? "", /you take it/, `seed ${seed}: ${won.yours}–${won.theirs}`); + const lost = drive(PONG, PONG.create({ rng: seeded(seed) }), 30000); + assert.match(lost.over ?? "", /machine takes it/, "a still paddle should lose"); + } +}); + +test("the pong table is drawn to size", () => { + const rows = PONG.render(drive(PONG, PONG.create({ rng: seeded(7) }), 50)); + assert.equal(rows.length, HEIGHT_P); + for (const row of rows) assert.equal(visible(row), WIDTH_P); +}); + +/* ------------------------------------------------------------------- tank */ + +test("the yard is closed on every side", () => { + for (let x = 0; x < WIDTH_T; x++) { + assert.ok(isYardWall(x, 0) && isYardWall(x, HEIGHT_T - 1), `the yard leaks at column ${x}`); + } + for (let y = 0; y < HEIGHT_T; y++) { + assert.ok(isYardWall(0, y) && isYardWall(WIDTH_T - 1, y), `the yard leaks at row ${y}`); + } + assert.equal(isYardWall(-1, 5), true, "and anything off the board is wall"); +}); + +test("a tank cannot drive through a wall", () => { + const tank = { x: 1, y: 1, dir: 0, cool: 0 }; // pointed at the top wall + assert.equal(driveTank(tank, 1), false); + assert.deepEqual([tank.x, tank.y], [1, 1], "and it does not move a bit"); + tank.dir = 1; + assert.equal(driveTank(tank, 1), true); + assert.equal(tank.x, 2); +}); + +test("a shell only carries down an open lane", () => { + const state = TANK.create(); + // Both tanks start on the same row with the yard's furniture between them. + assert.equal(lineOfSight({ x: 1, y: 1, dir: 1 }, { x: 20, y: 1 }), true, "the top lane is open"); + assert.equal(lineOfSight(state.you, state.them), false, "and the middle is not"); + assert.equal(lineOfSight({ x: 1, y: 1, dir: 2 }, { x: 20, y: 1 }), false, "nor is a target you are not facing"); +}); + +test("the machine finds its way round a block rather than into it", () => { + // The bug this replaces: "turn towards the enemy, then drive" grinds into the + // same wall for ever, and the two tanks never met at all. + const first = stepToward({ x: 2, y: 6 }, { x: 39, y: 6 }); + assert.ok(first, "there should be a way across the yard"); + assert.notEqual(first.dir, 1, "and it is not straight through the block in front"); + + const state = drive(TANK, TANK.create(), 4000); + assert.ok(state.theirs > 0 || state.over, "the machine should have come and found you"); +}); + +test("a hit scores, resets both tanks, and five of them is the match", () => { + const state = TANK.create(); + state.shells = [{ x: state.them.x - 1, y: state.them.y, dir: 1, owner: "you" }]; + TANK.tick(state); + assert.equal(state.yours, 1); + assert.equal(state.shells.length, 0, "the shell is spent"); + assert.deepEqual([state.you.x, state.you.y], [2, 6], "and both tanks go back to their corners"); + + state.yours = TANK_TARGET - 1; + state.shells = [{ x: state.them.x - 1, y: state.them.y, dir: 1, owner: "you" }]; + TANK.tick(state); + assert.match(state.over, /you take it/); +}); + +test("one shell each in the air", () => { + const state = TANK.create(); + state.you.dir = 0; // up the open lane, so the shell survives the first tick + TANK.onKey(state, "space"); + assert.equal(state.shells.length, 1); + TANK.onKey(state, "space"); + assert.equal(state.shells.length, 1, "the second press should do nothing"); +}); + +test("the machine takes a sitting duck, and loses to somebody playing", () => { + const hunting = (state, i) => { + if (i % 3) return; + if (lineOfSight(state.you, state.them)) { TANK.onKey(state, "space"); return; } + const next = stepToward(state.you, state.them); + if (!next) return; + if (state.you.dir !== next.dir) { + TANK.onKey(state, quarterTurn(state.you.dir, next.dir) === (state.you.dir + 1) % 4 ? "right" : "left"); + } else TANK.onKey(state, "up"); + }; + assert.match(drive(TANK, TANK.create(), 30000, hunting).over ?? "", /you take it/); + assert.match(drive(TANK, TANK.create(), 30000).over ?? "", /machine takes it/); +}); + +test("the yard is drawn to size", () => { + const rows = TANK.render(drive(TANK, TANK.create(), 100)); + assert.equal(rows.length, HEIGHT_T); + for (const row of rows) assert.equal(visible(row), WIDTH_T); +}); + +/* -------------------------------------------------------------- spyhunter */ + +test("the road always has a verge on both sides, and a width you can drive", () => { + let row = openRoad()[0]; + const rng = seeded(3); + const centres = []; + for (let i = 0; i < 4000; i++) { + row = nextRow(row, rng); + assert.ok(row.left >= 1, `the road ran off the left at ${row.left}`); + assert.ok(row.right <= SH_WIDTH - 2, `the road ran off the right at ${row.right}`); + const width = row.right - row.left; + assert.ok(width >= 12 && width <= 25, `a road ${width} wide is not a road`); + centres.push((row.left + row.right) / 2); + } + // Pulled back towards the middle rather than parked against an edge — a plain + // random walk fails this every time. + const mean = centres.reduce((a, b) => a + b, 0) / centres.length; + assert.ok(Math.abs(mean - SH_WIDTH / 2) < 3, `the road lives at ${mean.toFixed(1)}, not the middle`); +}); + +test("the verge costs a life, and so does the traffic", () => { + const off = SPYHUNTER.create({ rng: seeded(1) }); + off.car = 0; // hard against the left edge, off the tarmac + off.road = off.road.map(() => ({ left: 10, right: 28 })); + SPYHUNTER.tick(off); + assert.equal(off.lives, 2, "the verge should have cost a life"); + assert.equal(off.grace > 0, true, "and the road ahead is cleared for a moment"); + + const ram = SPYHUNTER.create({ rng: seeded(2) }); + ram.traffic = [{ kind: "civilian", x: ram.car, y: CAR_ROW, speed: 0.2 }]; + SPYHUNTER.tick(ram); + assert.equal(ram.lives, 2); + assert.match(SPYHUNTER.status(ram), /▲▲/); +}); + +test("shooting the wrong car costs more than not shooting at all", () => { + const shoot = (kind) => { + const state = SPYHUNTER.create({ rng: seeded(4) }); + state.score = 500; + state.grace = 999; // this test is about the gun, not the bumper + state.traffic = [{ kind, x: state.car, y: CAR_ROW - 4, speed: 0.2 }]; + // A tick moves the shot up 1.6 rows, so it starts below where they meet. + state.shots = [{ x: state.car + 0.5, y: CAR_ROW - 2.5 }]; + SPYHUNTER.tick(state); + assert.equal(state.traffic.length, 0, `the ${kind} should have been hit`); + return state.score - 500; + }; + assert.equal(shoot("enemy"), TRAFFIC.enemy.points); + assert.equal(shoot("civilian"), TRAFFIC.civilian.points); +}); + +test("a wreck does not drop you back onto the car that got you", () => { + const state = SPYHUNTER.create({ rng: seeded(5) }); + state.traffic = [ + { kind: "enemy", x: state.car, y: CAR_ROW, speed: 0.2 }, + { kind: "enemy", x: state.car, y: CAR_ROW - 2, speed: 0.2 }, + ]; + SPYHUNTER.tick(state); + assert.equal(state.traffic.length, 0, "the road is cleared"); + assert.ok(onRoad(state.road[CAR_ROW], state.car), "and you are put back on the tarmac"); +}); + +test("the road can be driven, and cannot be driven hands-off", () => { + const steering = (state) => { + const ahead = state.traffic.filter((c) => c.y > CAR_ROW - 7 && c.y <= CAR_ROW); + // Hold the dodge until the car is properly past, rather than steering back + // to the middle the moment the bumpers no longer touch — recentring early + // just drives you back into it. + const blocking = ahead.find((c) => Math.abs(c.x - state.car) <= 3); + const row = state.road[CAR_ROW]; + let want = Math.round((row.left + row.right) / 2) - 1; + if (blocking) want = blocking.x > state.car ? state.car - 4 : state.car + 4; + want = Math.max(row.left, Math.min(row.right - CAR_W + 1, want)); + if (state.car < want) SPYHUNTER.onKey(state, "right"); + else if (state.car > want) SPYHUNTER.onKey(state, "left"); + const enemy = ahead.find((c) => c.kind === "enemy" && overlaps(c.x, state.car)); + const civil = ahead.find((c) => c.kind === "civilian" && overlaps(c.x, state.car) && c.y > (enemy?.y ?? -99)); + if (enemy && !civil) SPYHUNTER.onKey(state, "space"); + }; + for (let seed = 1; seed <= 6; seed++) { + const driven = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(seed) }), 700, steering); + assert.equal(driven.over, null, `seed ${seed} could not be driven 700 ticks: ${driven.over}`); + assert.ok(driven.miles > 10, `seed ${seed} only covered ${driven.miles} miles`); + const drifted = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(seed) }), 1500); + assert.ok(drifted.over, `seed ${seed} survived with nobody steering`); + } +}); + +test("the road is drawn to size", () => { + const state = drive(SPYHUNTER, SPYHUNTER.create({ rng: seeded(8) }), 200); + const rows = SPYHUNTER.render(state); + assert.equal(rows.length, SH_HEIGHT); + for (const row of rows) assert.equal(visible(row), SH_WIDTH); +}); + +/* ---------------------------------------------------------------- pitfall */ + +test("a jump clears a log and a pit needs the vine", () => { + const jumped = PITFALL.create({ rng: seeded(1) }); + jumped.next = 1e9; + jumped.things = [{ kind: "log", x: PF_RUNNER + 2 }]; + PITFALL.onKey(jumped, "space"); + const overLog = drive(PITFALL, jumped, 12); + assert.equal(overLog.over, null, "a jump should cover a two-wide log"); + + // A pit is five wide, which is more than a jump covers. That is on purpose. + const short = PITFALL.create({ rng: seeded(2) }); + short.next = 1e9; + short.things = [{ kind: "pit", x: PF_RUNNER + 2 }]; + PITFALL.onKey(short, "space"); + const fell = drive(PITFALL, short, 20); + assert.equal(fell.lives, 2, "jumping a pit is how you find out how deep it is"); +}); + +test("a vine is only there to be caught, and only from the ground", () => { + const state = PITFALL.create({ rng: seeded(3) }); + state.next = 1e9; + state.things = [{ kind: "vine", x: PF_RUNNER }]; + PITFALL.tick(state); + assert.equal(state.lives, 3, "walking under one costs nothing"); + + grab(state); + assert.equal(state.swinging, true); + assert.ok(state.air > 0); + + const airborne = PITFALL.create({ rng: seeded(4) }); + airborne.next = 1e9; + airborne.things = [{ kind: "vine", x: PF_RUNNER }]; + PITFALL.onKey(airborne, "space"); + grab(airborne); + assert.equal(airborne.swinging, false, "you cannot catch one mid-jump"); +}); + +test("a swing carries you over a whole pit", () => { + const state = PITFALL.create({ rng: seeded(5) }); + state.next = 1e9; + // The spawner always hangs the vine three columns ahead of the pit it covers. + state.things = [{ kind: "vine", x: PF_RUNNER }, { kind: "pit", x: PF_RUNNER + 3 }]; + grab(state); + const crossed = drive(PITFALL, state, 30); + assert.equal(crossed.lives, 3, "the swing should have carried you the whole way"); +}); + +test("every pit the jungle throws has a vine over it", () => { + const state = PITFALL.create({ rng: seeded(6) }); + for (let i = 0; i < 400; i++) spawnPitfall(state); + const pits = state.things.filter((t) => t.kind === "pit"); + const vines = state.things.filter((t) => t.kind === "vine"); + assert.ok(pits.length > 0, "the spawner never made a pit"); + assert.equal(vines.length, pits.length, "a pit with no vine is a pit nobody gets past"); +}); + +test("gold is picked up on foot, and a fall costs you daylight", () => { + const state = PITFALL.create({ rng: seeded(7) }); + state.next = 1e9; + // A tick scrolls the jungle first, so put it one column further out. + state.things = [{ kind: "treasure", x: PF_RUNNER + 1 }]; + PITFALL.tick(state); + assert.equal(state.treasure, 1); + assert.ok(state.score >= 500); + + const fell = PITFALL.create({ rng: seeded(8) }); + fell.next = 1e9; + fell.clock = 500; + fell.things = [{ kind: "scorpion", x: PF_RUNNER + 1 }]; + PITFALL.tick(fell); + assert.equal(fell.lives, 2); + assert.ok(fell.clock < 500, "and it puts the sun down faster"); +}); + +test("the jungle can be run, and cannot be run standing up", () => { + const running = (state) => { + const vine = state.things.find((t) => t.kind === "vine" && Math.abs(Math.round(t.x) - PF_RUNNER) <= 1); + const soon = state.things.find((t) => { + if (t.kind === "vine" || t.kind === "treasure" || t.kind === "pit") return false; + const lead = pfSpan(t)[0] - PF_RUNNER; + return lead >= 1 && lead <= 3; + }); + if (vine) PITFALL.onKey(state, "up"); + else if (soon) PITFALL.onKey(state, "space"); + }; + for (let seed = 1; seed <= 6; seed++) { + const state = drive(PITFALL, PITFALL.create({ rng: seeded(seed) }), 3000, running); + // Somebody who jumps and swings at the right moments is only ever beaten by + // the clock, never by the jungle. + assert.match(state.over ?? "", /out of daylight/, `seed ${seed} ended: ${state.over}`); + assert.ok(state.treasure > 3, `seed ${seed} only found ${state.treasure} gold`); + } + for (let seed = 1; seed <= 6; seed++) { + const idle = drive(PITFALL, PITFALL.create({ rng: seeded(seed) }), 800); + assert.ok(idle.over, `seed ${seed} walked the jungle without jumping once`); + } +}); + +test("the pitfall board is drawn to size", () => { + const state = drive(PITFALL, PITFALL.create({ rng: seeded(9) }), 150); + const rows = PITFALL.render(state); + assert.equal(rows.length, PF_HEIGHT); + for (const row of rows) assert.equal(visible(row), PF_WIDTH); +}); + +/* ------------------------------------------------------------- choplifter */ + +test("the world is wider than the window, and the camera follows you", () => { + const state = CHOPLIFTER.create({ rng: seeded(1) }); + assert.ok(WORLD > CH_WIDTH * 2, "a rescue you can see all of is not a rescue"); + const home = strip(CHOPLIFTER.render(state).join("\n")); + state.chopper.x = WORLD - 10; + const away = strip(CHOPLIFTER.render(state).join("\n")); + assert.notEqual(home, away, "flying to the far end should show a different place"); +}); + +test("landing in the desert fills the back, four at a time", () => { + const state = CHOPLIFTER.create({ rng: seeded(2) }); + state.tanks = []; + state.people = Array.from({ length: 6 }, (_, i) => ({ x: 60 + i * 0.4 })); + state.chopper = { x: 60, y: GROUND_CH, vy: 0 }; + CHOPLIFTER.tick(state); + assert.equal(state.aboard, SEATS, "it holds four and no more"); + assert.equal(state.people.length, 2, "and leaves the rest waving"); +}); + +test("the pad is the only place they get out", () => { + const desert = CHOPLIFTER.create({ rng: seeded(3) }); + desert.tanks = []; + desert.people = []; + desert.aboard = 3; + desert.chopper = { x: 80, y: GROUND_CH, vy: 0 }; + CHOPLIFTER.tick(desert); + assert.equal(desert.home, 0, "putting them down in the desert is not a rescue"); + + const pad = CHOPLIFTER.create({ rng: seeded(3) }); + pad.tanks = []; + pad.aboard = 3; + pad.chopper = { x: BASE + 2, y: GROUND_CH, vy: 0 }; + CHOPLIFTER.tick(pad); + assert.equal(pad.home, 3); + assert.equal(pad.aboard, 0); + assert.ok(onPad(BASE + 2) && !onPad(80)); +}); + +test("a hit costs everybody in the back", () => { + const state = CHOPLIFTER.create({ rng: seeded(4) }); + state.tanks = []; + state.aboard = 4; + state.chopper = { x: 60, y: 5, vy: 0 }; + state.shells = [{ x: 60, y: 5, vy: -0.55 }]; + CHOPLIFTER.tick(state); + assert.equal(state.lives, 2); + assert.equal(state.aboard, 0, "they were in the back"); + assert.equal(state.home, 0); +}); + +test("a tank only shoots at what is overhead", () => { + const far = CHOPLIFTER.create({ rng: seeded(5) }); + far.people = []; + far.tanks = [{ x: 100, dir: 1 }]; + far.chopper = { x: 10, y: 4, vy: 0 }; + drive(CHOPLIFTER, far, 200); + assert.equal(far.shells.length, 0, "it should not shell the far end of the map"); + + const over = CHOPLIFTER.create({ rng: seeded(6) }); + over.people = [{ x: 100 }]; + over.tanks = [{ x: 100, dir: 1 }]; + over.chopper = { x: 100, y: 3, vy: 0 }; + let seen = 0; + drive(CHOPLIFTER, over, 200, (s) => { seen = Math.max(seen, s.shells.length); s.chopper.y = 3; }); + assert.ok(seen > 0, "and it should very much shell what is above it"); +}); + +test("everyone out is the end of it", () => { + const state = CHOPLIFTER.create({ rng: seeded(7) }); + state.tanks = []; + state.people = []; + state.aboard = 0; + CHOPLIFTER.tick(state); + assert.match(state.over, /everyone out/); +}); + +test("a pilot can fly the rescue, and a parked one rescues nobody", () => { + const flying = (state) => { + const chop = state.chopper; + const full = state.aboard >= SEATS || (!state.people.length && state.aboard); + const target = full ? BASE + 2 : (state.people[0]?.x ?? BASE + 2); + const dx = target - chop.x; + if (Math.abs(dx) > 1.5) { + CHOPLIFTER.onKey(state, dx > 0 ? "right" : "left"); + if (chop.y > GROUND_CH - 4) CHOPLIFTER.onKey(state, "up"); + } else if (chop.y < GROUND_CH) CHOPLIFTER.onKey(state, "down"); + }; + let rescued = 0; + for (let seed = 1; seed <= 6; seed++) { + rescued += drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(seed) }), 6000, flying).home; + } + assert.ok(rescued >= 20, `only ${rescued} people came home across six runs`); + for (let seed = 1; seed <= 6; seed++) { + const parked = drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(seed) }), 2000); + assert.equal(parked.home, 0, "nobody walks home on their own"); + } +}); + +test("the choplifter board is drawn to size", () => { + const state = drive(CHOPLIFTER, CHOPLIFTER.create({ rng: seeded(8) }), 200); + const rows = CHOPLIFTER.render(state); + assert.equal(rows.length, CH_HEIGHT); + for (const row of rows) assert.equal(visible(row), CH_WIDTH); +}); + +/* ------------------------------------------------------------- excitebike */ + +test("turbo is a loan, and the gauge is where it is called in", () => { + const state = EXCITEBIKE.create({ rng: seeded(1) }); + EXCITEBIKE.onKey(state, "space"); + assert.equal(state.turbo, true); + // Long enough to cook it, short enough to still be sitting in the seizure — + // it clears itself once it has cost you the time. + const hot = drive(EXCITEBIKE, state, 80); + assert.ok(hot.seized > 0, "holding it should cook the engine"); + assert.equal(hot.turbo, false, "and take it away from you"); + assert.ok(speedOf(hot) < 0.3, "a seized engine barely moves"); + + const cooling = EXCITEBIKE.create({ rng: seeded(2) }); + const cool = drive(EXCITEBIKE, cooling, 60); + assert.equal(cool.heat, 0, "off the turbo it cools back down"); +}); + +test("a ramp puts you in the air, and the nose drops all the way down", () => { + const state = EXCITEBIKE.create({ rng: seeded(3) }); + state.next = 1e9; + state.things = [{ kind: "ramp", x: RIDER }]; + EXCITEBIKE.tick(state); + assert.ok(state.air > 0, "you should be off the ground"); + const launch = state.pitch; + EXCITEBIKE.tick(state); + assert.ok(state.pitch > launch, "and the front wheel drops on its own"); +}); + +test("a landing is level or it is a tumble", () => { + const flown = (correct) => { + const state = EXCITEBIKE.create({ rng: seeded(4) }); + state.next = 1e9; + state.things = [{ kind: "ramp", x: RIDER }]; + return drive(EXCITEBIKE, state, 60, (s) => { + if (correct && s.air && s.pitch > 0.2) EXCITEBIKE.onKey(s, "up"); + }); + }; + assert.equal(flown(true).spills, 0, "held level, it lands"); + assert.equal(flown(false).spills, 1, "left alone, it lands on its face"); + assert.ok(Math.abs(LAND_OK) > 0); +}); + +test("pitching on the ground does nothing at all", () => { + const state = EXCITEBIKE.create({ rng: seeded(5) }); + EXCITEBIKE.onKey(state, "up"); + EXCITEBIKE.onKey(state, "down"); + assert.equal(state.pitch, 0, "there is nothing to pitch against"); +}); + +test("the race can be won by riding it well, and not by holding turbo down", () => { + const good = (state, i) => { + if (state.air && state.pitch > 0.2) EXCITEBIKE.onKey(state, "up"); + else if (state.air && state.pitch < -0.2) EXCITEBIKE.onKey(state, "down"); + if (state.heat > 70 && state.turbo) EXCITEBIKE.onKey(state, "space"); + if (state.heat < 25 && !state.turbo) EXCITEBIKE.onKey(state, "space"); + if (i % 5 === 0) EXCITEBIKE.onKey(state, "right"); + }; + const greedy = (state, i) => { + if (!state.turbo) EXCITEBIKE.onKey(state, "space"); + if (i % 5 === 0) EXCITEBIKE.onKey(state, "right"); + }; + let ridden = 0; + let mashed = 0; + for (let seed = 1; seed <= 5; seed++) { + const clean = drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(seed) }), 3000, good); + ridden += clean.race - 1; + assert.equal(clean.spills, 0, `seed ${seed} spilled while being ridden properly`); + mashed += drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(seed) }), 3000, greedy).race - 1; + } + assert.ok(ridden > mashed, `riding it (${ridden}) should beat mashing it (${mashed})`); + assert.ok(ridden >= 10, `only ${ridden} races finished in five runs`); +}); + +test("the excitebike board is drawn to size", () => { + const state = drive(EXCITEBIKE, EXCITEBIKE.create({ rng: seeded(6) }), 200); + const rows = EXCITEBIKE.render(state); + assert.equal(rows.length, EB_HEIGHT); + for (const row of rows) assert.equal(visible(row), EB_WIDTH); +}); + +/* ----------------------------------------------------------------- outrun */ + +test("the road is narrow far away and wide under the bumper", () => { + assert.ok(roadHalf(0) < roadHalf(PLAYER_ROW), "perspective runs the wrong way"); + for (let row = 1; row < OR_HEIGHT; row++) { + assert.ok(roadHalf(row) > roadHalf(row - 1), `row ${row} is not wider than the one above it`); + } +}); + +test("a corner bends the far end and leaves your bumper where it is", () => { + const straight = centreAt(PLAYER_ROW, 0); + assert.equal(centreAt(PLAYER_ROW, 1), straight, "the bottom row never moves"); + assert.equal(centreAt(PLAYER_ROW, -1), straight); + assert.ok(centreAt(0, 1) > centreAt(0, 0), "a right-hander throws the horizon right"); + assert.ok(centreAt(0, -1) < centreAt(0, 0), "and a left-hander throws it left"); + // And it opens up gradually rather than kinking. + const offsets = Array.from({ length: OR_HEIGHT }, (_, y) => centreAt(y, 1) - centreAt(y, 0)); + for (let y = 1; y < offsets.length; y++) assert.ok(offsets[y] <= offsets[y - 1] + 1e-9); +}); + +test("the grass is slow, and the tarmac is not", () => { + const state = OUTRUN.create({ rng: seeded(1) }); + state.speed = 1.8; + state.car = 1; // hard onto the verge + assert.equal(onTarmac(state.car, state.curve), false); + OUTRUN.tick(state); + assert.ok(state.speed <= 0.6, "two wheels on the grass should cost you everything"); + + const tarmac = OUTRUN.create({ rng: seeded(1) }); + tarmac.speed = 1.8; + OUTRUN.tick(tarmac); + assert.ok(tarmac.speed > 1.7, "and staying on it should cost you nothing"); +}); + +test("a corner throws you at the outside of it", () => { + const state = OUTRUN.create({ rng: seeded(2) }); + state.speed = 1.5; + state.curve = 0.8; + state.segment = { left: 1e9, curve: 0.8 }; + const before = state.car; + OUTRUN.tick(state); + assert.ok(state.car > before, "a right-hander should push you left-to-right across the road"); +}); + +test("traffic spins you, and a checkpoint buys the clock back", () => { + const state = OUTRUN.create({ rng: seeded(3) }); + state.speed = 1.6; + state.traffic = [{ z: 0.02, lane: 0, speed: 0.4 }]; + state.car = centreAt(PLAYER_ROW, state.curve); + OUTRUN.tick(state); + assert.equal(state.spins, 1); + assert.equal(state.speed, 0, "a spin costs you all of it"); + + const check = OUTRUN.create({ rng: seeded(4) }); + check.dist = check.nextCheck - 0.5; + check.speed = 1; + const clock = check.clock; + OUTRUN.tick(check); + assert.equal(check.checks, 1); + assert.ok(check.clock > clock, "and a checkpoint hands some of the clock back"); +}); + +test("the stage can be driven, and the clock takes anybody who doesn't", () => { + const driving = (state) => { + OUTRUN.onKey(state, "up"); + const want = centreAt(PLAYER_ROW, state.curve); + if (state.car < want - 0.6) OUTRUN.onKey(state, "right"); + else if (state.car > want + 0.6) OUTRUN.onKey(state, "left"); + }; + for (let seed = 1; seed <= 5; seed++) { + const driven = drive(OUTRUN, OUTRUN.create({ rng: seeded(seed) }), 3000, driving); + assert.equal(driven.over, null, `seed ${seed} ran out of road: ${driven.over}`); + assert.ok(driven.checks >= 8, `seed ${seed} only made ${driven.checks} checkpoints`); + const parked = drive(OUTRUN, OUTRUN.create({ rng: seeded(seed) }), 2000); + assert.match(parked.over ?? "", /time up/, "sitting still should run the clock out"); + } +}); + +test("the outrun road is drawn to size", () => { + const state = drive(OUTRUN, OUTRUN.create({ rng: seeded(5) }), 200, (s) => OUTRUN.onKey(s, "up")); + const rows = OUTRUN.render(state); + assert.equal(rows.length, OR_HEIGHT); + for (const row of rows) assert.equal(visible(row), OR_WIDTH); +}); + +/* -------------------------------------------------------------- stagedive */ + +/** A stage holding exactly the things a test puts on it, and nothing else. */ +const stage = (things = []) => ({ + ...STAGEDIVE.create({ rng: seeded(1) }), + things, + next: Number.MAX_SAFE_INTEGER, // no spawner — this test is about one obstacle +}); + +/** Run the stage, calling `act(state, tick)` before each tick. */ +function run(state, ticks, act = () => {}) { + for (let i = 0; i < ticks && !state.over; i++) { + act(state, i); + state = STAGEDIVE.tick(state); + } + return state; +} + +/** How far in front of the runner something is, in columns. */ +const lead = (thing) => (thing ? cells(thing).cols[0] - RUNNER : Infinity); + +/** A player with reflexes: jump the gear, duck the crowd. */ +const reflexes = (state) => { + for (const thing of state.things) { + if (thing.kind === "pick") continue; + const gap = lead(thing); + if (thing.kind === "surfer") { if (gap >= 1 && gap <= 5) STAGEDIVE.onKey(state, "down"); } + else if (gap >= 4 && gap <= 8) STAGEDIVE.onKey(state, "up"); + } +}; + +test("every hazard hits exactly as wide as it is drawn", () => { + for (const [kind, hazard] of Object.entries(HAZARDS)) { + const { cols } = cells({ kind, x: 20 }); + assert.equal(cols[1] - cols[0] + 1, [...hazard.art].length, `${kind} lies about its width`); + assert.ok(hazard.rows.every((r) => r <= GROUND), `${kind} floats above the stage`); + assert.ok(hazard.death.length, `${kind} kills you without saying so`); + } +}); + +test("the runner never moves — the stage does", () => { + const state = run(stage([{ kind: "wedge", x: 40 }]), 12); + assert.equal(state.things[0].x < 40, true, "the wedge should have come closer"); + assert.ok(state.dist > 0, "and the distance run should have gone up"); + const drawn = STAGEDIVE.render(state).map(strip); + assert.ok(drawn.some((row) => row[RUNNER] && row[RUNNER] !== " "), "the runner is always in its column"); +}); + +test("a wedge you do not jump is a wedge you trip over", () => { + const tripped = run(stage([{ kind: "wedge", x: RUNNER + 8 }]), 20); + assert.match(tripped.over, /monitor wedge/); + + const cleared = run(stage([{ kind: "wedge", x: RUNNER + 8 }]), 20, (s) => { + if (lead(s.things[0]) === 6) STAGEDIVE.onKey(s, "up"); + }); + assert.equal(cleared.over, null, "a jump at six columns should clear it"); +}); + +test("an amp stack takes the height of the jump, not the start of it", () => { + // Jumped in time: the runner is two rows up before the stack arrives. + const cleared = run(stage([{ kind: "stack", x: RUNNER + 20 }]), 40, (s) => { + if (lead(s.things[0]) === 6) STAGEDIVE.onKey(s, "up"); + }); + assert.equal(cleared.over, null); + + // Jumped one column late is still a jump, and still a wreck. + const late = run(stage([{ kind: "stack", x: RUNNER + 20 }]), 40, (s) => { + if (lead(s.things[0]) === 0) STAGEDIVE.onKey(s, "up"); + }); + assert.match(late.over, /amp stack/); +}); + +test("a crowdsurfer is ducked, not jumped into", () => { + const worn = run(stage([{ kind: "surfer", x: RUNNER + 8 }]), 20); + assert.match(worn.over, /crowdsurfer/); + + const ducked = run(stage([{ kind: "surfer", x: RUNNER + 8 }]), 20, (s) => { + if (lead(s.things[0]) === 3) STAGEDIVE.onKey(s, "down"); + }); + assert.equal(ducked.over, null, "crouching should pass under it"); + // Crouched is one row; standing is two, and the second row is the one that + // wears a crowdsurfer. + assert.deepEqual(runnerRows({ y: GROUND, duck: 4, airborne: false }), [GROUND]); + assert.deepEqual(runnerRows({ y: GROUND, duck: 0, airborne: false }), [GROUND - 1, GROUND]); +}); + +test("there is no second jump, and ↓ in the air is a slam", () => { + const state = stage(); + STAGEDIVE.onKey(state, "up"); + const climbing = state.vy; + STAGEDIVE.onKey(state, "up"); + assert.equal(state.vy, climbing, "a second jump would be a different game"); + STAGEDIVE.onKey(state, "down"); + assert.ok(state.vy > 0, "↓ in the air should send you down"); + assert.equal(state.duck, 0, "and it is not a crouch until you land"); +}); + +test("picks are collected rather than crashed into", () => { + const state = run(stage([{ kind: "pick", x: RUNNER + 6, row: GROUND }]), 20); + assert.equal(state.picks, 1); + assert.equal(state.over, null, "a pick is not a hazard"); + assert.equal(state.things.length, 0, "and it is off the stage once taken"); + assert.match(STAGEDIVE.status(state), /1 picks/); +}); + +test("the stage speeds up, and the gaps grow with it", () => { + const state = STAGEDIVE.create({ rng: seeded(2) }); + const opening = state.speed; + const far = run(state, 3000, reflexes); + assert.ok(far.speed > opening, "the stage should get faster"); + + // The gap is set in columns but a jump is fixed in ticks, so the gap has to + // scale with speed or the game stops being playable at the far end. + const slow = { ...STAGEDIVE.create({ rng: seeded(3) }), things: [] }; + const fast = { ...STAGEDIVE.create({ rng: seeded(3) }), things: [], speed: 1.75 }; + spawn(slow); + spawn(fast); + assert.ok(fast.next > slow.next * 1.9, "a faster stage should leave more room"); +}); + +test("a player with reflexes can run the whole set", () => { + for (let seed = 1; seed <= 25; seed++) { + const state = run(STAGEDIVE.create({ rng: seeded(seed) }), 3000, reflexes); + assert.equal(state.over, null, `seed ${seed} died at ${meters(state)} m: ${state.over}`); + assert.ok(state.picks > 10, `seed ${seed} only found ${state.picks} picks in 3000 ticks`); + } +}); + +test("a player with none cannot", () => { + for (let seed = 1; seed <= 15; seed++) { + // Standing still, and holding the jump key down — the two ways nobody + // should be able to play a runner. + const idle = run(STAGEDIVE.create({ rng: seeded(seed) }), 600); + assert.ok(idle.over, `seed ${seed} survived doing nothing`); + const masher = run(STAGEDIVE.create({ rng: seeded(seed) }), 1500, (s) => STAGEDIVE.onKey(s, "up")); + assert.ok(masher.over, `seed ${seed} survived on jump alone`); + } +}); + +test("the stagedive board is drawn to size, with the stage under it", () => { + const state = run(STAGEDIVE.create({ rng: seeded(9) }), 200, reflexes); + const rows = STAGEDIVE.render(state); + assert.equal(rows.length, D_HEIGHT); + for (const row of rows) assert.equal(visible(row), D_WIDTH, "a ragged row would tear the frame"); + assert.match(strip(rows[GROUND + 1]), /^[═╪]+$/, "the stage edge runs the whole width"); +}); + /* -------------------------------------------------------------- tictactoe */ test("three in a row is spotted in every direction", () => {