moshcode 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ // Excitebike. The throttle is not the interesting part — the temperature gauge
2
+ // is, and so is what your front wheel is doing when you land.
3
+ //
4
+ // Two things make this game rather than a side-scroller with ramps. Turbo is
5
+ // free until it isn't: heat climbs while you hold it and the engine seizes at
6
+ // the top of the gauge, so the fast lap is the one that cools off in the right
7
+ // places. And a jump is only as good as its landing: you pitch the bike in the
8
+ // air, and coming down nose-first puts you over the handlebars.
9
+ import { acid, ash, bone, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 46;
12
+ export const HEIGHT = 12;
13
+
14
+ export const GROUND = HEIGHT - 3;
15
+ export const RIDER = 8; // your column; the track comes to you
16
+
17
+ const BASE_SPEED = 0.35;
18
+ const MAX_SPEED = 1.5;
19
+ const TURBO = 0.55; // extra columns per tick while it is held
20
+ const HEAT_UP = 1.6;
21
+ const HEAT_DOWN = 0.8;
22
+ export const SEIZE_TICKS = 70; // how long a cooked engine costs you
23
+ const PITCH_LIMIT = 2.2;
24
+ // The nose drops on its own all the way down. Without this the pitch you leave
25
+ // the ramp with is the pitch you land on, and the landing — the whole second
26
+ // half of this game — is something you can simply ignore.
27
+ const PITCH_DROP = 0.075;
28
+ export const LAND_OK = 1.1; // how far from level you may land
29
+ const CRASH_TICKS = 45;
30
+ export const FINISH = 900; // columns of track in a race
31
+ const TIME = 2600;
32
+
33
+ const dirt = rgb(170, 130, 90);
34
+
35
+ /** A ramp is three columns of take-off; hit one moving and you are airborne. */
36
+ export const RAMP_W = 3;
37
+
38
+ export function spawn(state) {
39
+ const { rng } = state;
40
+ state.things.push({ kind: "ramp", x: WIDTH + 2 });
41
+ state.next = 18 + rng() * 22;
42
+ return state;
43
+ }
44
+
45
+ export const rampSpan = (thing) => {
46
+ const x = Math.round(thing.x);
47
+ return [x, x + RAMP_W - 1];
48
+ };
49
+
50
+ /** Speed right now, with everything that is holding it back applied. */
51
+ export function speedOf(state) {
52
+ if (state.crash > 0) return 0;
53
+ if (state.seized > 0) return BASE_SPEED * 0.4;
54
+ return Math.min(MAX_SPEED, state.throttle + (state.turbo ? TURBO : 0));
55
+ }
56
+
57
+ function crash(state, why) {
58
+ state.crash = CRASH_TICKS;
59
+ state.air = 0;
60
+ state.pitch = 0;
61
+ state.throttle = BASE_SPEED;
62
+ state.spills++;
63
+ state.last = why;
64
+ return state;
65
+ }
66
+
67
+ /** One tick of track. Exported so a test can ride a whole race with no clock. */
68
+ export function step(state) {
69
+ state.clock++;
70
+ if (state.clock >= TIME) {
71
+ state.over = `out of time · ${Math.round(state.dist)} of ${FINISH}`;
72
+ return state;
73
+ }
74
+ if (state.crash > 0) { state.crash--; return state; }
75
+
76
+ // The gauge. Turbo is a loan, and this is where it is called in.
77
+ if (state.turbo && !state.seized) state.heat = Math.min(100, state.heat + HEAT_UP);
78
+ else state.heat = Math.max(0, state.heat - HEAT_DOWN);
79
+ if (state.heat >= 100 && !state.seized) { state.seized = SEIZE_TICKS; state.turbo = false; }
80
+ if (state.seized > 0) { state.seized--; if (!state.seized) state.heat = 40; }
81
+
82
+ const speed = speedOf(state);
83
+ state.dist += speed;
84
+ for (const thing of state.things) thing.x -= speed;
85
+ state.things = state.things.filter((t) => rampSpan(t)[1] > -3);
86
+ state.next -= speed;
87
+ if (state.next <= 0) spawn(state);
88
+
89
+ if (state.air > 0) {
90
+ state.air--;
91
+ state.pitch = Math.min(PITCH_LIMIT, state.pitch + PITCH_DROP);
92
+ if (!state.air) {
93
+ // Landing: level enough is a landing, anything else is a tumble.
94
+ if (Math.abs(state.pitch) > LAND_OK) return crash(state, "over the handlebars");
95
+ // A flat landing carries the speed; a wobbly one scrubs some off.
96
+ state.throttle = Math.min(MAX_SPEED, state.throttle + (Math.abs(state.pitch) < 0.4 ? 0.12 : -0.1));
97
+ state.score += 50;
98
+ state.pitch = 0;
99
+ }
100
+ } else {
101
+ const ramp = state.things.find((t) => {
102
+ const [from, to] = rampSpan(t);
103
+ return RIDER >= from && RIDER <= to;
104
+ });
105
+ if (ramp && !ramp.used) {
106
+ ramp.used = true;
107
+ // The faster you hit it, the longer you are in the air — and the more time
108
+ // you have to get the pitch wrong.
109
+ state.air = Math.round(10 + speed * 14);
110
+ state.pitch = 0.6; // it launches you nose-up, and you ride it down
111
+ }
112
+ }
113
+
114
+ if (state.dist >= FINISH) {
115
+ state.race++;
116
+ state.score += Math.max(200, 2000 - state.clock);
117
+ state.dist = 0;
118
+ state.clock = 0;
119
+ state.things = [];
120
+ state.heat = 0;
121
+ }
122
+ return state;
123
+ }
124
+
125
+ export const EXCITEBIKE = {
126
+ key: "excitebike",
127
+ aliases: ["bike", "moto", "excite"],
128
+ title: "EXCITEBIKE",
129
+ blurb: "turbo until it cooks, and land the way you took off",
130
+ keys: "← → throttle · space turbo · ↑ ↓ pitch in the air · q quit",
131
+ tickMs: 55,
132
+
133
+ create({ rng = Math.random } = {}) {
134
+ return {
135
+ things: [],
136
+ next: 16,
137
+ throttle: BASE_SPEED,
138
+ turbo: false,
139
+ heat: 0,
140
+ seized: 0,
141
+ air: 0,
142
+ pitch: 0,
143
+ crash: 0,
144
+ spills: 0,
145
+ dist: 0,
146
+ race: 1,
147
+ clock: 0,
148
+ score: 0,
149
+ last: null,
150
+ over: null,
151
+ rng,
152
+ };
153
+ },
154
+
155
+ tick: step,
156
+
157
+ onKey(state, pressed) {
158
+ if (state.crash > 0) return state;
159
+ if (pressed === "right") state.throttle = Math.min(MAX_SPEED, state.throttle + 0.12);
160
+ else if (pressed === "left") state.throttle = Math.max(BASE_SPEED * 0.5, state.throttle - 0.12);
161
+ else if (pressed === "space" || pressed === "enter") state.turbo = !state.turbo;
162
+ else if (pressed === "up" || pressed === "down") {
163
+ // Pitch only means anything off the ground; on it, this does nothing at
164
+ // all, which is the honest answer.
165
+ if (!state.air) return state;
166
+ state.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, state.pitch + (pressed === "up" ? -0.35 : 0.35)));
167
+ }
168
+ return state;
169
+ },
170
+
171
+ status(state) {
172
+ if (state.over) return state.over;
173
+ const gauge = Math.round(state.heat / 10);
174
+ const bar = `${"█".repeat(gauge)}${"░".repeat(10 - gauge)}`;
175
+ return `race ${state.race} · ${Math.round(state.dist)}/${FINISH} · heat ${bar}${state.seized ? " seized" : ""}`;
176
+ },
177
+
178
+ render(state) {
179
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
180
+ const put = (x, y, glyph) => {
181
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
182
+ grid[y][x] = glyph;
183
+ };
184
+
185
+ for (const thing of state.things) {
186
+ const [from] = rampSpan(thing);
187
+ // A ramp climbs, so it is drawn climbing.
188
+ [...("▁▄█")].forEach((c, i) => put(from + i, GROUND - (i > 1 ? 1 : 0), dirt(c)));
189
+ }
190
+
191
+ const height = state.air ? Math.min(4, 1 + Math.round(state.air / 6)) : 0;
192
+ const nose = state.pitch < -LAND_OK ? "◜" : state.pitch > LAND_OK ? "◞" : state.air ? "◠" : "◉";
193
+ put(RIDER, GROUND - 1 - height, state.crash ? danger("✷") : acid(nose));
194
+ if (!state.air && !state.crash) put(RIDER + 1, GROUND - 1, ash("·"));
195
+
196
+ return grid.map((row, y) => row.map((cell, x) => {
197
+ if (cell) return cell;
198
+ if (y === GROUND) return dirt("▀");
199
+ if (y > GROUND) return dim("░");
200
+ // The finish, coming up the track.
201
+ const toGo = FINISH - state.dist;
202
+ if (toGo < WIDTH - RIDER && x === RIDER + Math.round(toGo)) return bone("┋");
203
+ return " ";
204
+ }).join(""));
205
+ },
206
+ };
@@ -0,0 +1,194 @@
1
+ // Frogger. Five lanes of traffic that kill you if they touch you, then five of
2
+ // river that kill you if they don't.
3
+ //
4
+ // That inversion is the whole game and it is worth stating plainly in the code:
5
+ // on the road, being on something is death; on the river, being on nothing is.
6
+ // Everything else here — the lanes, the hops, the homes — is bookkeeping.
7
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
8
+
9
+ export const WIDTH = 40;
10
+
11
+ /**
12
+ * The board, bottom to top. Row 0 is the top (the homes), and the frog starts
13
+ * on the bank at the bottom.
14
+ */
15
+ export const HOME_ROW = 0;
16
+ export const RIVER = [1, 2, 3, 4, 5];
17
+ export const MEDIAN = 6;
18
+ export const ROAD = [7, 8, 9, 10, 11];
19
+ export const BANK = 12;
20
+ export const HEIGHT = BANK + 1;
21
+
22
+ /** Five places to get to, evenly spaced along the top. */
23
+ export const HOMES = [3, 11, 19, 27, 35];
24
+ const HOME_W = 3;
25
+
26
+ const LIVES = 3;
27
+ const water = rgb(60, 130, 220);
28
+ const log = rgb(150, 100, 60);
29
+
30
+ /** Each lane: which way it runs, how fast, and how thick the things in it are. */
31
+ export const LANES = {
32
+ 1: { dir: 1, speed: 0.16, len: 4, gap: 11, kind: "log" },
33
+ 2: { dir: -1, speed: 0.22, len: 3, gap: 9, kind: "turtle" },
34
+ 3: { dir: 1, speed: 0.13, len: 6, gap: 14, kind: "log" },
35
+ 4: { dir: -1, speed: 0.28, len: 3, gap: 10, kind: "turtle" },
36
+ 5: { dir: 1, speed: 0.2, len: 5, gap: 13, kind: "log" },
37
+ 7: { dir: -1, speed: 0.26, len: 2, gap: 9, kind: "car" },
38
+ 8: { dir: 1, speed: 0.19, len: 3, gap: 11, kind: "truck" },
39
+ 9: { dir: -1, speed: 0.33, len: 2, gap: 12, kind: "car" },
40
+ 10: { dir: 1, speed: 0.15, len: 4, gap: 13, kind: "truck" },
41
+ 11: { dir: -1, speed: 0.24, len: 2, gap: 10, kind: "car" },
42
+ };
43
+
44
+ const ART = {
45
+ car: { paint: danger, art: "▄▄▄▄▄▄" },
46
+ truck: { paint: amber, art: "██████" },
47
+ log: { paint: log, art: "▓▓▓▓▓▓" },
48
+ turtle: { paint: acid, art: "◠◠◠◠◠◠" },
49
+ };
50
+
51
+ /** Every lane laid out end to end, evenly spaced. */
52
+ export function buildTraffic() {
53
+ const things = [];
54
+ for (const [row, lane] of Object.entries(LANES)) {
55
+ for (let x = 0; x < WIDTH + lane.gap; x += lane.gap) {
56
+ things.push({ row: Number(row), x, len: lane.len, kind: lane.kind });
57
+ }
58
+ }
59
+ return things;
60
+ }
61
+
62
+ /** The thing under a cell, if there is one. */
63
+ export const thingAt = (things, row, x) => things.find(
64
+ (t) => t.row === row && x >= Math.round(t.x) && x < Math.round(t.x) + t.len,
65
+ ) ?? null;
66
+
67
+ /** Which home a frog at `x` has reached, or -1. */
68
+ export const homeAt = (x) => HOMES.findIndex((h) => x >= h && x < h + HOME_W);
69
+
70
+ const start = () => ({ x: Math.floor(WIDTH / 2), row: BANK });
71
+
72
+ function drown(state, why) {
73
+ state.lives--;
74
+ if (state.lives <= 0) {
75
+ state.lives = 0;
76
+ state.over = `${why} · ${state.score} points`;
77
+ return state;
78
+ }
79
+ state.frog = start();
80
+ return state;
81
+ }
82
+
83
+ /** One tick. Exported so a test can get a frog home with no clock. */
84
+ export function step(state) {
85
+ for (const thing of state.traffic) {
86
+ const lane = LANES[thing.row];
87
+ thing.x += lane.dir * lane.speed;
88
+ if (thing.x > WIDTH + 2) thing.x = -thing.len - 2;
89
+ if (thing.x < -thing.len - 2) thing.x = WIDTH + 2;
90
+ }
91
+
92
+ const frog = state.frog;
93
+ if (RIVER.includes(frog.row)) {
94
+ // The river carries you. Riding a log off the edge of the world still
95
+ // counts as losing the frog, which is the lesson every player learns twice.
96
+ const ride = thingAt(state.traffic, frog.row, Math.round(frog.drift ?? frog.x));
97
+ if (!ride) return drown(state, "into the river");
98
+ frog.drift = (frog.drift ?? frog.x) + LANES[frog.row].dir * LANES[frog.row].speed;
99
+ frog.x = Math.round(frog.drift);
100
+ if (frog.x < 0 || frog.x >= WIDTH) return drown(state, "carried off the edge");
101
+ } else if (ROAD.includes(frog.row)) {
102
+ if (thingAt(state.traffic, frog.row, frog.x)) return drown(state, "flattened");
103
+ }
104
+ return state;
105
+ }
106
+
107
+ /** Hop, and settle what the frog landed on. */
108
+ export function hop(state, dx, dy) {
109
+ const frog = state.frog;
110
+ const row = Math.min(BANK, Math.max(HOME_ROW, frog.row + dy));
111
+ const x = Math.min(WIDTH - 1, Math.max(0, frog.x + dx));
112
+ frog.row = row;
113
+ frog.x = x;
114
+ frog.drift = RIVER.includes(row) ? x : null;
115
+
116
+ if (row === HOME_ROW) {
117
+ const home = homeAt(x);
118
+ if (home < 0 || state.homes[home]) {
119
+ // The bank between the homes is not a home, and neither is one you have
120
+ // already filled.
121
+ return drown(state, "nowhere to land");
122
+ }
123
+ state.homes[home] = true;
124
+ state.score += 100;
125
+ if (state.homes.every(Boolean)) {
126
+ state.level++;
127
+ state.homes = HOMES.map(() => false);
128
+ state.score += 500;
129
+ }
130
+ state.frog = start();
131
+ return state;
132
+ }
133
+ if (dy < 0) state.score += 10; // forwards only, so hopping on the spot pays nothing
134
+ return step(state);
135
+ }
136
+
137
+ export const FROGGER = {
138
+ key: "frogger",
139
+ aliases: ["frog", "hop"],
140
+ title: "FROGGER",
141
+ blurb: "the road kills what it touches, the river kills what it doesn't",
142
+ keys: "← ↑ ↓ → hop · q quit",
143
+ tickMs: 60,
144
+
145
+ create() {
146
+ return {
147
+ traffic: buildTraffic(),
148
+ frog: start(),
149
+ homes: HOMES.map(() => false),
150
+ score: 0,
151
+ lives: LIVES,
152
+ level: 1,
153
+ over: null,
154
+ };
155
+ },
156
+
157
+ tick: step,
158
+
159
+ onKey(state, pressed) {
160
+ const moves = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
161
+ const move = moves[pressed];
162
+ if (!move) return state;
163
+ return hop(state, move[0], move[1]);
164
+ },
165
+
166
+ status(state) {
167
+ return state.over
168
+ ? state.over
169
+ : `${state.score} · level ${state.level} · ${state.homes.filter(Boolean).length}/5 home · ${"▲".repeat(state.lives)}`;
170
+ },
171
+
172
+ render(state) {
173
+ const grid = Array.from({ length: HEIGHT }, (_, row) => Array.from({ length: WIDTH }, () => (
174
+ RIVER.includes(row) ? water("░") : ROAD.includes(row) ? dim("·") : null
175
+ )));
176
+ const put = (x, y, glyph) => {
177
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
178
+ grid[y][x] = glyph;
179
+ };
180
+
181
+ for (const [i, home] of HOMES.entries()) {
182
+ for (let j = 0; j < HOME_W; j++) put(home + j, HOME_ROW, state.homes[i] ? acid("▓") : ash("▒"));
183
+ }
184
+ for (const thing of state.traffic) {
185
+ const { paint, art } = ART[thing.kind];
186
+ for (let i = 0; i < thing.len; i++) put(Math.round(thing.x) + i, thing.row, paint(art[i % art.length]));
187
+ }
188
+ put(state.frog.x, state.frog.row, state.over ? danger("✷") : bone("◉"));
189
+
190
+ return grid.map((row, y) => row.map((cell) => (
191
+ cell ?? (y === MEDIAN || y === BANK ? ash("═") : " ")
192
+ )).join(""));
193
+ },
194
+ };
@@ -54,7 +54,12 @@ export const HANGMAN = {
54
54
  aliases: ["hang", "gallows"],
55
55
  title: "HANGMAN",
56
56
  blurb: "six wrong letters and you are done for",
57
- keys: "a–z guess · r new word · q quit",
57
+ keys: "a–z guess · r new word once it is over · q quit",
58
+ // Every letter has to reach the game: `h` is a guess, not the vim left it is
59
+ // in the games that read arrows, and `r` only restarts once the word is done.
60
+ // Without both, `refactor` was a word you could not spell at it.
61
+ vim: false,
62
+ restartable: false,
58
63
 
59
64
  create({ rng = Math.random } = {}) {
60
65
  return {
@@ -0,0 +1,258 @@
1
+ // Space Invaders. Forty of them, coming down a row at a time, and the fewer are
2
+ // left the faster the rest move — which is the joke the original hardware told by
3
+ // accident and every version since has kept on purpose.
4
+ //
5
+ // Nothing here moves on a fraction of a cell. The fleet steps a whole column at
6
+ // a time on a counter, shots move a whole row per tick, and every hit is one
7
+ // grid cell against another. A game whose whole tension is "will it get to the
8
+ // bottom before I do" should never lose a shot to a rounding error.
9
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 44;
12
+ export const HEIGHT = 16;
13
+
14
+ export const ROWS = 5;
15
+ export const COLS = 8;
16
+ const PITCH_X = 4; // an alien is two cells wide, with two between them
17
+ // One row per rank. At two the fleet stood nine rows tall on a board with eleven
18
+ // above the bunkers, so it "landed" after two drops and no wave was survivable.
19
+ const PITCH_Y = 1;
20
+
21
+ export const CANNON_ROW = HEIGHT - 2;
22
+ const FLOOR_ROW = HEIGHT - 1;
23
+ export const BUNKER_ROW = HEIGHT - 4;
24
+ const LIVES = 3;
25
+ const BOMB_EVERY = 2; // bombs fall on every other tick, so they can be dodged
26
+ const SHOT_SPEED = 2; // rows per tick — a slow shot makes the whole game a queue
27
+
28
+ /** Top rows are worth more, and look meaner. */
29
+ export const KINDS = [
30
+ { art: "▛▜", points: 30, paint: rgb(190, 130, 255) },
31
+ { art: "▛▜", points: 20, paint: rgb(90, 200, 250) },
32
+ { art: "▙▟", points: 20, paint: acid },
33
+ { art: "▙▟", points: 10, paint: amber },
34
+ { art: "▞▚", points: 10, paint: ash },
35
+ ];
36
+
37
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
38
+
39
+ /** Where an alien sits, given the fleet's corner. */
40
+ export const alienAt = (fleet, row, col) => ({
41
+ x: fleet.x + col * PITCH_X,
42
+ y: fleet.y + row * PITCH_Y,
43
+ });
44
+
45
+ export const aliveCount = (fleet) => fleet.alive.reduce((n, row) => n + row.filter(Boolean).length, 0);
46
+
47
+ /**
48
+ * How many ticks between steps. Forty aliens crawl; the last one sprints. This
49
+ * is the entire difficulty curve of the game and it costs one line.
50
+ */
51
+ export const cadence = (fleet) => Math.max(2, Math.round(aliveCount(fleet) / 2.4));
52
+
53
+ const newFleet = (wave) => ({
54
+ x: 3,
55
+ y: 1 + Math.min(3, wave - 1), // each wave starts closer to the floor
56
+ dir: 1,
57
+ alive: Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => true)),
58
+ clock: 0,
59
+ });
60
+
61
+ /** Four bunkers, each cell able to take two hits before it is gone. */
62
+ export function buildBunkers() {
63
+ const bunkers = new Map();
64
+ for (let b = 0; b < 4; b++) {
65
+ const left = 5 + b * 10;
66
+ for (let i = 0; i < 5; i++) bunkers.set(left + i, 2);
67
+ }
68
+ return bunkers;
69
+ }
70
+
71
+ /** Chip a bunker cell, and say whether there was one there to chip. */
72
+ export function chip(bunkers, x, y) {
73
+ if (y !== BUNKER_ROW) return false;
74
+ const hp = bunkers.get(x);
75
+ if (!hp) return false;
76
+ if (hp <= 1) bunkers.delete(x); else bunkers.set(x, hp - 1);
77
+ return true;
78
+ }
79
+
80
+ /** The lowest live alien in each column — the only ones that can drop a bomb. */
81
+ export function frontLine(fleet) {
82
+ const front = [];
83
+ for (let col = 0; col < COLS; col++) {
84
+ for (let row = ROWS - 1; row >= 0; row--) {
85
+ if (fleet.alive[row][col]) { front.push({ row, col }); break; }
86
+ }
87
+ }
88
+ return front;
89
+ }
90
+
91
+ /** Whether a shot at (x, y) hits an alien, and which one. */
92
+ export function alienHit(fleet, x, y) {
93
+ for (let row = 0; row < ROWS; row++) {
94
+ for (let col = 0; col < COLS; col++) {
95
+ if (!fleet.alive[row][col]) continue;
96
+ const at = alienAt(fleet, row, col);
97
+ if (y === at.y && x >= at.x && x <= at.x + 1) return { row, col };
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function marchFleet(state) {
104
+ const fleet = state.fleet;
105
+ const cols = [];
106
+ for (let col = 0; col < COLS; col++) if (fleet.alive.some((row) => row[col])) cols.push(col);
107
+ if (!cols.length) return state;
108
+ const leftMost = fleet.x + cols[0] * PITCH_X;
109
+ const rightMost = fleet.x + cols[cols.length - 1] * PITCH_X + 1;
110
+
111
+ if ((fleet.dir > 0 && rightMost >= WIDTH - 1) || (fleet.dir < 0 && leftMost <= 0)) {
112
+ fleet.dir *= -1;
113
+ fleet.y += 1;
114
+ } else {
115
+ fleet.x += fleet.dir;
116
+ }
117
+
118
+ // The fleet landing is the loss condition, and it beats having lives left.
119
+ for (let row = 0; row < ROWS; row++) {
120
+ for (let col = 0; col < COLS; col++) {
121
+ if (fleet.alive[row][col] && alienAt(fleet, row, col).y >= BUNKER_ROW) {
122
+ state.over = `the fleet landed · ${state.score} points`;
123
+ return state;
124
+ }
125
+ }
126
+ }
127
+ return state;
128
+ }
129
+
130
+ /** One tick. Exported so a test can clear a wave with no clock. */
131
+ export function step(state) {
132
+ const { rng } = state;
133
+
134
+ // The shot climbs a row at a time even though it covers two, so it can never
135
+ // skip over the row an alien is standing on.
136
+ for (let i = 0; i < SHOT_SPEED && state.shot; i++) {
137
+ state.shot.y -= 1;
138
+ if (state.shot.y < 0) { state.shot = null; break; }
139
+ if (chip(state.bunkers, state.shot.x, state.shot.y)) { state.shot = null; break; }
140
+ const hit = alienHit(state.fleet, state.shot.x, state.shot.y);
141
+ if (!hit) continue;
142
+ state.fleet.alive[hit.row][hit.col] = false;
143
+ state.score += KINDS[hit.row].points;
144
+ state.shot = null;
145
+ if (!aliveCount(state.fleet)) {
146
+ state.wave++;
147
+ state.fleet = newFleet(state.wave);
148
+ state.bombs = [];
149
+ return state;
150
+ }
151
+ }
152
+
153
+ state.tick++;
154
+ if (state.tick % BOMB_EVERY === 0) {
155
+ for (const bomb of state.bombs) bomb.y += 1;
156
+ state.bombs = state.bombs.filter((bomb) => {
157
+ if (chip(state.bunkers, bomb.x, bomb.y)) return false;
158
+ if (bomb.y >= FLOOR_ROW) return false;
159
+ if (bomb.y === CANNON_ROW && Math.abs(bomb.x - state.cannon) <= 1) {
160
+ state.lives--;
161
+ if (state.lives <= 0) { state.lives = 0; state.over = `out of cannons · ${state.score} points`; }
162
+ return false;
163
+ }
164
+ return true;
165
+ });
166
+ }
167
+ if (state.over) return state;
168
+
169
+ // Somebody on the front line lets one go, more often the fewer are left.
170
+ const front = frontLine(state.fleet);
171
+ if (front.length && state.bombs.length < 3 && rng() < 0.02 + (COLS - front.length) * 0.004) {
172
+ const from = front[Math.floor(rng() * front.length) % front.length];
173
+ const at = alienAt(state.fleet, from.row, from.col);
174
+ state.bombs.push({ x: at.x, y: at.y + 1 });
175
+ }
176
+
177
+ state.fleet.clock++;
178
+ if (state.fleet.clock >= cadence(state.fleet)) {
179
+ state.fleet.clock = 0;
180
+ marchFleet(state);
181
+ }
182
+ return state;
183
+ }
184
+
185
+ export const INVADERS = {
186
+ key: "invaders",
187
+ aliases: ["spaceinvaders", "space", "aliens"],
188
+ title: "SPACE INVADERS",
189
+ blurb: "forty of them, and the last one moves fastest",
190
+ keys: "← → move · space fire · q quit",
191
+ tickMs: 55,
192
+
193
+ create({ rng = Math.random } = {}) {
194
+ return {
195
+ fleet: newFleet(1),
196
+ bunkers: buildBunkers(),
197
+ cannon: Math.floor(WIDTH / 2),
198
+ shot: null,
199
+ bombs: [],
200
+ score: 0,
201
+ lives: LIVES,
202
+ wave: 1,
203
+ tick: 0,
204
+ over: null,
205
+ rng,
206
+ };
207
+ },
208
+
209
+ tick: step,
210
+
211
+ onKey(state, key) {
212
+ if (key === "left") state.cannon = clamp(state.cannon - 1, 1, WIDTH - 2);
213
+ else if (key === "right") state.cannon = clamp(state.cannon + 1, 1, WIDTH - 2);
214
+ else if (key === "space" || key === "up" || key === "enter") {
215
+ // One shot in the air at a time. Everything about the pacing of this game
216
+ // comes from that single rule.
217
+ if (!state.shot) state.shot = { x: state.cannon, y: CANNON_ROW - 1 };
218
+ }
219
+ return state;
220
+ },
221
+
222
+ status(state) {
223
+ return state.over
224
+ ? state.over
225
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
226
+ },
227
+
228
+ render(state) {
229
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
230
+ const put = (x, y, glyph) => {
231
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
232
+ grid[y][x] = glyph;
233
+ };
234
+
235
+ for (let row = 0; row < ROWS; row++) {
236
+ for (let col = 0; col < COLS; col++) {
237
+ if (!state.fleet.alive[row][col]) continue;
238
+ const at = alienAt(state.fleet, row, col);
239
+ const kind = KINDS[row];
240
+ [...kind.art].forEach((c, i) => put(at.x + i, at.y, kind.paint(c)));
241
+ }
242
+ }
243
+
244
+ for (const [x, hp] of state.bunkers) put(x, BUNKER_ROW, hp > 1 ? acid("█") : dim("▓"));
245
+ for (const bomb of state.bombs) put(bomb.x, bomb.y, danger("╽"));
246
+ if (state.shot) put(state.shot.x, state.shot.y, bone("│"));
247
+
248
+ if (state.over) {
249
+ put(state.cannon, CANNON_ROW, danger("✷"));
250
+ } else {
251
+ [...("▟█▙")].forEach((c, i) => put(state.cannon - 1 + i, CANNON_ROW, acid(c)));
252
+ }
253
+
254
+ return grid.map((row, y) => row.map((cell) => (
255
+ cell ?? (y === FLOOR_ROW ? ash("═") : " ")
256
+ )).join(""));
257
+ },
258
+ };