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,241 @@
1
+ // Centipede. It comes down through the mushrooms, and every piece you shoot out
2
+ // of the middle leaves you two of them.
3
+ //
4
+ // The trick that makes this cheap: the centipede is not a linked body, it is a
5
+ // list of segments that each obey the same rule — walk sideways, and when
6
+ // something is in the way, drop a row and turn round. Kept that way, a shot to
7
+ // the middle needs no surgery at all. You remove one segment and the ones
8
+ // behind it simply carry on, which is exactly what splitting looks like.
9
+ import { acid, amber, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 38;
12
+ export const HEIGHT = 18;
13
+
14
+ /** The bottom strip is yours; the centipede comes down into it. */
15
+ export const ZONE_TOP = HEIGHT - 5;
16
+ const MUSHROOM_HP = 4;
17
+ const LIVES = 3;
18
+ const SHOTS = 3;
19
+ const SHOT_SPEED = 2;
20
+
21
+ const shroom = rgb(120, 190, 40);
22
+ const MUSH_ART = ["▁", "▄", "▆", "█"]; // fuller the healthier
23
+
24
+ const key = (x, y) => `${x},${y}`;
25
+
26
+ /** A field of mushrooms, none of them in the row you stand on. */
27
+ export function seedField(rng, count = 34) {
28
+ const field = new Map();
29
+ for (let i = 0; i < count; i++) {
30
+ const x = Math.floor(rng() * WIDTH);
31
+ const y = 1 + Math.floor(rng() * (HEIGHT - 3));
32
+ if (y >= HEIGHT - 1) continue;
33
+ field.set(key(x, y), MUSHROOM_HP);
34
+ }
35
+ return field;
36
+ }
37
+
38
+ /** Chip a mushroom, and say whether one was there. A dead one is worth points. */
39
+ export function bite(field, x, y) {
40
+ const hp = field.get(key(x, y));
41
+ if (!hp) return 0;
42
+ if (hp <= 1) { field.delete(key(x, y)); return 5; }
43
+ field.set(key(x, y), hp - 1);
44
+ return 1;
45
+ }
46
+
47
+ /** A fresh centipede, strung out along the top row. */
48
+ export function newCentipede(length = 10) {
49
+ return Array.from({ length }, (_, i) => ({ x: length - 1 - i, y: 0, dir: 1, down: 1 }));
50
+ }
51
+
52
+ const blocked = (state, x, y) => x < 0 || x >= WIDTH || state.field.has(key(x, y));
53
+
54
+ /** Walk one segment: sideways if it can, otherwise down a row and about turn. */
55
+ export function walk(state, seg) {
56
+ if (!blocked(state, seg.x + seg.dir, seg.y)) { seg.x += seg.dir; return seg; }
57
+ seg.dir *= -1;
58
+ seg.y += seg.down;
59
+ // It bounces off the floor and climbs back up rather than vanishing, which is
60
+ // what keeps the bottom of the board dangerous instead of a safe corner.
61
+ if (seg.y >= HEIGHT - 1) { seg.y = HEIGHT - 1; seg.down = -1; }
62
+ if (seg.y <= 0) { seg.y = 0; seg.down = 1; }
63
+ return seg;
64
+ }
65
+
66
+ /** How many ticks between steps — shorter as the wave goes on. */
67
+ export const cadence = (state) => Math.max(2, 7 - state.wave);
68
+
69
+ /**
70
+ * The spider: in from the side, bouncing diagonally through your strip, eating
71
+ * mushrooms as it goes.
72
+ *
73
+ * It is here because without it the bottom of the board is safe. The centipede
74
+ * only reaches your row on the sweeps it happens to end on, so a player who
75
+ * simply never moves can survive a long time — which is not a game. The spider
76
+ * is the reason you cannot stand still.
77
+ */
78
+ export function spiderStep(state) {
79
+ const spider = state.spider;
80
+ if (!spider) return state;
81
+ spider.x += spider.dx;
82
+ spider.y += spider.dy;
83
+ if (spider.y < ZONE_TOP) { spider.y = ZONE_TOP; spider.dy = 1; }
84
+ if (spider.y > HEIGHT - 1) { spider.y = HEIGHT - 1; spider.dy = -1; }
85
+ // It leaves the way it came in rather than turning round at the wall, so it
86
+ // is a visitor and not a permanent resident.
87
+ if (spider.x < 0 || spider.x >= WIDTH) { state.spider = null; return state; }
88
+ state.field.delete(key(spider.x, spider.y));
89
+ return state;
90
+ }
91
+
92
+ export function spawnSpider(state) {
93
+ const fromLeft = state.rng() < 0.5;
94
+ state.spider = {
95
+ x: fromLeft ? 0 : WIDTH - 1,
96
+ y: HEIGHT - 1 - Math.floor(state.rng() * 3),
97
+ dx: fromLeft ? 1 : -1,
98
+ dy: state.rng() < 0.5 ? -1 : 1,
99
+ };
100
+ return state;
101
+ }
102
+
103
+ function hitPlayer(state) {
104
+ state.lives--;
105
+ if (state.lives <= 0) {
106
+ state.lives = 0;
107
+ state.over = `eaten · ${state.score} points`;
108
+ return state;
109
+ }
110
+ state.centipede = newCentipede(10);
111
+ state.shots = [];
112
+ state.spider = null;
113
+ state.player = { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 };
114
+ return state;
115
+ }
116
+
117
+ /** One tick. Exported so a test can clear a wave with no clock. */
118
+ export function step(state) {
119
+ for (let i = 0; i < SHOT_SPEED; i++) {
120
+ for (const shot of [...state.shots]) {
121
+ shot.y -= 1;
122
+ if (shot.y < 0) { state.shots = state.shots.filter((s) => s !== shot); continue; }
123
+ const points = bite(state.field, shot.x, shot.y);
124
+ if (points) { state.score += points; state.shots = state.shots.filter((s) => s !== shot); continue; }
125
+ if (state.spider && state.spider.x === shot.x && state.spider.y === shot.y) {
126
+ state.spider = null;
127
+ state.shots = state.shots.filter((s) => s !== shot);
128
+ state.score += 300;
129
+ continue;
130
+ }
131
+ const seg = state.centipede.find((s) => s.x === shot.x && s.y === shot.y);
132
+ if (!seg) continue;
133
+ state.shots = state.shots.filter((s) => s !== shot);
134
+ state.centipede = state.centipede.filter((s) => s !== seg);
135
+ state.score += 10;
136
+ // Every piece you take out of it leaves a mushroom where it fell, which
137
+ // is how the field thickens and the next wave gets harder for free.
138
+ state.field.set(key(seg.x, seg.y), MUSHROOM_HP);
139
+ }
140
+ }
141
+
142
+ if (!state.centipede.length) {
143
+ state.wave++;
144
+ state.centipede = newCentipede(10);
145
+ state.score += 100;
146
+ return state;
147
+ }
148
+
149
+ state.clock++;
150
+ if (state.clock >= cadence(state)) {
151
+ state.clock = 0;
152
+ for (const seg of state.centipede) walk(state, seg);
153
+ if (state.centipede.some((s) => s.x === state.player.x && s.y === state.player.y)) return hitPlayer(state);
154
+ }
155
+
156
+ state.spiderClock++;
157
+ if (state.spiderClock % 3 === 0) {
158
+ spiderStep(state);
159
+ const spider = state.spider;
160
+ if (spider && spider.x === state.player.x && spider.y === state.player.y) return hitPlayer(state);
161
+ }
162
+ if (!state.spider && state.rng() < 0.02) spawnSpider(state);
163
+ return state;
164
+ }
165
+
166
+ export const CENTIPEDE = {
167
+ key: "centipede",
168
+ aliases: ["cent", "bug", "millipede"],
169
+ title: "CENTIPEDE",
170
+ blurb: "shoot it in the middle and now there are two of them",
171
+ keys: "← ↑ ↓ → move · space fire · q quit",
172
+ tickMs: 55,
173
+
174
+ create({ rng = Math.random } = {}) {
175
+ return {
176
+ field: seedField(rng),
177
+ centipede: newCentipede(10),
178
+ player: { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 },
179
+ shots: [],
180
+ spider: null,
181
+ spiderClock: 0,
182
+ score: 0,
183
+ lives: LIVES,
184
+ wave: 1,
185
+ clock: 0,
186
+ over: null,
187
+ rng,
188
+ };
189
+ },
190
+
191
+ tick: step,
192
+
193
+ onKey(state, pressed) {
194
+ const p = state.player;
195
+ // You are free in the bottom strip and nowhere else — the whole game is
196
+ // fought in five rows.
197
+ if (pressed === "left") p.x = Math.max(0, p.x - 1);
198
+ else if (pressed === "right") p.x = Math.min(WIDTH - 1, p.x + 1);
199
+ else if (pressed === "up") p.y = Math.max(ZONE_TOP, p.y - 1);
200
+ else if (pressed === "down") p.y = Math.min(HEIGHT - 1, p.y + 1);
201
+ else if (pressed === "space" || pressed === "enter") {
202
+ if (state.shots.length < SHOTS) state.shots.push({ x: p.x, y: p.y - 1 });
203
+ return state;
204
+ }
205
+ // Walking into a mushroom is walking into a wall.
206
+ if (state.field.has(key(p.x, p.y))) {
207
+ if (pressed === "left") p.x += 1;
208
+ else if (pressed === "right") p.x -= 1;
209
+ else if (pressed === "up") p.y += 1;
210
+ else if (pressed === "down") p.y -= 1;
211
+ }
212
+ return state;
213
+ },
214
+
215
+ status(state) {
216
+ return state.over
217
+ ? state.over
218
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
219
+ },
220
+
221
+ render(state) {
222
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
223
+ const put = (x, y, glyph) => {
224
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
225
+ grid[y][x] = glyph;
226
+ };
227
+
228
+ for (const [at, hp] of state.field) {
229
+ const [x, y] = at.split(",").map(Number);
230
+ put(x, y, shroom(MUSH_ART[hp - 1] ?? "▁"));
231
+ }
232
+ for (const seg of state.centipede) put(seg.x, seg.y, danger("◍"));
233
+ if (state.spider) put(state.spider.x, state.spider.y, rgb(190, 130, 255)("✻"));
234
+ for (const shot of state.shots) put(shot.x, shot.y, amber("│"));
235
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("▲"));
236
+
237
+ return grid.map((row, y) => row.map((cell) => (
238
+ cell ?? (y === ZONE_TOP - 1 ? dim("┈") : " ")
239
+ )).join(""));
240
+ },
241
+ };
@@ -0,0 +1,179 @@
1
+ // Choplifter. Fly out, land, load them up, fly home. Do it before the tanks
2
+ // work out where you are going.
3
+ //
4
+ // The world is wider than the screen, which is the whole point — the camera
5
+ // follows the chopper and the base sits off the left edge, so "get back" is a
6
+ // real journey rather than a step. Everything is stored in world columns and
7
+ // only turned into screen columns at the very end, in render().
8
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
9
+
10
+ export const WIDTH = 46; // what you can see
11
+ export const HEIGHT = 14;
12
+ export const WORLD = 150; // how far it actually goes
13
+
14
+ export const GROUND = HEIGHT - 2;
15
+ export const BASE = 6; // the pad, at the left-hand end of the world
16
+ const BASE_W = 7;
17
+ export const SEATS = 4; // how many fit in the back
18
+ const LIVES = 3;
19
+ const SHELL_EVERY = 46; // ticks between a tank taking a shot
20
+
21
+ const sand = rgb(200, 170, 110);
22
+
23
+ export const onPad = (x) => x >= BASE - 1 && x <= BASE + BASE_W;
24
+
25
+ /** Hostages waiting in the desert, and the tanks that would rather they stayed. */
26
+ export function populate(rng) {
27
+ const people = [];
28
+ for (let i = 0; i < 12; i++) {
29
+ people.push({ x: 40 + Math.floor(rng() * (WORLD - 55)), waving: true });
30
+ }
31
+ const tanks = [];
32
+ for (let i = 0; i < 4; i++) {
33
+ tanks.push({ x: 55 + Math.floor(rng() * (WORLD - 70)), dir: rng() < 0.5 ? -1 : 1 });
34
+ }
35
+ return { people, tanks };
36
+ }
37
+
38
+ function hit(state, why) {
39
+ state.lives--;
40
+ // Anybody in the back goes down with it. That is the cost of one more pickup.
41
+ state.aboard = 0;
42
+ if (state.lives <= 0) {
43
+ state.lives = 0;
44
+ state.over = `${why} · ${state.home} home`;
45
+ return state;
46
+ }
47
+ state.chopper = { x: BASE + 2, y: GROUND - 1, vy: 0 };
48
+ state.shells = [];
49
+ return state;
50
+ }
51
+
52
+ /** One tick. Exported so a test can fly a whole rescue with no clock. */
53
+ export function step(state) {
54
+ const chop = state.chopper;
55
+ chop.x = Math.max(0, Math.min(WORLD - 1, chop.x + state.throttle));
56
+ chop.y = Math.max(1, Math.min(GROUND, chop.y + chop.vy));
57
+ // Both axes drift to a stop rather than stopping dead, so it hovers when you
58
+ // let go instead of dropping out of the sky the moment you stop pressing up.
59
+ state.throttle *= 0.72;
60
+ if (Math.abs(state.throttle) < 0.05) state.throttle = 0;
61
+ chop.vy *= 0.72;
62
+ if (Math.abs(chop.vy) < 0.05) chop.vy = 0;
63
+
64
+ const landed = chop.y >= GROUND;
65
+
66
+ if (landed) {
67
+ // On the ground at the base: everybody out, and that is the score.
68
+ if (onPad(Math.round(chop.x))) {
69
+ state.home += state.aboard;
70
+ state.score += state.aboard * 100;
71
+ state.aboard = 0;
72
+ } else {
73
+ // Out in the desert: anybody close enough climbs in.
74
+ for (const person of state.people) {
75
+ if (state.aboard >= SEATS) break;
76
+ if (Math.abs(person.x - chop.x) > 2 || person.rescued) continue;
77
+ person.rescued = true;
78
+ state.aboard++;
79
+ }
80
+ state.people = state.people.filter((p) => !p.rescued);
81
+ }
82
+ }
83
+
84
+ state.clock++;
85
+ for (const tank of state.tanks) {
86
+ if (state.clock % 5 === 0) {
87
+ tank.x += tank.dir;
88
+ if (tank.x < 30 || tank.x > WORLD - 4) tank.dir *= -1;
89
+ }
90
+ // A tank shoots when you are overhead, and only then — you can outrun them.
91
+ if (state.clock % SHELL_EVERY === 0 && Math.abs(tank.x - chop.x) < 12) {
92
+ state.shells.push({ x: tank.x, y: GROUND - 1, vy: -0.55 });
93
+ }
94
+ }
95
+
96
+ for (const shell of state.shells) shell.y += shell.vy;
97
+ state.shells = state.shells.filter((s) => s.y > 0);
98
+ const struck = state.shells.find((s) => Math.abs(s.x - chop.x) < 2 && Math.abs(s.y - chop.y) < 1);
99
+ if (struck) return hit(state, "shot down");
100
+
101
+ const run_over = state.tanks.find((t) => landed && Math.abs(t.x - chop.x) < 2);
102
+ if (run_over) return hit(state, "flattened on the ground");
103
+
104
+ if (!state.people.length && !state.aboard) {
105
+ state.over = `everyone out · ${state.home} home`;
106
+ }
107
+ return state;
108
+ }
109
+
110
+ export const CHOPLIFTER = {
111
+ key: "choplifter",
112
+ aliases: ["chopper", "rescue", "heli"],
113
+ title: "CHOPLIFTER",
114
+ blurb: "fly out, land, fill the back, and get them home",
115
+ keys: "← → fly · ↑ ↓ climb and land · q quit",
116
+ tickMs: 55,
117
+
118
+ create({ rng = Math.random } = {}) {
119
+ const { people, tanks } = populate(rng);
120
+ return {
121
+ chopper: { x: BASE + 2, y: GROUND - 1, vy: 0 },
122
+ throttle: 0,
123
+ people,
124
+ tanks,
125
+ shells: [],
126
+ aboard: 0,
127
+ home: 0,
128
+ score: 0,
129
+ lives: LIVES,
130
+ clock: 0,
131
+ over: null,
132
+ rng,
133
+ };
134
+ },
135
+
136
+ tick: step,
137
+
138
+ onKey(state, pressed) {
139
+ const chop = state.chopper;
140
+ if (pressed === "left") state.throttle = Math.max(-1.4, state.throttle - 0.7);
141
+ else if (pressed === "right") state.throttle = Math.min(1.4, state.throttle + 0.7);
142
+ else if (pressed === "up") chop.vy = Math.max(-0.7, chop.vy - 0.45);
143
+ else if (pressed === "down") chop.vy = Math.min(0.7, chop.vy + 0.45);
144
+ return state;
145
+ },
146
+
147
+ status(state) {
148
+ if (state.over) return state.over;
149
+ return `${state.home} home · ${state.aboard}/${SEATS} aboard · ${state.people.length} waiting · ${"▲".repeat(state.lives)}`;
150
+ },
151
+
152
+ render(state) {
153
+ // The camera keeps the chopper in the middle until the world runs out.
154
+ const camera = Math.max(0, Math.min(WORLD - WIDTH, Math.round(state.chopper.x) - Math.floor(WIDTH / 2)));
155
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
156
+ const put = (worldX, y, glyph) => {
157
+ const x = Math.round(worldX) - camera;
158
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
159
+ grid[y][x] = glyph;
160
+ };
161
+
162
+ for (let i = 0; i < BASE_W; i++) put(BASE + i, GROUND, acid("═"));
163
+ for (const person of state.people) put(person.x, GROUND - 1, bone("Ω"));
164
+ for (const tank of state.tanks) put(tank.x, GROUND - 1, danger("▙"));
165
+ for (const shell of state.shells) put(shell.x, shell.y, amber("•"));
166
+ const chop = state.chopper;
167
+ put(chop.x, Math.round(chop.y), state.over ? danger("✷") : acid("╤"));
168
+ put(chop.x - 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
169
+ put(chop.x + 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
170
+
171
+ return grid.map((row, y) => row.map((cell, x) => {
172
+ if (cell) return cell;
173
+ if (y === GROUND) return sand("▀");
174
+ if (y > GROUND) return sand("░");
175
+ // A horizon marker every ten columns of world, so flying feels like it.
176
+ return (x + camera) % 12 === 0 && y === 1 ? dim("│") : " ";
177
+ }).join(""));
178
+ },
179
+ };
@@ -0,0 +1,267 @@
1
+ // Dig Dug. You are underground, everything down here wants you, and the only
2
+ // wall between you and it is the ground you have not dug yet.
3
+ //
4
+ // The board is the enemy AI. There is no pathfinding: a monster walks the tunnel
5
+ // it is in and turns towards you at a junction, so the shape you dig is the
6
+ // shape of the fight. Dig a straight line and they queue up behind you; dig a
7
+ // loop and they come round both ends of it.
8
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
9
+
10
+ export const WIDTH = 40;
11
+ export const HEIGHT = 16;
12
+ export const SKY = 1; // rows above this are open air
13
+
14
+ const LIVES = 3;
15
+ const HARPOON = 5; // how far the pump reaches
16
+ const POPS_AT = 3; // pumps to burst a monster
17
+ const MONSTER_EVERY = 9; // ticks between monster steps
18
+ const ROCK_EVERY = 3;
19
+
20
+ const soil = rgb(150, 100, 60);
21
+ const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
22
+
23
+ const key = (x, y) => `${x},${y}`;
24
+
25
+ /** Solid ground everywhere below the sky, minus the shafts the level starts with. */
26
+ export function buildGround() {
27
+ const ground = new Set();
28
+ for (let y = SKY + 1; y < HEIGHT; y++) for (let x = 0; x < WIDTH; x++) ground.add(key(x, y));
29
+ return ground;
30
+ }
31
+
32
+ export const isDug = (ground, x, y) => (
33
+ x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT && !ground.has(key(x, y))
34
+ );
35
+
36
+ /** Where the monsters and rocks start — spread out, and never on top of you. */
37
+ export function populate(state, level) {
38
+ state.monsters = [];
39
+ for (let i = 0; i < 3 + Math.min(3, level - 1); i++) {
40
+ state.monsters.push({
41
+ x: 6 + Math.floor(state.rng() * (WIDTH - 12)),
42
+ y: SKY + 2 + Math.floor(state.rng() * (HEIGHT - SKY - 3)),
43
+ dir: state.rng() < 0.5 ? "left" : "right",
44
+ pumped: 0,
45
+ ghost: 0,
46
+ });
47
+ }
48
+ state.rocks = [];
49
+ for (let i = 0; i < 4; i++) {
50
+ state.rocks.push({
51
+ x: 4 + Math.floor(state.rng() * (WIDTH - 8)),
52
+ y: SKY + 2 + Math.floor(state.rng() * 5),
53
+ falling: false,
54
+ });
55
+ }
56
+ // Every monster and rock starts buried, so the board opens up only where you
57
+ // dig it.
58
+ for (const thing of [...state.monsters, ...state.rocks]) state.ground.delete(key(thing.x, thing.y));
59
+ return state;
60
+ }
61
+
62
+ function lose(state, why) {
63
+ state.lives--;
64
+ if (state.lives <= 0) {
65
+ state.lives = 0;
66
+ state.over = `${why} · ${state.score} points`;
67
+ return state;
68
+ }
69
+ state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
70
+ state.harpoon = null;
71
+ state.ground.delete(key(state.player.x, state.player.y));
72
+ return state;
73
+ }
74
+
75
+ /** A rock with nothing under it falls, and takes anything under it with it. */
76
+ export function fallRocks(state) {
77
+ for (const rock of state.rocks) {
78
+ const below = { x: rock.x, y: rock.y + 1 };
79
+ if (!rock.falling && !isDug(state.ground, below.x, below.y)) continue;
80
+ if (below.y >= HEIGHT) { rock.falling = false; continue; }
81
+ rock.falling = true;
82
+ rock.y += 1;
83
+ state.ground.delete(key(rock.x, rock.y));
84
+ const squashed = state.monsters.filter((m) => m.x === rock.x && m.y === rock.y);
85
+ if (squashed.length) {
86
+ state.monsters = state.monsters.filter((m) => !squashed.includes(m));
87
+ state.score += squashed.length * 200;
88
+ }
89
+ if (state.player.x === rock.x && state.player.y === rock.y) return lose(state, "under a rock");
90
+ }
91
+ return state;
92
+ }
93
+
94
+ /** One monster step: down its own tunnel, turning towards you at a junction. */
95
+ export function walkMonster(state, monster) {
96
+ if (monster.pumped) return monster; // a hooked monster is going nowhere
97
+ const player = state.player;
98
+
99
+ // Once in a while one gives up on the tunnels and comes straight through the
100
+ // ground at you. Without it, a player who digs one deep hole is untouchable.
101
+ if (monster.ghost > 0) {
102
+ monster.ghost--;
103
+ monster.x += Math.sign(player.x - monster.x);
104
+ if (monster.x === player.x) monster.y += Math.sign(player.y - monster.y);
105
+ return monster;
106
+ }
107
+ if (state.rng() < 0.02) { monster.ghost = 6; return monster; }
108
+
109
+ const options = Object.entries(DIRS)
110
+ .filter(([, [dx, dy]]) => isDug(state.ground, monster.x + dx, monster.y + dy))
111
+ .sort(([, a], [, b]) => {
112
+ const da = Math.abs(monster.x + a[0] - player.x) + Math.abs(monster.y + a[1] - player.y);
113
+ const db = Math.abs(monster.x + b[0] - player.x) + Math.abs(monster.y + b[1] - player.y);
114
+ return da - db;
115
+ });
116
+ if (!options.length) return monster;
117
+ // It prefers the way it is already going when that is no worse, so it does not
118
+ // jitter on the spot at a crossroads.
119
+ const [name, [dx, dy]] = options[0];
120
+ monster.dir = name;
121
+ monster.x += dx;
122
+ monster.y += dy;
123
+ return monster;
124
+ }
125
+
126
+ /** One tick. Exported so a test can clear a level with no clock. */
127
+ export function step(state) {
128
+ state.clock++;
129
+
130
+ if (state.clock % ROCK_EVERY === 0) {
131
+ fallRocks(state);
132
+ if (state.over) return state;
133
+ }
134
+
135
+ if (state.harpoon) {
136
+ // The harpoon holds whatever it caught; it is the pump that does the work.
137
+ const hooked = state.monsters.find((m) => m === state.harpoon.on);
138
+ if (!hooked) state.harpoon = null;
139
+ }
140
+
141
+ if (state.clock % MONSTER_EVERY === 0) {
142
+ for (const monster of state.monsters) walkMonster(state, monster);
143
+ const caught = state.monsters.find((m) => m.x === state.player.x && m.y === state.player.y);
144
+ if (caught) return lose(state, "caught underground");
145
+ }
146
+
147
+ if (!state.monsters.length) {
148
+ state.level++;
149
+ state.ground = buildGround();
150
+ state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
151
+ state.ground.delete(key(state.player.x, state.player.y));
152
+ state.score += 500;
153
+ populate(state, state.level);
154
+ }
155
+ return state;
156
+ }
157
+
158
+ /** Fire the harpoon down the tunnel you are facing, or pump what is on it. */
159
+ export function pump(state) {
160
+ if (state.harpoon) {
161
+ const monster = state.harpoon.on;
162
+ monster.pumped++;
163
+ if (monster.pumped >= POPS_AT) {
164
+ state.monsters = state.monsters.filter((m) => m !== monster);
165
+ state.score += 100 * POPS_AT;
166
+ state.harpoon = null;
167
+ }
168
+ return state;
169
+ }
170
+ const [dx, dy] = DIRS[state.player.dir];
171
+ for (let i = 1; i <= HARPOON; i++) {
172
+ const x = state.player.x + dx * i;
173
+ const y = state.player.y + dy * i;
174
+ if (!isDug(state.ground, x, y)) break; // it does not go through dirt
175
+ const monster = state.monsters.find((m) => m.x === x && m.y === y);
176
+ if (monster) {
177
+ state.harpoon = { on: monster, x, y };
178
+ monster.pumped = 1;
179
+ return state;
180
+ }
181
+ }
182
+ return state;
183
+ }
184
+
185
+ export const DIGDUG = {
186
+ key: "digdug",
187
+ aliases: ["dig", "pooka"],
188
+ title: "DIG DUG",
189
+ blurb: "dig the tunnels, pump the monsters, drop rocks on the rest",
190
+ keys: "← ↑ ↓ → dig · space pump · q quit",
191
+ tickMs: 60,
192
+
193
+ create({ rng = Math.random } = {}) {
194
+ const state = {
195
+ ground: buildGround(),
196
+ player: { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" },
197
+ monsters: [],
198
+ rocks: [],
199
+ harpoon: null,
200
+ score: 0,
201
+ lives: LIVES,
202
+ level: 1,
203
+ clock: 0,
204
+ over: null,
205
+ rng,
206
+ };
207
+ state.ground.delete(key(state.player.x, state.player.y));
208
+ return populate(state, 1);
209
+ },
210
+
211
+ tick: step,
212
+
213
+ onKey(state, pressed) {
214
+ if (pressed === "space" || pressed === "enter") return pump(state);
215
+ const move = DIRS[pressed];
216
+ if (!move) return state;
217
+ // Moving is digging: the tunnel is wherever you have been.
218
+ state.harpoon = null;
219
+ state.player.dir = pressed;
220
+ const x = state.player.x + move[0];
221
+ const y = state.player.y + move[1];
222
+ if (x < 0 || x >= WIDTH || y <= SKY || y >= HEIGHT) return state;
223
+ if (state.rocks.some((r) => r.x === x && r.y === y && !r.falling)) return state;
224
+ if (state.ground.delete(key(x, y))) state.score += 1;
225
+ state.player.x = x;
226
+ state.player.y = y;
227
+ return state;
228
+ },
229
+
230
+ status(state) {
231
+ return state.over
232
+ ? state.over
233
+ : `${state.score} · level ${state.level} · ${state.monsters.length} left · ${"▲".repeat(state.lives)}`;
234
+ },
235
+
236
+ render(state) {
237
+ const grid = Array.from({ length: HEIGHT }, (_, y) => Array.from({ length: WIDTH }, (_, x) => (
238
+ state.ground.has(key(x, y)) ? soil("▒") : null
239
+ )));
240
+ const put = (x, y, glyph) => {
241
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
242
+ grid[y][x] = glyph;
243
+ };
244
+
245
+ for (const rock of state.rocks) put(rock.x, rock.y, ash("▣"));
246
+ for (const monster of state.monsters) {
247
+ // A monster part-way through being pumped is visibly bigger, which is the
248
+ // only feedback the pump gives you.
249
+ const art = monster.pumped >= 2 ? "◯" : monster.pumped ? "◎" : "◉";
250
+ put(monster.x, monster.y, (monster.ghost ? amber : danger)(art));
251
+ }
252
+ if (state.harpoon) {
253
+ const [dx, dy] = DIRS[state.player.dir];
254
+ for (let i = 1; i < HARPOON; i++) {
255
+ const x = state.player.x + dx * i;
256
+ const y = state.player.y + dy * i;
257
+ if (x === state.harpoon.x && y === state.harpoon.y) break;
258
+ put(x, y, bone(dx ? "─" : "│"));
259
+ }
260
+ }
261
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("◈"));
262
+
263
+ return grid.map((row, y) => row.map((cell) => (
264
+ cell ?? (y <= SKY ? dim("·") : " ")
265
+ )).join(""));
266
+ },
267
+ };