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.
- package/README.md +62 -0
- package/bin/moshcode.mjs +6 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +28 -0
- package/src/games-asteroids.mjs +265 -0
- package/src/games-blackjack.mjs +284 -0
- package/src/games-breakout.mjs +186 -0
- package/src/games-centipede.mjs +241 -0
- package/src/games-chess.mjs +376 -0
- package/src/games-choplifter.mjs +179 -0
- package/src/games-digdug.mjs +267 -0
- package/src/games-excitebike.mjs +206 -0
- package/src/games-frogger.mjs +194 -0
- package/src/games-hangman.mjs +102 -0
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pacman.mjs +205 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -0
- package/src/games-snake.mjs +111 -0
- package/src/games-spyhunter.mjs +230 -0
- package/src/games-stagedive.mjs +206 -0
- package/src/games-tank.mjs +230 -0
- package/src/games-tetris.mjs +221 -0
- package/src/games-tictactoe.mjs +124 -0
- package/src/games.mjs +363 -0
- package/src/tui.mjs +13 -0
- package/src/ui.mjs +3 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Frogger. Five lanes of traffic that kill you if they touch you, then five of
|
|
2
|
+
// river that kill you if they don't.
|
|
3
|
+
//
|
|
4
|
+
// That inversion is the whole game and it is worth stating plainly in the code:
|
|
5
|
+
// on the road, being on something is death; on the river, being on nothing is.
|
|
6
|
+
// Everything else here — the lanes, the hops, the homes — is bookkeeping.
|
|
7
|
+
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
8
|
+
|
|
9
|
+
export const WIDTH = 40;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The board, bottom to top. Row 0 is the top (the homes), and the frog starts
|
|
13
|
+
* on the bank at the bottom.
|
|
14
|
+
*/
|
|
15
|
+
export const HOME_ROW = 0;
|
|
16
|
+
export const RIVER = [1, 2, 3, 4, 5];
|
|
17
|
+
export const MEDIAN = 6;
|
|
18
|
+
export const ROAD = [7, 8, 9, 10, 11];
|
|
19
|
+
export const BANK = 12;
|
|
20
|
+
export const HEIGHT = BANK + 1;
|
|
21
|
+
|
|
22
|
+
/** Five places to get to, evenly spaced along the top. */
|
|
23
|
+
export const HOMES = [3, 11, 19, 27, 35];
|
|
24
|
+
const HOME_W = 3;
|
|
25
|
+
|
|
26
|
+
const LIVES = 3;
|
|
27
|
+
const water = rgb(60, 130, 220);
|
|
28
|
+
const log = rgb(150, 100, 60);
|
|
29
|
+
|
|
30
|
+
/** Each lane: which way it runs, how fast, and how thick the things in it are. */
|
|
31
|
+
export const LANES = {
|
|
32
|
+
1: { dir: 1, speed: 0.16, len: 4, gap: 11, kind: "log" },
|
|
33
|
+
2: { dir: -1, speed: 0.22, len: 3, gap: 9, kind: "turtle" },
|
|
34
|
+
3: { dir: 1, speed: 0.13, len: 6, gap: 14, kind: "log" },
|
|
35
|
+
4: { dir: -1, speed: 0.28, len: 3, gap: 10, kind: "turtle" },
|
|
36
|
+
5: { dir: 1, speed: 0.2, len: 5, gap: 13, kind: "log" },
|
|
37
|
+
7: { dir: -1, speed: 0.26, len: 2, gap: 9, kind: "car" },
|
|
38
|
+
8: { dir: 1, speed: 0.19, len: 3, gap: 11, kind: "truck" },
|
|
39
|
+
9: { dir: -1, speed: 0.33, len: 2, gap: 12, kind: "car" },
|
|
40
|
+
10: { dir: 1, speed: 0.15, len: 4, gap: 13, kind: "truck" },
|
|
41
|
+
11: { dir: -1, speed: 0.24, len: 2, gap: 10, kind: "car" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ART = {
|
|
45
|
+
car: { paint: danger, art: "▄▄▄▄▄▄" },
|
|
46
|
+
truck: { paint: amber, art: "██████" },
|
|
47
|
+
log: { paint: log, art: "▓▓▓▓▓▓" },
|
|
48
|
+
turtle: { paint: acid, art: "◠◠◠◠◠◠" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every lane laid out end to end, evenly spaced. */
|
|
52
|
+
export function buildTraffic() {
|
|
53
|
+
const things = [];
|
|
54
|
+
for (const [row, lane] of Object.entries(LANES)) {
|
|
55
|
+
for (let x = 0; x < WIDTH + lane.gap; x += lane.gap) {
|
|
56
|
+
things.push({ row: Number(row), x, len: lane.len, kind: lane.kind });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return things;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The thing under a cell, if there is one. */
|
|
63
|
+
export const thingAt = (things, row, x) => things.find(
|
|
64
|
+
(t) => t.row === row && x >= Math.round(t.x) && x < Math.round(t.x) + t.len,
|
|
65
|
+
) ?? null;
|
|
66
|
+
|
|
67
|
+
/** Which home a frog at `x` has reached, or -1. */
|
|
68
|
+
export const homeAt = (x) => HOMES.findIndex((h) => x >= h && x < h + HOME_W);
|
|
69
|
+
|
|
70
|
+
const start = () => ({ x: Math.floor(WIDTH / 2), row: BANK });
|
|
71
|
+
|
|
72
|
+
function drown(state, why) {
|
|
73
|
+
state.lives--;
|
|
74
|
+
if (state.lives <= 0) {
|
|
75
|
+
state.lives = 0;
|
|
76
|
+
state.over = `${why} · ${state.score} points`;
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
state.frog = start();
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One tick. Exported so a test can get a frog home with no clock. */
|
|
84
|
+
export function step(state) {
|
|
85
|
+
for (const thing of state.traffic) {
|
|
86
|
+
const lane = LANES[thing.row];
|
|
87
|
+
thing.x += lane.dir * lane.speed;
|
|
88
|
+
if (thing.x > WIDTH + 2) thing.x = -thing.len - 2;
|
|
89
|
+
if (thing.x < -thing.len - 2) thing.x = WIDTH + 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const frog = state.frog;
|
|
93
|
+
if (RIVER.includes(frog.row)) {
|
|
94
|
+
// The river carries you. Riding a log off the edge of the world still
|
|
95
|
+
// counts as losing the frog, which is the lesson every player learns twice.
|
|
96
|
+
const ride = thingAt(state.traffic, frog.row, Math.round(frog.drift ?? frog.x));
|
|
97
|
+
if (!ride) return drown(state, "into the river");
|
|
98
|
+
frog.drift = (frog.drift ?? frog.x) + LANES[frog.row].dir * LANES[frog.row].speed;
|
|
99
|
+
frog.x = Math.round(frog.drift);
|
|
100
|
+
if (frog.x < 0 || frog.x >= WIDTH) return drown(state, "carried off the edge");
|
|
101
|
+
} else if (ROAD.includes(frog.row)) {
|
|
102
|
+
if (thingAt(state.traffic, frog.row, frog.x)) return drown(state, "flattened");
|
|
103
|
+
}
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Hop, and settle what the frog landed on. */
|
|
108
|
+
export function hop(state, dx, dy) {
|
|
109
|
+
const frog = state.frog;
|
|
110
|
+
const row = Math.min(BANK, Math.max(HOME_ROW, frog.row + dy));
|
|
111
|
+
const x = Math.min(WIDTH - 1, Math.max(0, frog.x + dx));
|
|
112
|
+
frog.row = row;
|
|
113
|
+
frog.x = x;
|
|
114
|
+
frog.drift = RIVER.includes(row) ? x : null;
|
|
115
|
+
|
|
116
|
+
if (row === HOME_ROW) {
|
|
117
|
+
const home = homeAt(x);
|
|
118
|
+
if (home < 0 || state.homes[home]) {
|
|
119
|
+
// The bank between the homes is not a home, and neither is one you have
|
|
120
|
+
// already filled.
|
|
121
|
+
return drown(state, "nowhere to land");
|
|
122
|
+
}
|
|
123
|
+
state.homes[home] = true;
|
|
124
|
+
state.score += 100;
|
|
125
|
+
if (state.homes.every(Boolean)) {
|
|
126
|
+
state.level++;
|
|
127
|
+
state.homes = HOMES.map(() => false);
|
|
128
|
+
state.score += 500;
|
|
129
|
+
}
|
|
130
|
+
state.frog = start();
|
|
131
|
+
return state;
|
|
132
|
+
}
|
|
133
|
+
if (dy < 0) state.score += 10; // forwards only, so hopping on the spot pays nothing
|
|
134
|
+
return step(state);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const FROGGER = {
|
|
138
|
+
key: "frogger",
|
|
139
|
+
aliases: ["frog", "hop"],
|
|
140
|
+
title: "FROGGER",
|
|
141
|
+
blurb: "the road kills what it touches, the river kills what it doesn't",
|
|
142
|
+
keys: "← ↑ ↓ → hop · q quit",
|
|
143
|
+
tickMs: 60,
|
|
144
|
+
|
|
145
|
+
create() {
|
|
146
|
+
return {
|
|
147
|
+
traffic: buildTraffic(),
|
|
148
|
+
frog: start(),
|
|
149
|
+
homes: HOMES.map(() => false),
|
|
150
|
+
score: 0,
|
|
151
|
+
lives: LIVES,
|
|
152
|
+
level: 1,
|
|
153
|
+
over: null,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
tick: step,
|
|
158
|
+
|
|
159
|
+
onKey(state, pressed) {
|
|
160
|
+
const moves = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
|
|
161
|
+
const move = moves[pressed];
|
|
162
|
+
if (!move) return state;
|
|
163
|
+
return hop(state, move[0], move[1]);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
status(state) {
|
|
167
|
+
return state.over
|
|
168
|
+
? state.over
|
|
169
|
+
: `${state.score} · level ${state.level} · ${state.homes.filter(Boolean).length}/5 home · ${"▲".repeat(state.lives)}`;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
render(state) {
|
|
173
|
+
const grid = Array.from({ length: HEIGHT }, (_, row) => Array.from({ length: WIDTH }, () => (
|
|
174
|
+
RIVER.includes(row) ? water("░") : ROAD.includes(row) ? dim("·") : null
|
|
175
|
+
)));
|
|
176
|
+
const put = (x, y, glyph) => {
|
|
177
|
+
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
|
|
178
|
+
grid[y][x] = glyph;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const [i, home] of HOMES.entries()) {
|
|
182
|
+
for (let j = 0; j < HOME_W; j++) put(home + j, HOME_ROW, state.homes[i] ? acid("▓") : ash("▒"));
|
|
183
|
+
}
|
|
184
|
+
for (const thing of state.traffic) {
|
|
185
|
+
const { paint, art } = ART[thing.kind];
|
|
186
|
+
for (let i = 0; i < thing.len; i++) put(Math.round(thing.x) + i, thing.row, paint(art[i % art.length]));
|
|
187
|
+
}
|
|
188
|
+
put(state.frog.x, state.frog.row, state.over ? danger("✷") : bone("◉"));
|
|
189
|
+
|
|
190
|
+
return grid.map((row, y) => row.map((cell) => (
|
|
191
|
+
cell ?? (y === MEDIAN || y === BANK ? ash("═") : " ")
|
|
192
|
+
)).join(""));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Hangman. Type a letter; the gallows does the rest.
|
|
2
|
+
import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
|
|
3
|
+
|
|
4
|
+
/** Six wrong guesses, and the gallows is finished. */
|
|
5
|
+
export const GALLOWS = [
|
|
6
|
+
[" ┌────┐", " │ ", " │ ", " │ ", " │ ", " ═╧══════"],
|
|
7
|
+
[" ┌────┐", " │ ○", " │ ", " │ ", " │ ", " ═╧══════"],
|
|
8
|
+
[" ┌────┐", " │ ○", " │ │", " │ ", " │ ", " ═╧══════"],
|
|
9
|
+
[" ┌────┐", " │ ○", " │ ╱│", " │ ", " │ ", " ═╧══════"],
|
|
10
|
+
[" ┌────┐", " │ ○", " │ ╱│╲", " │ ", " │ ", " ═╧══════"],
|
|
11
|
+
[" ┌────┐", " │ ○", " │ ╱│╲", " │ │", " │ ╱ ", " ═╧══════"],
|
|
12
|
+
[" ┌────┐", " │ ☹", " │ ╱│╲", " │ │", " │ ╱ ╲", " ═╧══════"],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const MISSES_ALLOWED = GALLOWS.length - 1;
|
|
16
|
+
|
|
17
|
+
/** Words a moshcoder might actually shout. Nothing needing a hyphen. */
|
|
18
|
+
export const WORDS = [
|
|
19
|
+
"moshcode", "distortion", "compiler", "kernel", "segfault", "refactor",
|
|
20
|
+
"closure", "promise", "daemon", "binary", "pointer", "monorepo", "terminal",
|
|
21
|
+
"runtime", "abstraction", "recursion", "interface", "payload", "protocol",
|
|
22
|
+
"semaphore", "mutex", "stacktrace", "breakpoint", "heuristic", "idempotent",
|
|
23
|
+
"bytecode", "checksum", "firewall", "namespace", "regression", "sandbox",
|
|
24
|
+
"throughput", "waveform", "amplifier", "feedback", "headbang", "overdrive",
|
|
25
|
+
"fretboard", "downbeat", "crowdsurf", "backline", "encryption", "quantum",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
29
|
+
|
|
30
|
+
/** The word with everything unguessed still hidden. */
|
|
31
|
+
export function mask(state) {
|
|
32
|
+
return state.word.split("").map((c) => (state.guessed.has(c) ? c.toUpperCase() : "_"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apply one letter. Repeats are free — guessing `e` twice costs nothing but
|
|
37
|
+
* also tells you nothing, which is the same deal every hangman has ever run.
|
|
38
|
+
*/
|
|
39
|
+
export function guess(state, letter) {
|
|
40
|
+
const c = String(letter).toLowerCase();
|
|
41
|
+
if (!LETTERS.includes(c) || state.guessed.has(c) || state.missed.includes(c)) return state;
|
|
42
|
+
if (state.word.includes(c)) {
|
|
43
|
+
state.guessed.add(c);
|
|
44
|
+
if (state.word.split("").every((ch) => state.guessed.has(ch))) state.over = "got it 🤘";
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
state.missed.push(c);
|
|
48
|
+
if (state.missed.length >= MISSES_ALLOWED) state.over = `hanged — it was ${state.word.toUpperCase()}`;
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const HANGMAN = {
|
|
53
|
+
key: "hangman",
|
|
54
|
+
aliases: ["hang", "gallows"],
|
|
55
|
+
title: "HANGMAN",
|
|
56
|
+
blurb: "six wrong letters and you are done for",
|
|
57
|
+
keys: "a–z guess · r new word once it is over · q quit",
|
|
58
|
+
// Every letter has to reach the game: `h` is a guess, not the vim left it is
|
|
59
|
+
// in the games that read arrows, and `r` only restarts once the word is done.
|
|
60
|
+
// Without both, `refactor` was a word you could not spell at it.
|
|
61
|
+
vim: false,
|
|
62
|
+
restartable: false,
|
|
63
|
+
|
|
64
|
+
create({ rng = Math.random } = {}) {
|
|
65
|
+
return {
|
|
66
|
+
word: WORDS[Math.floor(rng() * WORDS.length) % WORDS.length],
|
|
67
|
+
guessed: new Set(),
|
|
68
|
+
missed: [],
|
|
69
|
+
over: null,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
onKey(state, pressed) {
|
|
74
|
+
if (pressed.length !== 1) return state;
|
|
75
|
+
return guess(state, pressed);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
status(state) {
|
|
79
|
+
if (state.over) return state.over;
|
|
80
|
+
const left = MISSES_ALLOWED - state.missed.length;
|
|
81
|
+
return `${left} wrong ${left === 1 ? "guess" : "guesses"} left`;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
render(state) {
|
|
85
|
+
const art = GALLOWS[Math.min(state.missed.length, MISSES_ALLOWED)];
|
|
86
|
+
const shown = state.over && state.over.startsWith("hanged")
|
|
87
|
+
? state.word.split("").map((c) => c.toUpperCase())
|
|
88
|
+
: mask(state);
|
|
89
|
+
const word = shown.map((c) => (c === "_" ? ash("_") : acid(c))).join(" ");
|
|
90
|
+
const missed = state.missed.length
|
|
91
|
+
? `${ash("missed ")}${danger(state.missed.join(" ").toUpperCase())}`
|
|
92
|
+
: dim("no wrong letters yet");
|
|
93
|
+
return [
|
|
94
|
+
...art.map((line) => bone(line)),
|
|
95
|
+
"",
|
|
96
|
+
` ${word}`,
|
|
97
|
+
"",
|
|
98
|
+
` ${missed}`,
|
|
99
|
+
` ${amber("✳".repeat(MISSES_ALLOWED - state.missed.length))}${dim("✳".repeat(state.missed.length))}`,
|
|
100
|
+
];
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Space Invaders. Forty of them, coming down a row at a time, and the fewer are
|
|
2
|
+
// left the faster the rest move — which is the joke the original hardware told by
|
|
3
|
+
// accident and every version since has kept on purpose.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here moves on a fraction of a cell. The fleet steps a whole column at
|
|
6
|
+
// a time on a counter, shots move a whole row per tick, and every hit is one
|
|
7
|
+
// grid cell against another. A game whose whole tension is "will it get to the
|
|
8
|
+
// bottom before I do" should never lose a shot to a rounding error.
|
|
9
|
+
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
10
|
+
|
|
11
|
+
export const WIDTH = 44;
|
|
12
|
+
export const HEIGHT = 16;
|
|
13
|
+
|
|
14
|
+
export const ROWS = 5;
|
|
15
|
+
export const COLS = 8;
|
|
16
|
+
const PITCH_X = 4; // an alien is two cells wide, with two between them
|
|
17
|
+
// One row per rank. At two the fleet stood nine rows tall on a board with eleven
|
|
18
|
+
// above the bunkers, so it "landed" after two drops and no wave was survivable.
|
|
19
|
+
const PITCH_Y = 1;
|
|
20
|
+
|
|
21
|
+
export const CANNON_ROW = HEIGHT - 2;
|
|
22
|
+
const FLOOR_ROW = HEIGHT - 1;
|
|
23
|
+
export const BUNKER_ROW = HEIGHT - 4;
|
|
24
|
+
const LIVES = 3;
|
|
25
|
+
const BOMB_EVERY = 2; // bombs fall on every other tick, so they can be dodged
|
|
26
|
+
const SHOT_SPEED = 2; // rows per tick — a slow shot makes the whole game a queue
|
|
27
|
+
|
|
28
|
+
/** Top rows are worth more, and look meaner. */
|
|
29
|
+
export const KINDS = [
|
|
30
|
+
{ art: "▛▜", points: 30, paint: rgb(190, 130, 255) },
|
|
31
|
+
{ art: "▛▜", points: 20, paint: rgb(90, 200, 250) },
|
|
32
|
+
{ art: "▙▟", points: 20, paint: acid },
|
|
33
|
+
{ art: "▙▟", points: 10, paint: amber },
|
|
34
|
+
{ art: "▞▚", points: 10, paint: ash },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
38
|
+
|
|
39
|
+
/** Where an alien sits, given the fleet's corner. */
|
|
40
|
+
export const alienAt = (fleet, row, col) => ({
|
|
41
|
+
x: fleet.x + col * PITCH_X,
|
|
42
|
+
y: fleet.y + row * PITCH_Y,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const aliveCount = (fleet) => fleet.alive.reduce((n, row) => n + row.filter(Boolean).length, 0);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many ticks between steps. Forty aliens crawl; the last one sprints. This
|
|
49
|
+
* is the entire difficulty curve of the game and it costs one line.
|
|
50
|
+
*/
|
|
51
|
+
export const cadence = (fleet) => Math.max(2, Math.round(aliveCount(fleet) / 2.4));
|
|
52
|
+
|
|
53
|
+
const newFleet = (wave) => ({
|
|
54
|
+
x: 3,
|
|
55
|
+
y: 1 + Math.min(3, wave - 1), // each wave starts closer to the floor
|
|
56
|
+
dir: 1,
|
|
57
|
+
alive: Array.from({ length: ROWS }, () => Array.from({ length: COLS }, () => true)),
|
|
58
|
+
clock: 0,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Four bunkers, each cell able to take two hits before it is gone. */
|
|
62
|
+
export function buildBunkers() {
|
|
63
|
+
const bunkers = new Map();
|
|
64
|
+
for (let b = 0; b < 4; b++) {
|
|
65
|
+
const left = 5 + b * 10;
|
|
66
|
+
for (let i = 0; i < 5; i++) bunkers.set(left + i, 2);
|
|
67
|
+
}
|
|
68
|
+
return bunkers;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Chip a bunker cell, and say whether there was one there to chip. */
|
|
72
|
+
export function chip(bunkers, x, y) {
|
|
73
|
+
if (y !== BUNKER_ROW) return false;
|
|
74
|
+
const hp = bunkers.get(x);
|
|
75
|
+
if (!hp) return false;
|
|
76
|
+
if (hp <= 1) bunkers.delete(x); else bunkers.set(x, hp - 1);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The lowest live alien in each column — the only ones that can drop a bomb. */
|
|
81
|
+
export function frontLine(fleet) {
|
|
82
|
+
const front = [];
|
|
83
|
+
for (let col = 0; col < COLS; col++) {
|
|
84
|
+
for (let row = ROWS - 1; row >= 0; row--) {
|
|
85
|
+
if (fleet.alive[row][col]) { front.push({ row, col }); break; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return front;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Whether a shot at (x, y) hits an alien, and which one. */
|
|
92
|
+
export function alienHit(fleet, x, y) {
|
|
93
|
+
for (let row = 0; row < ROWS; row++) {
|
|
94
|
+
for (let col = 0; col < COLS; col++) {
|
|
95
|
+
if (!fleet.alive[row][col]) continue;
|
|
96
|
+
const at = alienAt(fleet, row, col);
|
|
97
|
+
if (y === at.y && x >= at.x && x <= at.x + 1) return { row, col };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function marchFleet(state) {
|
|
104
|
+
const fleet = state.fleet;
|
|
105
|
+
const cols = [];
|
|
106
|
+
for (let col = 0; col < COLS; col++) if (fleet.alive.some((row) => row[col])) cols.push(col);
|
|
107
|
+
if (!cols.length) return state;
|
|
108
|
+
const leftMost = fleet.x + cols[0] * PITCH_X;
|
|
109
|
+
const rightMost = fleet.x + cols[cols.length - 1] * PITCH_X + 1;
|
|
110
|
+
|
|
111
|
+
if ((fleet.dir > 0 && rightMost >= WIDTH - 1) || (fleet.dir < 0 && leftMost <= 0)) {
|
|
112
|
+
fleet.dir *= -1;
|
|
113
|
+
fleet.y += 1;
|
|
114
|
+
} else {
|
|
115
|
+
fleet.x += fleet.dir;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The fleet landing is the loss condition, and it beats having lives left.
|
|
119
|
+
for (let row = 0; row < ROWS; row++) {
|
|
120
|
+
for (let col = 0; col < COLS; col++) {
|
|
121
|
+
if (fleet.alive[row][col] && alienAt(fleet, row, col).y >= BUNKER_ROW) {
|
|
122
|
+
state.over = `the fleet landed · ${state.score} points`;
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return state;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One tick. Exported so a test can clear a wave with no clock. */
|
|
131
|
+
export function step(state) {
|
|
132
|
+
const { rng } = state;
|
|
133
|
+
|
|
134
|
+
// The shot climbs a row at a time even though it covers two, so it can never
|
|
135
|
+
// skip over the row an alien is standing on.
|
|
136
|
+
for (let i = 0; i < SHOT_SPEED && state.shot; i++) {
|
|
137
|
+
state.shot.y -= 1;
|
|
138
|
+
if (state.shot.y < 0) { state.shot = null; break; }
|
|
139
|
+
if (chip(state.bunkers, state.shot.x, state.shot.y)) { state.shot = null; break; }
|
|
140
|
+
const hit = alienHit(state.fleet, state.shot.x, state.shot.y);
|
|
141
|
+
if (!hit) continue;
|
|
142
|
+
state.fleet.alive[hit.row][hit.col] = false;
|
|
143
|
+
state.score += KINDS[hit.row].points;
|
|
144
|
+
state.shot = null;
|
|
145
|
+
if (!aliveCount(state.fleet)) {
|
|
146
|
+
state.wave++;
|
|
147
|
+
state.fleet = newFleet(state.wave);
|
|
148
|
+
state.bombs = [];
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
state.tick++;
|
|
154
|
+
if (state.tick % BOMB_EVERY === 0) {
|
|
155
|
+
for (const bomb of state.bombs) bomb.y += 1;
|
|
156
|
+
state.bombs = state.bombs.filter((bomb) => {
|
|
157
|
+
if (chip(state.bunkers, bomb.x, bomb.y)) return false;
|
|
158
|
+
if (bomb.y >= FLOOR_ROW) return false;
|
|
159
|
+
if (bomb.y === CANNON_ROW && Math.abs(bomb.x - state.cannon) <= 1) {
|
|
160
|
+
state.lives--;
|
|
161
|
+
if (state.lives <= 0) { state.lives = 0; state.over = `out of cannons · ${state.score} points`; }
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (state.over) return state;
|
|
168
|
+
|
|
169
|
+
// Somebody on the front line lets one go, more often the fewer are left.
|
|
170
|
+
const front = frontLine(state.fleet);
|
|
171
|
+
if (front.length && state.bombs.length < 3 && rng() < 0.02 + (COLS - front.length) * 0.004) {
|
|
172
|
+
const from = front[Math.floor(rng() * front.length) % front.length];
|
|
173
|
+
const at = alienAt(state.fleet, from.row, from.col);
|
|
174
|
+
state.bombs.push({ x: at.x, y: at.y + 1 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
state.fleet.clock++;
|
|
178
|
+
if (state.fleet.clock >= cadence(state.fleet)) {
|
|
179
|
+
state.fleet.clock = 0;
|
|
180
|
+
marchFleet(state);
|
|
181
|
+
}
|
|
182
|
+
return state;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const INVADERS = {
|
|
186
|
+
key: "invaders",
|
|
187
|
+
aliases: ["spaceinvaders", "space", "aliens"],
|
|
188
|
+
title: "SPACE INVADERS",
|
|
189
|
+
blurb: "forty of them, and the last one moves fastest",
|
|
190
|
+
keys: "← → move · space fire · q quit",
|
|
191
|
+
tickMs: 55,
|
|
192
|
+
|
|
193
|
+
create({ rng = Math.random } = {}) {
|
|
194
|
+
return {
|
|
195
|
+
fleet: newFleet(1),
|
|
196
|
+
bunkers: buildBunkers(),
|
|
197
|
+
cannon: Math.floor(WIDTH / 2),
|
|
198
|
+
shot: null,
|
|
199
|
+
bombs: [],
|
|
200
|
+
score: 0,
|
|
201
|
+
lives: LIVES,
|
|
202
|
+
wave: 1,
|
|
203
|
+
tick: 0,
|
|
204
|
+
over: null,
|
|
205
|
+
rng,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
tick: step,
|
|
210
|
+
|
|
211
|
+
onKey(state, key) {
|
|
212
|
+
if (key === "left") state.cannon = clamp(state.cannon - 1, 1, WIDTH - 2);
|
|
213
|
+
else if (key === "right") state.cannon = clamp(state.cannon + 1, 1, WIDTH - 2);
|
|
214
|
+
else if (key === "space" || key === "up" || key === "enter") {
|
|
215
|
+
// One shot in the air at a time. Everything about the pacing of this game
|
|
216
|
+
// comes from that single rule.
|
|
217
|
+
if (!state.shot) state.shot = { x: state.cannon, y: CANNON_ROW - 1 };
|
|
218
|
+
}
|
|
219
|
+
return state;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
status(state) {
|
|
223
|
+
return state.over
|
|
224
|
+
? state.over
|
|
225
|
+
: `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
render(state) {
|
|
229
|
+
const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
|
|
230
|
+
const put = (x, y, glyph) => {
|
|
231
|
+
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
|
|
232
|
+
grid[y][x] = glyph;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
for (let row = 0; row < ROWS; row++) {
|
|
236
|
+
for (let col = 0; col < COLS; col++) {
|
|
237
|
+
if (!state.fleet.alive[row][col]) continue;
|
|
238
|
+
const at = alienAt(state.fleet, row, col);
|
|
239
|
+
const kind = KINDS[row];
|
|
240
|
+
[...kind.art].forEach((c, i) => put(at.x + i, at.y, kind.paint(c)));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const [x, hp] of state.bunkers) put(x, BUNKER_ROW, hp > 1 ? acid("█") : dim("▓"));
|
|
245
|
+
for (const bomb of state.bombs) put(bomb.x, bomb.y, danger("╽"));
|
|
246
|
+
if (state.shot) put(state.shot.x, state.shot.y, bone("│"));
|
|
247
|
+
|
|
248
|
+
if (state.over) {
|
|
249
|
+
put(state.cannon, CANNON_ROW, danger("✷"));
|
|
250
|
+
} else {
|
|
251
|
+
[...("▟█▙")].forEach((c, i) => put(state.cannon - 1 + i, CANNON_ROW, acid(c)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return grid.map((row, y) => row.map((cell) => (
|
|
255
|
+
cell ?? (y === FLOOR_ROW ? ash("═") : " ")
|
|
256
|
+
)).join(""));
|
|
257
|
+
},
|
|
258
|
+
};
|