moshcode 0.40.0 → 0.42.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,191 @@
1
+ // Kong. Girders, ladders, barrels, and a climb you have done before.
2
+ //
3
+ // The girders here are flat. The originals slope, and a slope is the one thing
4
+ // this board cannot honestly draw — a terminal row is a terminal row, and faking
5
+ // it with half-blocks would make a game that looks like the arcade and plays
6
+ // like a bug report. What the slope actually *does* is kept: each girder has a
7
+ // direction, they alternate, and the ladder down sits at the far end of each
8
+ // one. So barrels cross a whole girder and drop, and a player climbing the same
9
+ // ladders walks every girder the opposite way — which is why you meet them head
10
+ // on instead of following them around.
11
+ import { acid, amber, ash, bone, danger, rgb } from "./ui.mjs";
12
+
13
+ export const WIDTH = 34;
14
+ export const HEIGHT = 17;
15
+
16
+ /**
17
+ * Top to bottom: the row, the way barrels roll along it, the column of the
18
+ * ladder barrels come down, and a second ladder that only you use.
19
+ *
20
+ * The second one is not decoration. With a single ladder per girder, the only
21
+ * way up is the chute the barrels fall down, and climbing it is a coin toss you
22
+ * cannot jump out of — the board becomes unplayable rather than hard.
23
+ */
24
+ export const GIRDERS = [
25
+ { y: 3, dir: -1, ladder: 3, climb: WIDTH - 12 },
26
+ { y: 6, dir: 1, ladder: WIDTH - 4, climb: 8 },
27
+ { y: 9, dir: -1, ladder: 3, climb: WIDTH - 12 },
28
+ { y: 12, dir: 1, ladder: WIDTH - 4, climb: 8 },
29
+ { y: 15, dir: -1, ladder: null, climb: null },
30
+ ];
31
+
32
+ export const TOP = GIRDERS[0].y;
33
+ export const FLOOR = GIRDERS[GIRDERS.length - 1].y;
34
+
35
+ /** Where Kong stands and throws from, and the way out beside him. */
36
+ export const KONG_X = WIDTH - 3;
37
+ const GOAL_X = WIDTH - 6;
38
+
39
+ const LIVES = 3;
40
+ const JUMP_TICKS = 6;
41
+ const kong = rgb(190, 130, 255);
42
+
43
+ export const girderAt = (y) => GIRDERS.find((g) => g.y === y) ?? null;
44
+
45
+ /** The ladders, derived from the girders so the two can never disagree. */
46
+ export const LADDERS = GIRDERS.slice(0, -1).flatMap((g, i) => (
47
+ [g.ladder, g.climb].map((x) => ({ x, top: g.y, bottom: GIRDERS[i + 1].y, barrels: x === g.ladder }))
48
+ ));
49
+
50
+ export const ladderAt = (x, y) => LADDERS.find((l) => l.x === x && y >= l.top && y <= l.bottom) ?? null;
51
+
52
+ /** A barrel starts at Kong's end of the top girder and works its way down. */
53
+ export const newBarrel = () => ({ x: KONG_X, y: TOP, falling: null, jumped: false });
54
+
55
+ /**
56
+ * One barrel step: along its girder in that girder's direction, and down the
57
+ * ladder at the end of it. Rolling off the bottom girder is how a barrel leaves.
58
+ */
59
+ export function rollBarrel(state, barrel) {
60
+ if (barrel.falling !== null) {
61
+ barrel.y += 1;
62
+ if (barrel.y >= barrel.falling) barrel.falling = null;
63
+ return barrel;
64
+ }
65
+ const girder = girderAt(barrel.y);
66
+ if (!girder) { barrel.done = true; return barrel; }
67
+ if (girder.ladder !== null && barrel.x === girder.ladder) {
68
+ // Mostly it takes the ladder; sometimes it carries on and rolls off the end,
69
+ // which is the only thing that makes two barrels behave differently.
70
+ if (state.rng() < 0.8) {
71
+ barrel.falling = GIRDERS[GIRDERS.indexOf(girder) + 1].y;
72
+ return barrel;
73
+ }
74
+ }
75
+ barrel.x += girder.dir;
76
+ if (barrel.x < 0 || barrel.x >= WIDTH) barrel.done = true;
77
+ return barrel;
78
+ }
79
+
80
+ function lose(state, why) {
81
+ state.lives--;
82
+ if (state.lives <= 0) {
83
+ state.lives = 0;
84
+ state.over = `${why} · ${state.score} points`;
85
+ return state;
86
+ }
87
+ state.player = { x: 1, y: FLOOR, jump: 0 };
88
+ state.barrels = [];
89
+ return state;
90
+ }
91
+
92
+ /** Barrels roll this often, and a level makes them quicker. */
93
+ export const rollEvery = (state) => Math.max(2, 5 - Math.floor(state.level / 2));
94
+ export const throwEvery = (state) => Math.max(14, 40 - state.level * 5);
95
+
96
+ /** One tick. Exported so a test can climb the whole board with no clock. */
97
+ export function step(state) {
98
+ state.clock++;
99
+ if (state.player.jump > 0) state.player.jump--;
100
+
101
+ if (state.clock % rollEvery(state) === 0) {
102
+ for (const barrel of state.barrels) rollBarrel(state, barrel);
103
+ state.barrels = state.barrels.filter((b) => !b.done);
104
+ }
105
+ if (state.clock % throwEvery(state) === 0) state.barrels.push(newBarrel());
106
+
107
+ for (const barrel of state.barrels) {
108
+ if (barrel.x !== state.player.x || barrel.y !== state.player.y) continue;
109
+ // A barrel you are in the air over is a barrel you have jumped.
110
+ if (!state.player.jump) return lose(state, "flattened by a barrel");
111
+ if (!barrel.jumped) { barrel.jumped = true; state.score += 100; }
112
+ }
113
+
114
+ if (state.player.y === TOP && state.player.x >= GOAL_X) {
115
+ state.level++;
116
+ state.score += 1000;
117
+ state.player = { x: 1, y: FLOOR, jump: 0 };
118
+ state.barrels = [];
119
+ }
120
+ return state;
121
+ }
122
+
123
+ export const KONG = {
124
+ key: "kong",
125
+ aliases: ["dk", "barrels", "climb"],
126
+ title: "KONG",
127
+ blurb: "five girders, four ladders, and a barrel with your name on it",
128
+ keys: "← → walk · ↑ ↓ ladders · space jump · q quit",
129
+ tickMs: 60,
130
+
131
+ create({ rng = Math.random } = {}) {
132
+ return {
133
+ player: { x: 1, y: FLOOR, jump: 0 },
134
+ barrels: [],
135
+ score: 0,
136
+ lives: LIVES,
137
+ level: 1,
138
+ clock: 0,
139
+ over: null,
140
+ rng,
141
+ };
142
+ },
143
+
144
+ tick: step,
145
+
146
+ onKey(state, pressed) {
147
+ const p = state.player;
148
+ if (pressed === "space" || pressed === "enter") {
149
+ if (!p.jump) p.jump = JUMP_TICKS;
150
+ return state;
151
+ }
152
+ if (pressed === "left" && p.x > 0) p.x -= 1;
153
+ else if (pressed === "right" && p.x < WIDTH - 1) p.x += 1;
154
+ else if (pressed === "up" || pressed === "down") {
155
+ // Ladders are the only way between girders, and you have to be standing on
156
+ // one to use it.
157
+ const ladder = ladderAt(p.x, p.y);
158
+ if (!ladder) return state;
159
+ const next = pressed === "up" ? p.y - 1 : p.y + 1;
160
+ if (next >= ladder.top && next <= ladder.bottom) p.y = next;
161
+ }
162
+ return state;
163
+ },
164
+
165
+ status(state) {
166
+ return state.over
167
+ ? state.over
168
+ : `${state.score} · level ${state.level} · ${"▲".repeat(state.lives)}`;
169
+ },
170
+
171
+ render(state) {
172
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
173
+ const put = (x, y, glyph) => {
174
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
175
+ grid[y][x] = glyph;
176
+ };
177
+
178
+ for (const girder of GIRDERS) for (let x = 0; x < WIDTH; x++) put(x, girder.y, ash("═"));
179
+ for (const ladder of LADDERS) {
180
+ for (let y = ladder.top; y <= ladder.bottom; y++) put(ladder.x, y, bone("╫"));
181
+ }
182
+ put(KONG_X, TOP - 1, kong("♜"));
183
+ put(GOAL_X, TOP - 1, amber("♥"));
184
+
185
+ for (const barrel of state.barrels) put(barrel.x, barrel.y, danger("◍"));
186
+ const p = state.player;
187
+ put(p.x, p.jump ? p.y - 1 : p.y, state.over ? danger("✷") : acid(p.jump ? "⌃" : "◉"));
188
+
189
+ return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
190
+ },
191
+ };
@@ -0,0 +1,208 @@
1
+ // OutRun. A road drawn in perspective, a clock that is always losing, and a
2
+ // checkpoint that gives you a bit of it back.
3
+ //
4
+ // This is the one game in the cabinet that fakes a third dimension, and it does
5
+ // it the way the eighties did: the road is not an object, it is a rule for
6
+ // drawing each row. Rows near the top are far away, so the tarmac is narrow
7
+ // there and the bend is wide; rows near the bottom are under your bumper, so the
8
+ // tarmac is wide and dead centre. Nothing is ever transformed — `roadHalf` and
9
+ // `centreAt` are the whole renderer, and the same two functions decide what you
10
+ // have hit.
11
+ import { acid, bone, danger, dim, rgb } from "./ui.mjs";
12
+
13
+ export const WIDTH = 48;
14
+ export const HEIGHT = 16;
15
+
16
+ const NEAR_HALF = 20; // half the road's width under your bumper
17
+ const FAR_HALF = 3; // and up at the horizon
18
+ const BEND = 15; // how far a full-lock corner throws the far end sideways
19
+
20
+ const MAX_SPEED = 1.9;
21
+ const ACCEL = 0.06;
22
+ const BRAKE = 0.12;
23
+ const DRAG = 0.012;
24
+ const OFF_ROAD = 0.55; // the fastest you will ever go with two wheels on the grass
25
+ const DRIFT = 0.5; // how hard a corner pushes you towards the outside
26
+ const START_TIME = 900; // ticks
27
+ const CHECKPOINT = 320; // and how far apart the checkpoints are
28
+ const CHECK_BONUS = 220; // flat out, a checkpoint takes about 170 ticks to reach
29
+
30
+ const grass = rgb(60, 140, 70);
31
+ const road = rgb(90, 90, 95);
32
+
33
+ /** How far from the middle the tarmac reaches on a given row. */
34
+ export const roadHalf = (row) => {
35
+ const p = (row + 1) / HEIGHT;
36
+ return FAR_HALF + (NEAR_HALF - FAR_HALF) * p ** 1.7;
37
+ };
38
+
39
+ /**
40
+ * Where the middle of the road is on a given row.
41
+ *
42
+ * The bend is squared against distance so it opens up towards the horizon and
43
+ * closes to nothing under the car — which is exactly what a corner looks like
44
+ * from the driver's seat, and why the bottom row never moves.
45
+ */
46
+ export const centreAt = (row, curve) => {
47
+ const p = (row + 1) / HEIGHT;
48
+ return WIDTH / 2 + curve * BEND * (1 - p) ** 2;
49
+ };
50
+
51
+ export const PLAYER_ROW = HEIGHT - 1;
52
+ export const CAR_W = 3;
53
+
54
+ /** A stretch of road: how long it runs, and how hard it bends. */
55
+ export function nextSegment(rng) {
56
+ return { left: 40 + Math.floor(rng() * 60), curve: (rng() * 2 - 1) * (rng() < 0.35 ? 1 : 0.45) };
57
+ }
58
+
59
+ /** Whether the car is on the tarmac at all. */
60
+ export const onRoad = (x, curve) => Math.abs(x - centreAt(PLAYER_ROW, curve)) <= roadHalf(PLAYER_ROW) - 1;
61
+
62
+ /** Where a car at depth `z` (1 at the horizon, 0 at your bumper) is drawn. */
63
+ export const rowAt = (z) => Math.round(PLAYER_ROW - z * (PLAYER_ROW - 1));
64
+
65
+ function spin(state) {
66
+ state.speed = 0;
67
+ state.spins++;
68
+ state.stunned = 30;
69
+ return state;
70
+ }
71
+
72
+ /** One tick of road. Exported so a test can drive the whole stage with no clock. */
73
+ export function step(state) {
74
+ state.clock--;
75
+ if (state.clock <= 0) {
76
+ state.clock = 0;
77
+ state.over = `time up · ${Math.round(state.dist)} miles`;
78
+ return state;
79
+ }
80
+ if (state.stunned > 0) state.stunned--;
81
+
82
+ // The road ahead, one segment at a time, eased towards rather than snapped to.
83
+ state.segment.left -= state.speed;
84
+ if (state.segment.left <= 0) state.segment = nextSegment(state.rng);
85
+ state.curve += (state.segment.curve - state.curve) * 0.04;
86
+
87
+ state.speed = Math.max(0, state.speed - DRAG);
88
+ const off = !onRoad(state.car, state.curve);
89
+ if (off) state.speed = Math.min(state.speed, OFF_ROAD);
90
+ if (state.stunned) state.speed = Math.min(state.speed, 0.2);
91
+
92
+ state.dist += state.speed;
93
+ // A corner throws you at the outside of it. Steering is how you stay in.
94
+ state.car += state.curve * state.speed * DRIFT;
95
+ state.car = Math.max(0, Math.min(WIDTH - 1, state.car));
96
+
97
+ for (const car of state.traffic) car.z -= (state.speed - car.speed) * 0.012;
98
+ state.traffic = state.traffic.filter((c) => c.z > -0.05 && c.z < 1.2);
99
+ if (state.traffic.length < 3 && state.rng() < 0.03) {
100
+ state.traffic.push({ z: 1.1, lane: state.rng() * 1.4 - 0.7, speed: 0.35 + state.rng() * 0.5 });
101
+ }
102
+
103
+ if (!state.stunned) {
104
+ const hit = state.traffic.find((c) => {
105
+ if (c.z > 0.08) return false;
106
+ const at = centreAt(PLAYER_ROW, state.curve) + c.lane * roadHalf(PLAYER_ROW);
107
+ return Math.abs(at - state.car) < CAR_W;
108
+ });
109
+ if (hit) {
110
+ state.traffic = state.traffic.filter((c) => c !== hit);
111
+ spin(state);
112
+ }
113
+ }
114
+
115
+ if (state.dist >= state.nextCheck) {
116
+ state.nextCheck += CHECKPOINT;
117
+ state.clock += CHECK_BONUS;
118
+ state.checks++;
119
+ state.score += 1000;
120
+ }
121
+ state.score += Math.floor(state.dist) - state.scored;
122
+ state.scored = Math.floor(state.dist);
123
+ return state;
124
+ }
125
+
126
+ export const OUTRUN = {
127
+ key: "outrun",
128
+ aliases: ["run", "coast", "racer"],
129
+ title: "OUTRUN",
130
+ blurb: "a road that bends, traffic that doesn't, and a clock that always wins",
131
+ keys: "← → steer · ↑ throttle · ↓ brake · q quit",
132
+ tickMs: 55,
133
+
134
+ create({ rng = Math.random } = {}) {
135
+ return {
136
+ car: WIDTH / 2,
137
+ speed: 0,
138
+ curve: 0,
139
+ segment: nextSegment(rng),
140
+ traffic: [],
141
+ dist: 0,
142
+ scored: 0,
143
+ nextCheck: CHECKPOINT,
144
+ checks: 0,
145
+ clock: START_TIME,
146
+ stunned: 0,
147
+ spins: 0,
148
+ score: 0,
149
+ over: null,
150
+ rng,
151
+ };
152
+ },
153
+
154
+ tick: step,
155
+
156
+ onKey(state, pressed) {
157
+ if (pressed === "left") state.car -= 0.9;
158
+ else if (pressed === "right") state.car += 0.9;
159
+ else if (pressed === "up") state.speed = Math.min(MAX_SPEED, state.speed + ACCEL * 4);
160
+ else if (pressed === "down") state.speed = Math.max(0, state.speed - BRAKE * 2);
161
+ state.car = Math.max(0, Math.min(WIDTH - 1, state.car));
162
+ return state;
163
+ },
164
+
165
+ status(state) {
166
+ if (state.over) return state.over;
167
+ const kph = Math.round(state.speed * 120);
168
+ return `${kph} kph · ${Math.round(state.clock / 20)}s · check ${state.checks} · ${Math.round(state.dist)} mi`;
169
+ },
170
+
171
+ render(state) {
172
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
173
+ const put = (x, y, glyph) => {
174
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
175
+ grid[y][x] = glyph;
176
+ };
177
+
178
+ for (const car of state.traffic) {
179
+ const row = rowAt(car.z);
180
+ if (row < 1 || row > PLAYER_ROW) continue;
181
+ const at = centreAt(row, state.curve) + car.lane * roadHalf(row);
182
+ // Cars shrink with distance, the same way the road does.
183
+ const w = Math.max(1, Math.round(CAR_W * ((row + 1) / HEIGHT)));
184
+ for (let i = 0; i < w; i++) put(Math.round(at) - Math.floor(w / 2) + i, row, danger("▀"));
185
+ }
186
+
187
+ const nose = state.stunned ? danger("✷") : acid("▟▙");
188
+ put(Math.round(state.car) - 1, PLAYER_ROW, state.stunned ? nose : acid("▟"));
189
+ put(Math.round(state.car), PLAYER_ROW, state.stunned ? nose : acid("█"));
190
+ put(Math.round(state.car) + 1, PLAYER_ROW, state.stunned ? nose : acid("▙"));
191
+
192
+ return grid.map((row, y) => {
193
+ const centre = centreAt(y, state.curve);
194
+ const half = roadHalf(y);
195
+ return row.map((cell, x) => {
196
+ if (cell) return cell;
197
+ const from = centre - half;
198
+ const to = centre + half;
199
+ if (x < from || x > to) return grass("░");
200
+ // Kerbs, and a centre line that moves with you so the road runs.
201
+ if (x < from + 1 || x > to - 1) return ((y + Math.floor(state.dist)) % 4 < 2 ? bone : danger)("│");
202
+ const middle = Math.round(centre);
203
+ if (x === middle && (y + Math.floor(state.dist * 1.5)) % 4 < 2) return dim("┆");
204
+ return road(" ");
205
+ }).join("");
206
+ });
207
+ },
208
+ };
@@ -0,0 +1,205 @@
1
+ // Pac-Man, arcade-sized. One maze, 240-odd dots, four power pellets and three
2
+ // ghosts that are not quite as clever as the ones in 1980 — on purpose. This is
3
+ // a game you can win on a coffee break.
4
+ import { acid, amber, ash, dim, rgb } from "./ui.mjs";
5
+
6
+ /**
7
+ * `#` wall · `.` dot · `o` power pellet · `P` where pac starts · `G` the pen.
8
+ *
9
+ * Symmetric, fully connected, and small enough that the whole board fits above
10
+ * the pit's prompt without scrolling.
11
+ */
12
+ export const MAZE = [
13
+ "###################",
14
+ "#........#........#",
15
+ "#o##.###.#.###.##o#",
16
+ "#.................#",
17
+ "#.##.#.#####.#.##.#",
18
+ "#....#...G...#....#",
19
+ "####.##.###.##.####",
20
+ "#........P........#",
21
+ "#.##.####.####.##.#",
22
+ "#o...............o#",
23
+ "###################",
24
+ ];
25
+
26
+ export const WIDTH = MAZE[0].length;
27
+ export const HEIGHT = MAZE.length;
28
+
29
+ const WALL = ash("██");
30
+ const DOT = dim("· ");
31
+ const POWER = amber("✳ ");
32
+ const PAC = acid("● ");
33
+ const BLANK = " ";
34
+ const GHOST_COLORS = [rgb(255, 77, 61), rgb(255, 120, 180), rgb(90, 220, 250)];
35
+ const SCARED = rgb(90, 140, 255);
36
+
37
+ const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
38
+ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
39
+ const FRIGHT_TICKS = 40;
40
+
41
+ export const isWall = (x, y) => MAZE[y]?.[x] === "#" || MAZE[y]?.[x] === undefined;
42
+ const cellKey = (x, y) => `${x},${y}`;
43
+ const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
44
+
45
+ /** Every dot and pellet in the maze, as a fresh map. */
46
+ export function pellets() {
47
+ const map = new Map();
48
+ for (let y = 0; y < HEIGHT; y++) {
49
+ for (let x = 0; x < WIDTH; x++) {
50
+ const cell = MAZE[y][x];
51
+ if (cell === "." || cell === "o") map.set(cellKey(x, y), cell);
52
+ }
53
+ }
54
+ return map;
55
+ }
56
+
57
+ function find(char) {
58
+ for (let y = 0; y < HEIGHT; y++) {
59
+ const x = MAZE[y].indexOf(char);
60
+ if (x >= 0) return { x, y };
61
+ }
62
+ return { x: 1, y: 1 };
63
+ }
64
+
65
+ /** Where everything stands at the start of a life. */
66
+ function positions() {
67
+ const pen = find("G");
68
+ const pac = find("P");
69
+ return {
70
+ pac: { ...pac, dir: "left", want: "left" },
71
+ // Three ghosts abreast in the pen — the two beside it are open corridor in
72
+ // this maze, which is what keeps them from stepping on each other at spawn.
73
+ ghosts: [
74
+ { x: pen.x, y: pen.y },
75
+ { x: pen.x - 1, y: pen.y },
76
+ { x: pen.x + 1, y: pen.y },
77
+ ].map((g, i) => ({ ...g, home: { x: g.x, y: g.y }, dir: "up", color: i })),
78
+ };
79
+ }
80
+
81
+ /** Directions a ghost may take from where it stands. */
82
+ export function options(ghost, { allowReverse = false } = {}) {
83
+ const open = Object.entries(DIRS)
84
+ .filter(([name, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy)
85
+ && (allowReverse || name !== OPPOSITE[ghost.dir]));
86
+ // A dead end is the one place reversing is the only move there is.
87
+ return open.length ? open : Object.entries(DIRS).filter(([, [dx, dy]]) => !isWall(ghost.x + dx, ghost.y + dy));
88
+ }
89
+
90
+ /**
91
+ * One ghost step. Chases by Manhattan distance, flees while you are lit up,
92
+ * and takes a random legal turn one time in five so the three of them do not
93
+ * arrive in a single-file line.
94
+ */
95
+ export function moveGhost(ghost, pac, { frightened = false, rng = Math.random } = {}) {
96
+ const open = options(ghost);
97
+ if (!open.length) return ghost;
98
+ const scored = open.map(([name, [dx, dy]]) => ({
99
+ name,
100
+ dx,
101
+ dy,
102
+ d: distance({ x: ghost.x + dx, y: ghost.y + dy }, pac),
103
+ }));
104
+ let choice;
105
+ if (rng() < 0.2) choice = scored[Math.floor(rng() * scored.length) % scored.length];
106
+ else {
107
+ const sorted = scored.slice().sort((a, b) => (frightened ? b.d - a.d : a.d - b.d));
108
+ choice = sorted[0];
109
+ }
110
+ ghost.dir = choice.name;
111
+ ghost.x += choice.dx;
112
+ ghost.y += choice.dy;
113
+ return ghost;
114
+ }
115
+
116
+ function caught(state) {
117
+ const hits = state.ghosts.filter((g) => g.x === state.pac.x && g.y === state.pac.y);
118
+ if (!hits.length) return false;
119
+ if (state.fright > 0) {
120
+ for (const g of hits) {
121
+ state.score += 200;
122
+ Object.assign(g, { x: g.home.x, y: g.home.y, dir: "up" });
123
+ }
124
+ return false;
125
+ }
126
+ state.lives--;
127
+ if (state.lives <= 0) { state.over = "game over"; return true; }
128
+ Object.assign(state, positions(), { fright: 0 });
129
+ return true;
130
+ }
131
+
132
+ export const PACMAN = {
133
+ key: "pacman",
134
+ aliases: ["pac", "pacmam", "puckman"],
135
+ title: "PAC-MAN",
136
+ blurb: "eat the dots, dodge the ghosts, ✳ makes them edible",
137
+ keys: "← ↑ ↓ → steer · q quit",
138
+ tickMs: 150,
139
+
140
+ create({ rng = Math.random } = {}) {
141
+ return { ...positions(), dots: pellets(), score: 0, lives: 3, fright: 0, frame: 0, over: null, rng };
142
+ },
143
+
144
+ tick(state) {
145
+ state.frame++;
146
+ const pac = state.pac;
147
+ // A turn is remembered until it becomes legal, which is what makes a corner
148
+ // feel like a corner rather than a keypress you have to time.
149
+ const wanted = DIRS[pac.want];
150
+ if (wanted && !isWall(pac.x + wanted[0], pac.y + wanted[1])) pac.dir = pac.want;
151
+ const [dx, dy] = DIRS[pac.dir];
152
+ if (!isWall(pac.x + dx, pac.y + dy)) { pac.x += dx; pac.y += dy; }
153
+
154
+ const here = state.dots.get(cellKey(pac.x, pac.y));
155
+ if (here) {
156
+ state.dots.delete(cellKey(pac.x, pac.y));
157
+ state.score += here === "o" ? 50 : 10;
158
+ if (here === "o") state.fright = FRIGHT_TICKS;
159
+ }
160
+ if (!state.dots.size) { state.over = "maze cleared 🤘"; return state; }
161
+ if (caught(state)) return state;
162
+
163
+ // Ghosts move at half speed, and slower still while they are running away.
164
+ const beat = state.fright > 0 ? 3 : 2;
165
+ if (state.frame % beat === 0) {
166
+ for (const ghost of state.ghosts) {
167
+ moveGhost(ghost, pac, { frightened: state.fright > 0, rng: state.rng });
168
+ }
169
+ caught(state);
170
+ }
171
+ if (state.fright > 0) state.fright--;
172
+ return state;
173
+ },
174
+
175
+ onKey(state, pressed) {
176
+ if (DIRS[pressed]) state.pac.want = pressed;
177
+ return state;
178
+ },
179
+
180
+ status(state) {
181
+ const left = state.dots.size;
182
+ if (state.over) return `${state.over} · ${state.score} points`;
183
+ return `score ${state.score} · lives ${"●".repeat(Math.max(0, state.lives))} · dots ${left}`
184
+ + (state.fright > 0 ? " · RUN" : "");
185
+ },
186
+
187
+ render(state) {
188
+ const rows = [];
189
+ for (let y = 0; y < HEIGHT; y++) {
190
+ let row = "";
191
+ for (let x = 0; x < WIDTH; x++) {
192
+ const ghost = state.ghosts.find((g) => g.x === x && g.y === y);
193
+ if (state.pac.x === x && state.pac.y === y) row += PAC;
194
+ else if (ghost) row += (state.fright > 0 ? SCARED : GHOST_COLORS[ghost.color])("▲ ");
195
+ else if (MAZE[y][x] === "#") row += WALL;
196
+ else {
197
+ const pellet = state.dots.get(cellKey(x, y));
198
+ row += pellet === "o" ? POWER : pellet === "." ? DOT : BLANK;
199
+ }
200
+ }
201
+ rows.push(row);
202
+ }
203
+ return rows;
204
+ },
205
+ };