moshcode 0.40.0 → 0.41.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 +42 -0
- package/bin/moshcode.mjs +6 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +24 -0
- package/src/games-chess.mjs +376 -0
- package/src/games-hangman.mjs +97 -0
- package/src/games-pacman.mjs +205 -0
- package/src/games-snake.mjs +111 -0
- package/src/games-tetris.mjs +221 -0
- package/src/games-tictactoe.mjs +124 -0
- package/src/games.mjs +337 -0
- package/src/tui.mjs +13 -0
- package/src/ui.mjs +3 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Snake. The cheapest game in the arcade and the hardest to stop playing.
|
|
2
|
+
import { acid, amber, dim, rgb } from "./ui.mjs";
|
|
3
|
+
|
|
4
|
+
export const WIDTH = 28;
|
|
5
|
+
export const HEIGHT = 14;
|
|
6
|
+
|
|
7
|
+
const HEAD = acid("█");
|
|
8
|
+
const BODY = rgb(120, 190, 40)("▓");
|
|
9
|
+
const FOOD = amber("✳");
|
|
10
|
+
const EMPTY = dim("·");
|
|
11
|
+
|
|
12
|
+
const DIRS = {
|
|
13
|
+
up: [0, -1],
|
|
14
|
+
down: [0, 1],
|
|
15
|
+
left: [-1, 0],
|
|
16
|
+
right: [1, 0],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const same = (a, b) => a[0] === b[0] && a[1] === b[1];
|
|
20
|
+
|
|
21
|
+
/** A cell nothing is standing on, so the food never lands under the snake. */
|
|
22
|
+
export function placeFood(snake, rng) {
|
|
23
|
+
const free = [];
|
|
24
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
25
|
+
for (let x = 0; x < WIDTH; x++) {
|
|
26
|
+
if (!snake.some((s) => same(s, [x, y]))) free.push([x, y]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!free.length) return null; // the board is snake — that is a win
|
|
30
|
+
return free[Math.floor(rng() * free.length) % free.length];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One step. Returns the state; sets `over` when the head meets a wall or
|
|
35
|
+
* itself. Kept separate from the game object so a test can walk a snake into
|
|
36
|
+
* its own tail on purpose.
|
|
37
|
+
*/
|
|
38
|
+
export function step(state) {
|
|
39
|
+
const [dx, dy] = DIRS[state.dir];
|
|
40
|
+
const head = [state.snake[0][0] + dx, state.snake[0][1] + dy];
|
|
41
|
+
if (head[0] < 0 || head[0] >= WIDTH || head[1] < 0 || head[1] >= HEIGHT) {
|
|
42
|
+
state.over = "into the wall";
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
// The tail cell is about to move out from under the head, so it is only a
|
|
46
|
+
// collision when the snake is about to grow into it.
|
|
47
|
+
const eating = state.food && same(head, state.food);
|
|
48
|
+
const body = eating ? state.snake : state.snake.slice(0, -1);
|
|
49
|
+
if (body.some((s) => same(s, head))) {
|
|
50
|
+
state.over = "ate itself";
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
state.snake = [head, ...body];
|
|
54
|
+
if (eating) {
|
|
55
|
+
state.score += 10;
|
|
56
|
+
state.food = placeFood(state.snake, state.rng);
|
|
57
|
+
if (!state.food) state.over = "the whole board — no notes";
|
|
58
|
+
}
|
|
59
|
+
state.turned = false;
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const SNAKE = {
|
|
64
|
+
key: "snake",
|
|
65
|
+
aliases: ["worm", "nibbles"],
|
|
66
|
+
title: "SNAKE",
|
|
67
|
+
blurb: "eat, grow, and try not to eat yourself",
|
|
68
|
+
keys: "← ↑ ↓ → turn · q quit",
|
|
69
|
+
tickMs: (state) => Math.max(60, 130 - state.snake.length * 2),
|
|
70
|
+
|
|
71
|
+
create({ rng = Math.random } = {}) {
|
|
72
|
+
const mid = Math.floor(HEIGHT / 2);
|
|
73
|
+
const snake = [[6, mid], [5, mid], [4, mid]];
|
|
74
|
+
return { snake, dir: "right", turned: false, score: 0, over: null, rng, food: placeFood(snake, rng) };
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
tick: step,
|
|
78
|
+
|
|
79
|
+
onKey(state, key) {
|
|
80
|
+
if (!DIRS[key]) return state;
|
|
81
|
+
// One turn per tick, and never a full reverse: without either, a fast
|
|
82
|
+
// left-then-up folds the snake back through its own neck.
|
|
83
|
+
const opposite = { up: "down", down: "up", left: "right", right: "left" };
|
|
84
|
+
if (state.turned || opposite[key] === state.dir || key === state.dir) return state;
|
|
85
|
+
state.dir = key;
|
|
86
|
+
state.turned = true;
|
|
87
|
+
return state;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
status(state) {
|
|
91
|
+
return state.over
|
|
92
|
+
? `${state.over} · ${state.score} points`
|
|
93
|
+
: `score ${state.score} · length ${state.snake.length}`;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
render(state) {
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
99
|
+
let row = "";
|
|
100
|
+
for (let x = 0; x < WIDTH; x++) {
|
|
101
|
+
const cell = [x, y];
|
|
102
|
+
if (same(state.snake[0], cell)) row += HEAD;
|
|
103
|
+
else if (state.snake.some((s) => same(s, cell))) row += BODY;
|
|
104
|
+
else if (state.food && same(state.food, cell)) row += FOOD;
|
|
105
|
+
else row += EMPTY;
|
|
106
|
+
}
|
|
107
|
+
rows.push(row);
|
|
108
|
+
}
|
|
109
|
+
return rows;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Tetris, for the moshcode arcade. Ten wide, twenty deep, seven bricks.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is pure — rotate a shape, ask whether it collides, merge it,
|
|
4
|
+
// clear the full rows — so the whole game can be played in a test with no
|
|
5
|
+
// terminal anywhere near it. See src/games.mjs for the frame it is drawn in.
|
|
6
|
+
import { acid, amber, ash, danger, dim, rgb } from "./ui.mjs";
|
|
7
|
+
|
|
8
|
+
export const WIDTH = 10;
|
|
9
|
+
export const HEIGHT = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The seven tetrominoes, drawn as square grids so one rotate() handles them
|
|
13
|
+
* all. Each fills itself with its own letter, which is also its colour key —
|
|
14
|
+
* merging a piece into the board is then a copy, with no bookkeeping.
|
|
15
|
+
*/
|
|
16
|
+
export const SHAPES = {
|
|
17
|
+
I: ["....", "IIII", "....", "...."],
|
|
18
|
+
O: ["OO", "OO"],
|
|
19
|
+
T: [".T.", "TTT", "..."],
|
|
20
|
+
S: [".SS", "SS.", "..."],
|
|
21
|
+
Z: ["ZZ.", ".ZZ", "..."],
|
|
22
|
+
J: ["J..", "JJJ", "..."],
|
|
23
|
+
L: ["..L", "LLL", "..."],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const COLORS = {
|
|
27
|
+
I: rgb(90, 220, 250),
|
|
28
|
+
O: amber,
|
|
29
|
+
T: rgb(190, 130, 255),
|
|
30
|
+
S: acid,
|
|
31
|
+
Z: danger,
|
|
32
|
+
J: rgb(90, 140, 255),
|
|
33
|
+
L: rgb(255, 150, 60),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const BAG = Object.keys(SHAPES);
|
|
37
|
+
const BLOCK = "██";
|
|
38
|
+
const EMPTY = dim("· ");
|
|
39
|
+
const GHOST = ash("░░");
|
|
40
|
+
|
|
41
|
+
/** Clockwise quarter turn of a square shape. */
|
|
42
|
+
export function rotate(shape) {
|
|
43
|
+
return shape.map((_, i) => shape.map((row) => row[i]).reverse().join(""));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const emptyBoard = () =>
|
|
47
|
+
Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
|
|
48
|
+
|
|
49
|
+
/** Would this shape overlap a wall, the floor, or something already stacked? */
|
|
50
|
+
export function collides(board, shape, px, py) {
|
|
51
|
+
for (let y = 0; y < shape.length; y++) {
|
|
52
|
+
for (let x = 0; x < shape[y].length; x++) {
|
|
53
|
+
if (shape[y][x] === ".") continue;
|
|
54
|
+
const bx = px + x;
|
|
55
|
+
const by = py + y;
|
|
56
|
+
if (bx < 0 || bx >= WIDTH || by >= HEIGHT) return true;
|
|
57
|
+
// Above the ceiling is legal — that is where a piece spawns from.
|
|
58
|
+
if (by >= 0 && board[by][bx]) return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stamp a piece into the board. Mutates, and is only ever called on a lock. */
|
|
65
|
+
export function merge(board, piece) {
|
|
66
|
+
for (let y = 0; y < piece.shape.length; y++) {
|
|
67
|
+
for (let x = 0; x < piece.shape[y].length; x++) {
|
|
68
|
+
const cell = piece.shape[y][x];
|
|
69
|
+
if (cell === ".") continue;
|
|
70
|
+
const by = piece.y + y;
|
|
71
|
+
if (by >= 0) board[by][piece.x + x] = cell;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return board;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Drop out every full row, refill from the top. Returns how many went. */
|
|
78
|
+
export function clearLines(board) {
|
|
79
|
+
const kept = board.filter((row) => row.some((cell) => !cell));
|
|
80
|
+
const cleared = HEIGHT - kept.length;
|
|
81
|
+
while (kept.length < HEIGHT) kept.unshift(Array.from({ length: WIDTH }, () => null));
|
|
82
|
+
for (let y = 0; y < HEIGHT; y++) board[y] = kept[y];
|
|
83
|
+
return cleared;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pick = (rng) => BAG[Math.floor(rng() * BAG.length) % BAG.length];
|
|
87
|
+
|
|
88
|
+
function spawn(state, key) {
|
|
89
|
+
const shape = SHAPES[key];
|
|
90
|
+
const piece = { key, shape, x: Math.floor((WIDTH - shape[0].length) / 2), y: 0 };
|
|
91
|
+
if (collides(state.board, piece.shape, piece.x, piece.y)) state.over = "stacked out";
|
|
92
|
+
state.piece = piece;
|
|
93
|
+
return state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const level = (state) => 1 + Math.floor(state.lines / 10);
|
|
97
|
+
|
|
98
|
+
/** Where the piece would land if you let go of it — drawn as the ghost. */
|
|
99
|
+
export function landing(state) {
|
|
100
|
+
let y = state.piece.y;
|
|
101
|
+
while (!collides(state.board, state.piece.shape, state.piece.x, y + 1)) y++;
|
|
102
|
+
return y;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lock(state) {
|
|
106
|
+
merge(state.board, state.piece);
|
|
107
|
+
const cleared = clearLines(state.board);
|
|
108
|
+
if (cleared) {
|
|
109
|
+
state.lines += cleared;
|
|
110
|
+
state.score += [0, 100, 300, 500, 800][cleared] * level(state);
|
|
111
|
+
}
|
|
112
|
+
const key = state.next;
|
|
113
|
+
state.next = pick(state.rng);
|
|
114
|
+
return spawn(state, key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const TETRIS = {
|
|
118
|
+
key: "tetris",
|
|
119
|
+
aliases: ["blocks", "bricks"],
|
|
120
|
+
title: "TETRIS",
|
|
121
|
+
blurb: "stack the bricks, clear the lines, outrun gravity",
|
|
122
|
+
keys: "← → move · ↑ rotate · ↓ drop one · space slam · q quit",
|
|
123
|
+
// Gravity is the level, and the level is the lines you have cleared.
|
|
124
|
+
tickMs: (state) => Math.max(90, 700 - (level(state) - 1) * 65),
|
|
125
|
+
|
|
126
|
+
create({ rng = Math.random } = {}) {
|
|
127
|
+
const state = { board: emptyBoard(), score: 0, lines: 0, over: null, rng, next: pick(rng) };
|
|
128
|
+
return spawn(state, pick(rng));
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
tick(state) {
|
|
132
|
+
if (collides(state.board, state.piece.shape, state.piece.x, state.piece.y + 1)) return lock(state);
|
|
133
|
+
state.piece.y++;
|
|
134
|
+
return state;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
onKey(state, key) {
|
|
138
|
+
const p = state.piece;
|
|
139
|
+
if (key === "left" && !collides(state.board, p.shape, p.x - 1, p.y)) p.x--;
|
|
140
|
+
else if (key === "right" && !collides(state.board, p.shape, p.x + 1, p.y)) p.x++;
|
|
141
|
+
else if (key === "down") return TETRIS.tick(state);
|
|
142
|
+
else if (key === "up" || key === "x") {
|
|
143
|
+
const turned = rotate(p.shape);
|
|
144
|
+
// Wall kicks, the simple kind: if the turn doesn't fit, shove it a column
|
|
145
|
+
// or two off the wall before giving up. Without this an I-piece can never
|
|
146
|
+
// stand up in the left gutter.
|
|
147
|
+
for (const dx of [0, -1, 1, -2, 2]) {
|
|
148
|
+
if (!collides(state.board, turned, p.x + dx, p.y)) {
|
|
149
|
+
p.shape = turned;
|
|
150
|
+
p.x += dx;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else if (key === "space") {
|
|
155
|
+
const drop = landing(state);
|
|
156
|
+
state.score += (drop - p.y) * 2;
|
|
157
|
+
p.y = drop;
|
|
158
|
+
return lock(state);
|
|
159
|
+
}
|
|
160
|
+
return state;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
status(state) {
|
|
164
|
+
return state.over
|
|
165
|
+
? `${state.over} · score ${state.score}`
|
|
166
|
+
: `score ${state.score} · lines ${state.lines}`;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
render(state) {
|
|
170
|
+
const view = state.board.map((row) => row.slice());
|
|
171
|
+
const p = state.piece;
|
|
172
|
+
if (!state.over) {
|
|
173
|
+
const gy = landing(state);
|
|
174
|
+
for (let y = 0; y < p.shape.length; y++) {
|
|
175
|
+
for (let x = 0; x < p.shape[y].length; x++) {
|
|
176
|
+
if (p.shape[y][x] === ".") continue;
|
|
177
|
+
if (gy + y >= 0 && gy + y < HEIGHT) view[gy + y][p.x + x] = "ghost";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (let y = 0; y < p.shape.length; y++) {
|
|
182
|
+
for (let x = 0; x < p.shape[y].length; x++) {
|
|
183
|
+
if (p.shape[y][x] === ".") continue;
|
|
184
|
+
if (p.y + y >= 0 && p.y + y < HEIGHT) view[p.y + y][p.x + x] = p.key;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const gutter = sidePanel(state);
|
|
188
|
+
return view.map((row, y) => {
|
|
189
|
+
const well = row.map((cell) => {
|
|
190
|
+
if (!cell) return EMPTY;
|
|
191
|
+
if (cell === "ghost") return GHOST;
|
|
192
|
+
return COLORS[cell](BLOCK);
|
|
193
|
+
}).join("");
|
|
194
|
+
return `${well} ${gutter[y] ?? ""}`;
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The strip down the right: what is coming, and what level you are on.
|
|
201
|
+
*
|
|
202
|
+
* It is also what makes the well look like a tetris cabinet rather than a
|
|
203
|
+
* column of bricks floating in a frame — the board sets the frame's width, so
|
|
204
|
+
* without it the box is wider than the game.
|
|
205
|
+
*/
|
|
206
|
+
function sidePanel(state) {
|
|
207
|
+
const colour = COLORS[state.next];
|
|
208
|
+
const shape = SHAPES[state.next];
|
|
209
|
+
const lines = [
|
|
210
|
+
ash("NEXT"),
|
|
211
|
+
...shape.map((row) => row.split("").map((c) => (c === "." ? " " : colour(BLOCK))).join("")),
|
|
212
|
+
"",
|
|
213
|
+
ash(`LVL ${level(state)}`),
|
|
214
|
+
];
|
|
215
|
+
// Padded to a fixed width so a narrow piece cannot make the frame breathe in
|
|
216
|
+
// and out as the bag turns over.
|
|
217
|
+
return lines.map((line) => {
|
|
218
|
+
const width = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
219
|
+
return line + " ".repeat(Math.max(0, 8 - width));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Tic-tac-toe against an opponent that cannot be beaten, only held.
|
|
2
|
+
//
|
|
3
|
+
// The AI is a full minimax — the search space is 9! at its very worst, which is
|
|
4
|
+
// nothing — so a draw is a win. It breaks ties at random, which is the only
|
|
5
|
+
// reason two games in a row are not the same game.
|
|
6
|
+
import { acid, ash, bone, danger, dim } from "./ui.mjs";
|
|
7
|
+
|
|
8
|
+
export const LINES = [
|
|
9
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
|
10
|
+
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
|
11
|
+
[0, 4, 8], [2, 4, 6],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const emptyBoard = () => Array.from({ length: 9 }, () => null);
|
|
15
|
+
|
|
16
|
+
/** "X", "O", "draw", or null while there is still a game on. */
|
|
17
|
+
export function winner(board) {
|
|
18
|
+
for (const [a, b, c] of LINES) {
|
|
19
|
+
if (board[a] && board[a] === board[b] && board[b] === board[c]) return board[a];
|
|
20
|
+
}
|
|
21
|
+
return board.every(Boolean) ? "draw" : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const other = (player) => (player === "X" ? "O" : "X");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimax with no pruning and no depth limit — 3×3 does not need either.
|
|
28
|
+
* Depth is in the score so it prefers winning sooner and losing later, which is
|
|
29
|
+
* what stops it from wandering into a fork it could have blocked.
|
|
30
|
+
*/
|
|
31
|
+
export function score(board, player, me, depth = 0) {
|
|
32
|
+
const done = winner(board);
|
|
33
|
+
if (done === me) return 10 - depth;
|
|
34
|
+
if (done === other(me)) return depth - 10;
|
|
35
|
+
if (done === "draw") return 0;
|
|
36
|
+
|
|
37
|
+
const scores = [];
|
|
38
|
+
for (let i = 0; i < 9; i++) {
|
|
39
|
+
if (board[i]) continue;
|
|
40
|
+
board[i] = player;
|
|
41
|
+
scores.push(score(board, other(player), me, depth + 1));
|
|
42
|
+
board[i] = null;
|
|
43
|
+
}
|
|
44
|
+
return player === me ? Math.max(...scores) : Math.min(...scores);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The best square for `player`, chosen at random among equally good ones. */
|
|
48
|
+
export function bestMove(board, player, rng = Math.random) {
|
|
49
|
+
let best = -Infinity;
|
|
50
|
+
let moves = [];
|
|
51
|
+
for (let i = 0; i < 9; i++) {
|
|
52
|
+
if (board[i]) continue;
|
|
53
|
+
board[i] = player;
|
|
54
|
+
const value = score(board, other(player), player, 1);
|
|
55
|
+
board[i] = null;
|
|
56
|
+
if (value > best) { best = value; moves = [i]; }
|
|
57
|
+
else if (value === best) moves.push(i);
|
|
58
|
+
}
|
|
59
|
+
if (!moves.length) return null;
|
|
60
|
+
return moves[Math.floor(rng() * moves.length) % moves.length];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function finish(state) {
|
|
64
|
+
const result = winner(state.board);
|
|
65
|
+
if (!result) return state;
|
|
66
|
+
state.over = result === "draw" ? "a draw — the only honest result"
|
|
67
|
+
: result === "X" ? "you win 🤘" : "the machine takes it";
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const TICTACTOE = {
|
|
72
|
+
key: "tictactoe",
|
|
73
|
+
// `tic-tac-toe` needs no alias — resolveGame drops dashes before it looks.
|
|
74
|
+
aliases: ["ttt", "tiktaktoe", "noughts", "xo"],
|
|
75
|
+
title: "TIC-TAC-TOE",
|
|
76
|
+
blurb: "three in a row against a perfect opponent",
|
|
77
|
+
keys: "← ↑ ↓ → move · enter mark · r new game · q quit",
|
|
78
|
+
|
|
79
|
+
create() {
|
|
80
|
+
return { board: emptyBoard(), cursor: 4, over: null, turn: "X" };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
onKey(state, pressed, { rng = Math.random } = {}) {
|
|
84
|
+
const x = state.cursor % 3;
|
|
85
|
+
const y = Math.floor(state.cursor / 3);
|
|
86
|
+
if (pressed === "left") state.cursor = y * 3 + (x + 2) % 3;
|
|
87
|
+
else if (pressed === "right") state.cursor = y * 3 + (x + 1) % 3;
|
|
88
|
+
else if (pressed === "up") state.cursor = ((y + 2) % 3) * 3 + x;
|
|
89
|
+
else if (pressed === "down") state.cursor = ((y + 1) % 3) * 3 + x;
|
|
90
|
+
else if (pressed === "enter" || pressed === "space") {
|
|
91
|
+
if (state.board[state.cursor]) return state;
|
|
92
|
+
state.board[state.cursor] = "X";
|
|
93
|
+
if (finish(state).over) return state;
|
|
94
|
+
const reply = bestMove(state.board, "O", rng);
|
|
95
|
+
if (reply != null) state.board[reply] = "O";
|
|
96
|
+
finish(state);
|
|
97
|
+
}
|
|
98
|
+
return state;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
status(state) {
|
|
102
|
+
return state.over ? state.over : `you ${acid("X")} ${ash("· machine")} ${bone("O")}`;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
render(state) {
|
|
106
|
+
const mark = (i) => {
|
|
107
|
+
const value = state.board[i];
|
|
108
|
+
const glyph = value === "X" ? acid("X") : value === "O" ? danger("O") : " ";
|
|
109
|
+
// The cursor is drawn as brackets rather than a highlight so it survives
|
|
110
|
+
// NO_COLOR, a pipe, and every terminal that lies about its capabilities.
|
|
111
|
+
return i === state.cursor && !state.over ? ` ${acid("[")}${glyph}${acid("]")} ` : ` ${glyph} `;
|
|
112
|
+
};
|
|
113
|
+
const line = (l, m, r) => ash(`${l}─────${m}─────${m}─────${r}`);
|
|
114
|
+
const rows = [];
|
|
115
|
+
rows.push(line("┌", "┬", "┐"));
|
|
116
|
+
for (let y = 0; y < 3; y++) {
|
|
117
|
+
rows.push(`${ash("│")}${mark(y * 3)}${ash("│")}${mark(y * 3 + 1)}${ash("│")}${mark(y * 3 + 2)}${ash("│")}`);
|
|
118
|
+
rows.push(y < 2 ? line("├", "┼", "┤") : line("└", "┴", "┘"));
|
|
119
|
+
}
|
|
120
|
+
rows.push("");
|
|
121
|
+
rows.push(dim(state.over ? "r for another" : "enter to mark the square"));
|
|
122
|
+
return rows;
|
|
123
|
+
},
|
|
124
|
+
};
|