pi-pac-man 0.1.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.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # pi-pac-man
2
+
3
+ Play a terminal-native Pac-Man game inside [Pi](https://github.com/earendil-works/pi).
4
+
5
+ `pi-pac-man` adds a persistent TUI overlay opened with `/pac-man`. You move Pac-Man through multi-level ASCII mazes while four local ghosts chase, scatter, and flee deterministically.
6
+
7
+ ## Features
8
+
9
+ - Centered terminal overlay, built with Pi TUI
10
+ - Three larger levels
11
+ - Four named ghosts: Blinky, Pinky, Inky, and Clyde
12
+ - Pellets, power pellets, frightened ghosts, lives, scoring, and level clears
13
+ - Arrow-key and WASD input
14
+ - Session persistence across Pi reloads/resumes
15
+ - Validated saved state, wall collision checks, and deterministic rules
16
+ - TypeScript source, no build step required
17
+ - Manual npm publish workflow included
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:pi-pac-man
23
+ ```
24
+
25
+ Or add it to `~/.pi/agent/settings.json`:
26
+
27
+ ```json
28
+ {
29
+ "packages": ["npm:pi-pac-man"]
30
+ }
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ Start or resume a game:
36
+
37
+ ```text
38
+ /pac-man
39
+ ```
40
+
41
+ Start a fresh game:
42
+
43
+ ```text
44
+ /pac-man new
45
+ ```
46
+
47
+ ## Controls
48
+
49
+ | Key | Action |
50
+ | --- | --- |
51
+ | Arrow keys | Move Pac-Man |
52
+ | `W` `A` `S` `D` | Move Pac-Man |
53
+ | `n` | New game |
54
+ | `q` | Close overlay |
55
+
56
+ The overlay is TUI-only. It will not open in Pi print, JSON, or RPC modes.
57
+
58
+ ## Symbols
59
+
60
+ | Symbol | Meaning |
61
+ | --- | --- |
62
+ | Green `P` | Pac-Man |
63
+ | Red `B` `K` `I` `C` | Dangerous ghosts |
64
+ | Blue `g` | Frightened ghost |
65
+ | `.` | Pellet |
66
+ | `o` | Power pellet |
67
+ | `#` | Wall |
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ npm install
73
+ npm test
74
+ npm run check
75
+ ```
76
+
77
+ Try the extension locally:
78
+
79
+ ```bash
80
+ pi -e ./extensions/index.ts
81
+ ```
82
+
83
+ Then run:
84
+
85
+ ```text
86
+ /pac-man
87
+ ```
88
+
89
+ ## Package Notes
90
+
91
+ Pi loads TypeScript extensions directly, so this package publishes the source `.ts` files. Pi core packages are declared as peer dependencies to avoid bundling duplicate Pi runtime packages.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,415 @@
1
+ export type Direction = "up" | "right" | "down" | "left";
2
+ export type Status = "playing" | "won" | "lost";
3
+ export type GhostName = "Blinky" | "Pinky" | "Inky" | "Clyde";
4
+ export type Point = { x: number; y: number };
5
+ export type Actor = Point & { direction: Direction };
6
+ export type GhostState = Point & { name: GhostName; home: Point };
7
+
8
+ export type GameState = {
9
+ level: number;
10
+ pacman: Actor;
11
+ ghosts: GhostState[];
12
+ pellets: Point[];
13
+ powers: Point[];
14
+ score: number;
15
+ lives: number;
16
+ fright: number;
17
+ status: Status;
18
+ ticks: number;
19
+ };
20
+
21
+ export type Level = {
22
+ rows: string[];
23
+ pacman: Point;
24
+ ghosts: GhostState[];
25
+ pellets: Point[];
26
+ powers: Point[];
27
+ scatter: Record<GhostName, Point>;
28
+ width: number;
29
+ height: number;
30
+ };
31
+
32
+ const RAW_LEVELS = [
33
+ [
34
+ "###################",
35
+ "#P...............B#",
36
+ "#.###.###.###.###.#",
37
+ "#o....K...I....o..#",
38
+ "#.###.#.###.#.###.#",
39
+ "#.....#..C..#.....#",
40
+ "###.#.#####.#.###.#",
41
+ "#.................#",
42
+ "#.###.#.###.#.###.#",
43
+ "#o...............o#",
44
+ "###################",
45
+ ],
46
+ [
47
+ "###################",
48
+ "#P..o....#....o..B#",
49
+ "#.###.##.#.##.###.#",
50
+ "#.....K.....I.....#",
51
+ "###.#.###.###.#.###",
52
+ "#...#.....C.....#.#",
53
+ "#.#.#.#######.#.#.#",
54
+ "#.................#",
55
+ "#.###.#.###.#.###.#",
56
+ "#o...............o#",
57
+ "###################",
58
+ ],
59
+ [
60
+ "###################",
61
+ "#P...o.........o.B#",
62
+ "#.###.###.###.###.#",
63
+ "#.....K..#..I.....#",
64
+ "#.###.#.###.#.###.#",
65
+ "#o....#..C..#....o#",
66
+ "###.#.##.#.##.#.###",
67
+ "#.................#",
68
+ "#.###.#.###.#.###.#",
69
+ "#o...............o#",
70
+ "###################",
71
+ ],
72
+ ] as const;
73
+
74
+ const GHOSTS = { B: "Blinky", K: "Pinky", I: "Inky", C: "Clyde" } as const;
75
+ const GHOST_CHARS: Record<GhostName, string> = { Blinky: "B", Pinky: "K", Inky: "I", Clyde: "C" };
76
+ const STEPS: Record<Direction, Point> = {
77
+ up: { x: 0, y: -1 },
78
+ right: { x: 1, y: 0 },
79
+ down: { x: 0, y: 1 },
80
+ left: { x: -1, y: 0 },
81
+ };
82
+
83
+ export const directions: Direction[] = ["up", "right", "down", "left"];
84
+ export const LEVELS: Level[] = RAW_LEVELS.map(parseLevel);
85
+
86
+ export class PacManGame {
87
+ level: number;
88
+ pacman: Actor;
89
+ ghosts: GhostState[];
90
+ pellets: Point[];
91
+ powers: Point[];
92
+ score: number;
93
+ lives: number;
94
+ fright: number;
95
+ status: Status;
96
+ ticks: number;
97
+
98
+ constructor(state: GameState = newLevelState(0, 0, 3)) {
99
+ const parsed = parseGameState(state);
100
+ this.level = parsed.level;
101
+ this.pacman = copyActor(parsed.pacman);
102
+ this.ghosts = parsed.ghosts.map(copyGhost);
103
+ this.pellets = parsed.pellets.map(copyPoint);
104
+ this.powers = parsed.powers.map(copyPoint);
105
+ this.score = parsed.score;
106
+ this.lives = parsed.lives;
107
+ this.fright = parsed.fright;
108
+ this.status = parsed.status;
109
+ this.ticks = parsed.ticks;
110
+ }
111
+
112
+ get width(): number {
113
+ return this.map.width;
114
+ }
115
+
116
+ get height(): number {
117
+ return this.map.height;
118
+ }
119
+
120
+ move(direction: Direction, options: { moveGhosts?: boolean } = {}): boolean {
121
+ if (this.status !== "playing") return false;
122
+ const next = movePoint(this.pacman, direction);
123
+ if (!this.isOpen(next)) return false;
124
+ this.pacman = { ...next, direction };
125
+ this.ticks++;
126
+ this.eat();
127
+ if (this.hitGhost()) return true;
128
+ if (this.clearIfDone()) return true;
129
+ if (options.moveGhosts !== false) this.moveGhosts();
130
+ return true;
131
+ }
132
+
133
+ moveGhosts(): void {
134
+ if (this.status !== "playing") return;
135
+ const targets = this.ghostTargets();
136
+ this.ghosts = this.ghosts.map((ghost, i) => ({
137
+ ...ghost,
138
+ ...bestStep(ghost, targets[i], this.fright > 0, this),
139
+ }));
140
+ this.hitGhost();
141
+ if (this.status === "playing" && this.fright > 0) this.fright--;
142
+ }
143
+
144
+ ghostTargets(): Point[] {
145
+ return this.ghosts.map((ghost) => {
146
+ if (this.fright > 0) return this.map.scatter[ghost.name];
147
+ if (ghost.name === "Blinky") return this.pacman;
148
+ if (ghost.name === "Pinky") return ahead(this.pacman, 2);
149
+ if (ghost.name === "Inky") return this.ticks % 6 < 3 ? this.map.scatter.Inky : this.pacman;
150
+ return distance(ghost, this.pacman) <= 4 ? this.map.scatter.Clyde : this.pacman;
151
+ });
152
+ }
153
+
154
+ legalDirections(point: Point = this.pacman): Direction[] {
155
+ return directions.filter((direction) => this.isOpen(movePoint(point, direction)));
156
+ }
157
+
158
+ statusText(): string {
159
+ const base = `L${this.level + 1} Score ${this.score} Lives ${this.lives}`;
160
+ const fright = this.fright ? ` Power ${this.fright}` : "";
161
+ return this.status === "playing" ? base + fright : `${this.status.toUpperCase()} ${base}`;
162
+ }
163
+
164
+ toState(): GameState {
165
+ return {
166
+ level: this.level,
167
+ pacman: copyActor(this.pacman),
168
+ ghosts: this.ghosts.map(copyGhost),
169
+ pellets: this.pellets.map(copyPoint),
170
+ powers: this.powers.map(copyPoint),
171
+ score: this.score,
172
+ lives: this.lives,
173
+ fright: this.fright,
174
+ status: this.status,
175
+ ticks: this.ticks,
176
+ };
177
+ }
178
+
179
+ static fromState(state: unknown): PacManGame {
180
+ return new PacManGame(parseGameState(state));
181
+ }
182
+
183
+ private get map(): Level {
184
+ return LEVELS[this.level];
185
+ }
186
+
187
+ private isOpen(point: Point): boolean {
188
+ return this.map.rows[point.y]?.[point.x] === " ";
189
+ }
190
+
191
+ private eat(): void {
192
+ const beforePellets = this.pellets.length;
193
+ const beforePowers = this.powers.length;
194
+ this.pellets = this.pellets.filter((pellet) => !samePoint(pellet, this.pacman));
195
+ this.powers = this.powers.filter((power) => !samePoint(power, this.pacman));
196
+ if (this.pellets.length < beforePellets) this.score += 10;
197
+ if (this.powers.length < beforePowers) {
198
+ this.score += 50;
199
+ this.fright = 8;
200
+ }
201
+ }
202
+
203
+ private hitGhost(): boolean {
204
+ const hits = this.ghosts.filter((ghost) => samePoint(ghost, this.pacman));
205
+ if (!hits.length) return false;
206
+ if (this.fright > 0) {
207
+ for (const hit of hits) {
208
+ this.score += 200;
209
+ this.ghosts = this.ghosts.map((ghost) => ghost === hit ? { ...ghost, ...copyPoint(ghost.home) } : ghost);
210
+ }
211
+ return false;
212
+ }
213
+ this.lives--;
214
+ this.fright = 0;
215
+ if (this.lives <= 0) this.status = "lost";
216
+ else this.resetActors();
217
+ return true;
218
+ }
219
+
220
+ private clearIfDone(): boolean {
221
+ if (this.pellets.length || this.powers.length) return false;
222
+ if (this.level === LEVELS.length - 1) this.status = "won";
223
+ else this.loadLevel(this.level + 1);
224
+ return true;
225
+ }
226
+
227
+ private resetActors(): void {
228
+ this.pacman = { ...copyPoint(this.map.pacman), direction: "right" };
229
+ this.ghosts = this.ghosts.map((ghost) => ({ ...ghost, ...copyPoint(ghost.home) }));
230
+ }
231
+
232
+ private loadLevel(level: number): void {
233
+ const next = newLevelState(level, this.score, this.lives);
234
+ this.level = next.level;
235
+ this.pacman = next.pacman;
236
+ this.ghosts = next.ghosts;
237
+ this.pellets = next.pellets;
238
+ this.powers = next.powers;
239
+ this.fright = 0;
240
+ this.ticks = 0;
241
+ }
242
+ }
243
+
244
+ export function boardPrompt(game: PacManGame): string {
245
+ const level = LEVELS[game.level];
246
+ return level.rows.map((row, y) =>
247
+ [...row].map((cell, x) => renderCell(game, cell, { x, y })).join(""),
248
+ ).join("\n");
249
+ }
250
+
251
+ export function parseGameState(value: unknown): GameState {
252
+ if (!isRecord(value) || typeof value.level !== "number" || !Number.isInteger(value.level)) throw new Error("Invalid state");
253
+ const level = value.level;
254
+ if (level < 0 || level >= LEVELS.length) throw new Error("Invalid state");
255
+ const map = LEVELS[level];
256
+ const state = {
257
+ level,
258
+ pacman: parseActor(value.pacman, map),
259
+ ghosts: parseGhosts(value.ghosts, map),
260
+ pellets: parsePoints(value.pellets, map, "pellet"),
261
+ powers: parsePoints(value.powers, map, "power"),
262
+ score: parseNonNegativeInt(value.score, "score"),
263
+ lives: parseNonNegativeInt(value.lives, "lives"),
264
+ fright: parseNonNegativeInt(value.fright, "fright"),
265
+ status: parseStatus(value.status),
266
+ ticks: parseNonNegativeInt(value.ticks, "ticks"),
267
+ };
268
+ if (!state.ghosts.length) throw new Error("Invalid ghost");
269
+ return state;
270
+ }
271
+
272
+ function parseLevel(rows: readonly string[]): Level {
273
+ const width = rows[0].length;
274
+ const level: Level = {
275
+ rows: rows.map((row) => row.replace(/[PBIKC.o]/g, " ")),
276
+ pacman: { x: 1, y: 1 },
277
+ ghosts: [],
278
+ pellets: [],
279
+ powers: [],
280
+ scatter: {
281
+ Blinky: { x: 1, y: rows.length - 2 },
282
+ Pinky: { x: width - 2, y: rows.length - 2 },
283
+ Inky: { x: width - 2, y: 1 },
284
+ Clyde: { x: 1, y: rows.length - 2 },
285
+ },
286
+ width,
287
+ height: rows.length,
288
+ };
289
+ for (const [y, row] of rows.entries()) {
290
+ if (row.length !== width) throw new Error("Invalid level");
291
+ for (const [x, cell] of [...row].entries()) {
292
+ if (cell === ".") level.pellets.push({ x, y });
293
+ else if (cell === "o") level.powers.push({ x, y });
294
+ else if (cell === "P") level.pacman = { x, y };
295
+ else if (cell in GHOSTS) {
296
+ const name = GHOSTS[cell as keyof typeof GHOSTS];
297
+ level.ghosts.push({ name, x, y, home: { x, y } });
298
+ }
299
+ }
300
+ }
301
+ return level;
302
+ }
303
+
304
+ function newLevelState(level: number, score: number, lives: number): GameState {
305
+ const map = LEVELS[level];
306
+ return {
307
+ level,
308
+ pacman: { ...copyPoint(map.pacman), direction: "right" },
309
+ ghosts: map.ghosts.map(copyGhost),
310
+ pellets: map.pellets.map(copyPoint),
311
+ powers: map.powers.map(copyPoint),
312
+ score,
313
+ lives,
314
+ fright: 0,
315
+ status: "playing",
316
+ ticks: 0,
317
+ };
318
+ }
319
+
320
+ function renderCell(game: PacManGame, cell: string, point: Point): string {
321
+ const ghosts = game.ghosts.filter((ghost) => samePoint(ghost, point));
322
+ if (samePoint(game.pacman, point) && ghosts.length) return "X";
323
+ if (samePoint(game.pacman, point)) return "P";
324
+ if (ghosts[0]) return game.fright ? "g" : GHOST_CHARS[ghosts[0].name];
325
+ if (game.powers.some((power) => samePoint(power, point))) return "o";
326
+ if (game.pellets.some((pellet) => samePoint(pellet, point))) return ".";
327
+ return cell;
328
+ }
329
+
330
+ function bestStep(ghost: GhostState, target: Point, away: boolean, game: PacManGame): Point {
331
+ const steps = game.legalDirections(ghost).map((direction) => movePoint(ghost, direction));
332
+ return steps.sort((a, b) => (away ? distance(b, target) - distance(a, target) : distance(a, target) - distance(b, target)))[0] ?? ghost;
333
+ }
334
+
335
+ function ahead(actor: Actor, cells: number): Point {
336
+ let point: Point = actor;
337
+ for (let i = 0; i < cells; i++) point = movePoint(point, actor.direction);
338
+ return point;
339
+ }
340
+
341
+ function movePoint(point: Point, direction: Direction): Point {
342
+ const step = STEPS[direction];
343
+ return { x: point.x + step.x, y: point.y + step.y };
344
+ }
345
+
346
+ function parseActor(value: unknown, map: Level): Actor {
347
+ if (!isRecord(value)) throw new Error("Invalid pacman");
348
+ const point = parsePoint(value, map, "pacman");
349
+ if (!isDirection(value.direction)) throw new Error("Invalid pacman");
350
+ return { ...point, direction: value.direction };
351
+ }
352
+
353
+ function parseGhosts(value: unknown, map: Level): GhostState[] {
354
+ if (!Array.isArray(value)) throw new Error("Invalid ghost");
355
+ return value.map((ghost) => {
356
+ if (!isRecord(ghost) || !isGhostName(ghost.name)) throw new Error("Invalid ghost");
357
+ return { ...parsePoint(ghost, map, "ghost"), name: ghost.name, home: parsePoint(ghost.home, map, "ghost") };
358
+ });
359
+ }
360
+
361
+ function parsePoints(value: unknown, map: Level, name: string): Point[] {
362
+ if (!Array.isArray(value)) throw new Error(`Invalid ${name}`);
363
+ return value.map((point) => parsePoint(point, map, name));
364
+ }
365
+
366
+ function parsePoint(value: unknown, map: Level, name: string): Point {
367
+ if (!isRecord(value)) throw new Error(`Invalid ${name}`);
368
+ const { x, y } = value;
369
+ if (typeof x !== "number" || typeof y !== "number" || !Number.isInteger(x) || !Number.isInteger(y)) throw new Error(`Invalid ${name}`);
370
+ const point = { x, y };
371
+ if (map.rows[y]?.[x] !== " ") throw new Error(`Invalid ${name}`);
372
+ return point;
373
+ }
374
+
375
+ function parseNonNegativeInt(value: unknown, name: string): number {
376
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`Invalid ${name}`);
377
+ return value;
378
+ }
379
+
380
+ function parseStatus(value: unknown): Status {
381
+ if (value === "playing" || value === "won" || value === "lost") return value;
382
+ throw new Error("Invalid status");
383
+ }
384
+
385
+ function isDirection(value: unknown): value is Direction {
386
+ return value === "up" || value === "right" || value === "down" || value === "left";
387
+ }
388
+
389
+ function isGhostName(value: unknown): value is GhostName {
390
+ return value === "Blinky" || value === "Pinky" || value === "Inky" || value === "Clyde";
391
+ }
392
+
393
+ function samePoint(a: Point, b: Point): boolean {
394
+ return a.x === b.x && a.y === b.y;
395
+ }
396
+
397
+ function copyPoint(point: Point): Point {
398
+ return { x: point.x, y: point.y };
399
+ }
400
+
401
+ function copyActor(actor: Actor): Actor {
402
+ return { ...copyPoint(actor), direction: actor.direction };
403
+ }
404
+
405
+ function copyGhost(ghost: GhostState): GhostState {
406
+ return { ...copyPoint(ghost), name: ghost.name, home: copyPoint(ghost.home) };
407
+ }
408
+
409
+ function distance(a: Point, b: Point): number {
410
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
411
+ }
412
+
413
+ function isRecord(value: unknown): value is Record<string, unknown> {
414
+ return typeof value === "object" && value !== null;
415
+ }
@@ -0,0 +1,170 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ type TUI,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { boardPrompt, type Direction, PacManGame } from "./game.ts";
11
+
12
+ const SAVE_TYPE = "pac-man-save";
13
+ const colors = {
14
+ green: "\x1b[32m",
15
+ red: "\x1b[31m",
16
+ blue: "\x1b[34m",
17
+ reset: "\x1b[0m",
18
+ };
19
+
20
+ class PacManComponent implements Component {
21
+ private error = "";
22
+ private game: PacManGame;
23
+ private onClose: () => void;
24
+ private onMove: (direction: Direction) => void;
25
+ private onNew: () => void;
26
+ private tui: TUI;
27
+
28
+ constructor(tui: TUI, game: PacManGame, onClose: () => void, onMove: (direction: Direction) => void, onNew: () => void) {
29
+ this.tui = tui;
30
+ this.game = game;
31
+ this.onClose = onClose;
32
+ this.onMove = onMove;
33
+ this.onNew = onNew;
34
+ }
35
+
36
+ handleInput(data: string): void {
37
+ if (matchesKey(data, "q")) return this.onClose();
38
+ if (matchesKey(data, "n")) return this.onNew();
39
+ const direction = inputDirection(data);
40
+ if (direction) this.onMove(direction);
41
+ }
42
+
43
+ render(width: number): string[] {
44
+ return [
45
+ this.center("PAC-MAN", width),
46
+ "",
47
+ ...colorBoard(this.game).split("\n").map((line) => this.center(line, width)),
48
+ "",
49
+ this.center(this.game.statusText(), width),
50
+ this.error ? this.center(`Error: ${this.error}`, width) : "",
51
+ this.center("Arrows/WASD=move n=new q=quit", width),
52
+ ].map((line) => truncateToWidth(line, width));
53
+ }
54
+
55
+ setGame(game: PacManGame): void {
56
+ this.game = game;
57
+ this.error = "";
58
+ this.tui.requestRender();
59
+ }
60
+
61
+ setError(error: string): void {
62
+ this.error = error;
63
+ this.tui.requestRender();
64
+ }
65
+
66
+ center(text: string, width: number): string {
67
+ return " ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) + text;
68
+ }
69
+
70
+ invalidate(): void {}
71
+ }
72
+
73
+ export default function pacMan(pi: ExtensionAPI): void {
74
+ let game: PacManGame | null = null;
75
+ let active: PacManComponent | null = null;
76
+
77
+ pi.on("session_start", (_event, ctx) => {
78
+ game = loadGame(ctx.sessionManager.getBranch());
79
+ });
80
+
81
+ pi.registerCommand("pac-man", {
82
+ description: "Play Pac-Man with deterministic local ghosts",
83
+ handler: async (args, ctx) => {
84
+ if (ctx.mode !== "tui") {
85
+ ctx.ui.notify("Pac-Man overlay only works in TUI mode", "error");
86
+ return;
87
+ }
88
+ if (!game || game.status !== "playing" || args.trim() === "new") {
89
+ game = new PacManGame();
90
+ save();
91
+ }
92
+ await show(ctx);
93
+ },
94
+ });
95
+
96
+ function handleMove(direction: Direction): void {
97
+ if (!game?.move(direction)) return active?.setError(game?.status === "playing" ? `Blocked: ${direction}` : "Press n for a new game");
98
+ save();
99
+ active?.setGame(game);
100
+ }
101
+
102
+ async function show(ctx: ExtensionCommandContext): Promise<void> {
103
+ await ctx.ui.custom(
104
+ (tui, _theme, _keybindings, done) => {
105
+ active = new PacManComponent(
106
+ tui,
107
+ game!,
108
+ () => {
109
+ active = null;
110
+ done(undefined);
111
+ },
112
+ handleMove,
113
+ () => {
114
+ game = new PacManGame();
115
+ save();
116
+ active?.setGame(game);
117
+ },
118
+ );
119
+ return active;
120
+ },
121
+ {
122
+ overlay: true,
123
+ overlayOptions: {
124
+ width: "50%",
125
+ maxHeight: "70%",
126
+ anchor: "center",
127
+ margin: { top: 1 },
128
+ },
129
+ },
130
+ );
131
+ }
132
+
133
+ function save(): void {
134
+ if (game) pi.appendEntry(SAVE_TYPE, game.toState());
135
+ }
136
+ }
137
+
138
+ export function colorBoard(game: PacManGame): string {
139
+ return boardPrompt(game).replace(/[PBKICXg]/g, (cell) => {
140
+ if (cell === "P") return color(cell, colors.green);
141
+ if (cell === "g") return color(cell, colors.blue);
142
+ return color(cell, colors.red);
143
+ });
144
+ }
145
+
146
+ function color(text: string, ansi: string): string {
147
+ return `${ansi}${text}${colors.reset}`;
148
+ }
149
+
150
+ function inputDirection(data: string): Direction | null {
151
+ if (matchesKey(data, Key.up) || matchesKey(data, "w")) return "up";
152
+ if (matchesKey(data, Key.right) || matchesKey(data, "d")) return "right";
153
+ if (matchesKey(data, Key.down) || matchesKey(data, "s")) return "down";
154
+ if (matchesKey(data, Key.left) || matchesKey(data, "a")) return "left";
155
+ return null;
156
+ }
157
+
158
+ function loadGame(entries: Array<{ type: string; customType?: string; data?: unknown }>): PacManGame | null {
159
+ for (let i = entries.length - 1; i >= 0; i--) {
160
+ const entry = entries[i];
161
+ if (entry.type === "custom" && entry.customType === SAVE_TYPE) {
162
+ try {
163
+ return PacManGame.fromState(entry.data);
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pi-pac-man",
3
+ "version": "0.1.0",
4
+ "description": "Pac-Man extension for Pi",
5
+ "type": "module",
6
+ "main": "./extensions/index.ts",
7
+ "scripts": {
8
+ "test": "node --test tests/*.test.ts",
9
+ "check": "tsc --noEmit"
10
+ },
11
+ "keywords": ["pi-package", "pi", "extension", "pac-man"],
12
+ "files": ["extensions", "README.md"],
13
+ "pi": {
14
+ "extensions": ["./extensions/index.ts"]
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*",
18
+ "@earendil-works/pi-tui": "*"
19
+ },
20
+ "devDependencies": {
21
+ "@earendil-works/pi-coding-agent": "latest",
22
+ "@earendil-works/pi-tui": "latest",
23
+ "@types/node": "latest",
24
+ "typescript": "latest"
25
+ },
26
+ "license": "MIT"
27
+ }