moshcode 0.41.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 +22 -2
- package/package.json +1 -1
- package/src/cli-schema.mjs +6 -2
- 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-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 +6 -1
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -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.mjs +35 -9
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Dig Dug. You are underground, everything down here wants you, and the only
|
|
2
|
+
// wall between you and it is the ground you have not dug yet.
|
|
3
|
+
//
|
|
4
|
+
// The board is the enemy AI. There is no pathfinding: a monster walks the tunnel
|
|
5
|
+
// it is in and turns towards you at a junction, so the shape you dig is the
|
|
6
|
+
// shape of the fight. Dig a straight line and they queue up behind you; dig a
|
|
7
|
+
// loop and they come round both ends of it.
|
|
8
|
+
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
9
|
+
|
|
10
|
+
export const WIDTH = 40;
|
|
11
|
+
export const HEIGHT = 16;
|
|
12
|
+
export const SKY = 1; // rows above this are open air
|
|
13
|
+
|
|
14
|
+
const LIVES = 3;
|
|
15
|
+
const HARPOON = 5; // how far the pump reaches
|
|
16
|
+
const POPS_AT = 3; // pumps to burst a monster
|
|
17
|
+
const MONSTER_EVERY = 9; // ticks between monster steps
|
|
18
|
+
const ROCK_EVERY = 3;
|
|
19
|
+
|
|
20
|
+
const soil = rgb(150, 100, 60);
|
|
21
|
+
const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
|
22
|
+
|
|
23
|
+
const key = (x, y) => `${x},${y}`;
|
|
24
|
+
|
|
25
|
+
/** Solid ground everywhere below the sky, minus the shafts the level starts with. */
|
|
26
|
+
export function buildGround() {
|
|
27
|
+
const ground = new Set();
|
|
28
|
+
for (let y = SKY + 1; y < HEIGHT; y++) for (let x = 0; x < WIDTH; x++) ground.add(key(x, y));
|
|
29
|
+
return ground;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isDug = (ground, x, y) => (
|
|
33
|
+
x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT && !ground.has(key(x, y))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/** Where the monsters and rocks start — spread out, and never on top of you. */
|
|
37
|
+
export function populate(state, level) {
|
|
38
|
+
state.monsters = [];
|
|
39
|
+
for (let i = 0; i < 3 + Math.min(3, level - 1); i++) {
|
|
40
|
+
state.monsters.push({
|
|
41
|
+
x: 6 + Math.floor(state.rng() * (WIDTH - 12)),
|
|
42
|
+
y: SKY + 2 + Math.floor(state.rng() * (HEIGHT - SKY - 3)),
|
|
43
|
+
dir: state.rng() < 0.5 ? "left" : "right",
|
|
44
|
+
pumped: 0,
|
|
45
|
+
ghost: 0,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
state.rocks = [];
|
|
49
|
+
for (let i = 0; i < 4; i++) {
|
|
50
|
+
state.rocks.push({
|
|
51
|
+
x: 4 + Math.floor(state.rng() * (WIDTH - 8)),
|
|
52
|
+
y: SKY + 2 + Math.floor(state.rng() * 5),
|
|
53
|
+
falling: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Every monster and rock starts buried, so the board opens up only where you
|
|
57
|
+
// dig it.
|
|
58
|
+
for (const thing of [...state.monsters, ...state.rocks]) state.ground.delete(key(thing.x, thing.y));
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lose(state, why) {
|
|
63
|
+
state.lives--;
|
|
64
|
+
if (state.lives <= 0) {
|
|
65
|
+
state.lives = 0;
|
|
66
|
+
state.over = `${why} · ${state.score} points`;
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
|
|
70
|
+
state.harpoon = null;
|
|
71
|
+
state.ground.delete(key(state.player.x, state.player.y));
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A rock with nothing under it falls, and takes anything under it with it. */
|
|
76
|
+
export function fallRocks(state) {
|
|
77
|
+
for (const rock of state.rocks) {
|
|
78
|
+
const below = { x: rock.x, y: rock.y + 1 };
|
|
79
|
+
if (!rock.falling && !isDug(state.ground, below.x, below.y)) continue;
|
|
80
|
+
if (below.y >= HEIGHT) { rock.falling = false; continue; }
|
|
81
|
+
rock.falling = true;
|
|
82
|
+
rock.y += 1;
|
|
83
|
+
state.ground.delete(key(rock.x, rock.y));
|
|
84
|
+
const squashed = state.monsters.filter((m) => m.x === rock.x && m.y === rock.y);
|
|
85
|
+
if (squashed.length) {
|
|
86
|
+
state.monsters = state.monsters.filter((m) => !squashed.includes(m));
|
|
87
|
+
state.score += squashed.length * 200;
|
|
88
|
+
}
|
|
89
|
+
if (state.player.x === rock.x && state.player.y === rock.y) return lose(state, "under a rock");
|
|
90
|
+
}
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One monster step: down its own tunnel, turning towards you at a junction. */
|
|
95
|
+
export function walkMonster(state, monster) {
|
|
96
|
+
if (monster.pumped) return monster; // a hooked monster is going nowhere
|
|
97
|
+
const player = state.player;
|
|
98
|
+
|
|
99
|
+
// Once in a while one gives up on the tunnels and comes straight through the
|
|
100
|
+
// ground at you. Without it, a player who digs one deep hole is untouchable.
|
|
101
|
+
if (monster.ghost > 0) {
|
|
102
|
+
monster.ghost--;
|
|
103
|
+
monster.x += Math.sign(player.x - monster.x);
|
|
104
|
+
if (monster.x === player.x) monster.y += Math.sign(player.y - monster.y);
|
|
105
|
+
return monster;
|
|
106
|
+
}
|
|
107
|
+
if (state.rng() < 0.02) { monster.ghost = 6; return monster; }
|
|
108
|
+
|
|
109
|
+
const options = Object.entries(DIRS)
|
|
110
|
+
.filter(([, [dx, dy]]) => isDug(state.ground, monster.x + dx, monster.y + dy))
|
|
111
|
+
.sort(([, a], [, b]) => {
|
|
112
|
+
const da = Math.abs(monster.x + a[0] - player.x) + Math.abs(monster.y + a[1] - player.y);
|
|
113
|
+
const db = Math.abs(monster.x + b[0] - player.x) + Math.abs(monster.y + b[1] - player.y);
|
|
114
|
+
return da - db;
|
|
115
|
+
});
|
|
116
|
+
if (!options.length) return monster;
|
|
117
|
+
// It prefers the way it is already going when that is no worse, so it does not
|
|
118
|
+
// jitter on the spot at a crossroads.
|
|
119
|
+
const [name, [dx, dy]] = options[0];
|
|
120
|
+
monster.dir = name;
|
|
121
|
+
monster.x += dx;
|
|
122
|
+
monster.y += dy;
|
|
123
|
+
return monster;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One tick. Exported so a test can clear a level with no clock. */
|
|
127
|
+
export function step(state) {
|
|
128
|
+
state.clock++;
|
|
129
|
+
|
|
130
|
+
if (state.clock % ROCK_EVERY === 0) {
|
|
131
|
+
fallRocks(state);
|
|
132
|
+
if (state.over) return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (state.harpoon) {
|
|
136
|
+
// The harpoon holds whatever it caught; it is the pump that does the work.
|
|
137
|
+
const hooked = state.monsters.find((m) => m === state.harpoon.on);
|
|
138
|
+
if (!hooked) state.harpoon = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (state.clock % MONSTER_EVERY === 0) {
|
|
142
|
+
for (const monster of state.monsters) walkMonster(state, monster);
|
|
143
|
+
const caught = state.monsters.find((m) => m.x === state.player.x && m.y === state.player.y);
|
|
144
|
+
if (caught) return lose(state, "caught underground");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!state.monsters.length) {
|
|
148
|
+
state.level++;
|
|
149
|
+
state.ground = buildGround();
|
|
150
|
+
state.player = { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" };
|
|
151
|
+
state.ground.delete(key(state.player.x, state.player.y));
|
|
152
|
+
state.score += 500;
|
|
153
|
+
populate(state, state.level);
|
|
154
|
+
}
|
|
155
|
+
return state;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Fire the harpoon down the tunnel you are facing, or pump what is on it. */
|
|
159
|
+
export function pump(state) {
|
|
160
|
+
if (state.harpoon) {
|
|
161
|
+
const monster = state.harpoon.on;
|
|
162
|
+
monster.pumped++;
|
|
163
|
+
if (monster.pumped >= POPS_AT) {
|
|
164
|
+
state.monsters = state.monsters.filter((m) => m !== monster);
|
|
165
|
+
state.score += 100 * POPS_AT;
|
|
166
|
+
state.harpoon = null;
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
const [dx, dy] = DIRS[state.player.dir];
|
|
171
|
+
for (let i = 1; i <= HARPOON; i++) {
|
|
172
|
+
const x = state.player.x + dx * i;
|
|
173
|
+
const y = state.player.y + dy * i;
|
|
174
|
+
if (!isDug(state.ground, x, y)) break; // it does not go through dirt
|
|
175
|
+
const monster = state.monsters.find((m) => m.x === x && m.y === y);
|
|
176
|
+
if (monster) {
|
|
177
|
+
state.harpoon = { on: monster, x, y };
|
|
178
|
+
monster.pumped = 1;
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return state;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const DIGDUG = {
|
|
186
|
+
key: "digdug",
|
|
187
|
+
aliases: ["dig", "pooka"],
|
|
188
|
+
title: "DIG DUG",
|
|
189
|
+
blurb: "dig the tunnels, pump the monsters, drop rocks on the rest",
|
|
190
|
+
keys: "← ↑ ↓ → dig · space pump · q quit",
|
|
191
|
+
tickMs: 60,
|
|
192
|
+
|
|
193
|
+
create({ rng = Math.random } = {}) {
|
|
194
|
+
const state = {
|
|
195
|
+
ground: buildGround(),
|
|
196
|
+
player: { x: Math.floor(WIDTH / 2), y: SKY + 1, dir: "down" },
|
|
197
|
+
monsters: [],
|
|
198
|
+
rocks: [],
|
|
199
|
+
harpoon: null,
|
|
200
|
+
score: 0,
|
|
201
|
+
lives: LIVES,
|
|
202
|
+
level: 1,
|
|
203
|
+
clock: 0,
|
|
204
|
+
over: null,
|
|
205
|
+
rng,
|
|
206
|
+
};
|
|
207
|
+
state.ground.delete(key(state.player.x, state.player.y));
|
|
208
|
+
return populate(state, 1);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
tick: step,
|
|
212
|
+
|
|
213
|
+
onKey(state, pressed) {
|
|
214
|
+
if (pressed === "space" || pressed === "enter") return pump(state);
|
|
215
|
+
const move = DIRS[pressed];
|
|
216
|
+
if (!move) return state;
|
|
217
|
+
// Moving is digging: the tunnel is wherever you have been.
|
|
218
|
+
state.harpoon = null;
|
|
219
|
+
state.player.dir = pressed;
|
|
220
|
+
const x = state.player.x + move[0];
|
|
221
|
+
const y = state.player.y + move[1];
|
|
222
|
+
if (x < 0 || x >= WIDTH || y <= SKY || y >= HEIGHT) return state;
|
|
223
|
+
if (state.rocks.some((r) => r.x === x && r.y === y && !r.falling)) return state;
|
|
224
|
+
if (state.ground.delete(key(x, y))) state.score += 1;
|
|
225
|
+
state.player.x = x;
|
|
226
|
+
state.player.y = y;
|
|
227
|
+
return state;
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
status(state) {
|
|
231
|
+
return state.over
|
|
232
|
+
? state.over
|
|
233
|
+
: `${state.score} · level ${state.level} · ${state.monsters.length} left · ${"▲".repeat(state.lives)}`;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
render(state) {
|
|
237
|
+
const grid = Array.from({ length: HEIGHT }, (_, y) => Array.from({ length: WIDTH }, (_, x) => (
|
|
238
|
+
state.ground.has(key(x, y)) ? soil("▒") : null
|
|
239
|
+
)));
|
|
240
|
+
const put = (x, y, glyph) => {
|
|
241
|
+
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
|
|
242
|
+
grid[y][x] = glyph;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const rock of state.rocks) put(rock.x, rock.y, ash("▣"));
|
|
246
|
+
for (const monster of state.monsters) {
|
|
247
|
+
// A monster part-way through being pumped is visibly bigger, which is the
|
|
248
|
+
// only feedback the pump gives you.
|
|
249
|
+
const art = monster.pumped >= 2 ? "◯" : monster.pumped ? "◎" : "◉";
|
|
250
|
+
put(monster.x, monster.y, (monster.ghost ? amber : danger)(art));
|
|
251
|
+
}
|
|
252
|
+
if (state.harpoon) {
|
|
253
|
+
const [dx, dy] = DIRS[state.player.dir];
|
|
254
|
+
for (let i = 1; i < HARPOON; i++) {
|
|
255
|
+
const x = state.player.x + dx * i;
|
|
256
|
+
const y = state.player.y + dy * i;
|
|
257
|
+
if (x === state.harpoon.x && y === state.harpoon.y) break;
|
|
258
|
+
put(x, y, bone(dx ? "─" : "│"));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
put(state.player.x, state.player.y, state.over ? danger("✷") : acid("◈"));
|
|
262
|
+
|
|
263
|
+
return grid.map((row, y) => row.map((cell) => (
|
|
264
|
+
cell ?? (y <= SKY ? dim("·") : " ")
|
|
265
|
+
)).join(""));
|
|
266
|
+
},
|
|
267
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Excitebike. The throttle is not the interesting part — the temperature gauge
|
|
2
|
+
// is, and so is what your front wheel is doing when you land.
|
|
3
|
+
//
|
|
4
|
+
// Two things make this game rather than a side-scroller with ramps. Turbo is
|
|
5
|
+
// free until it isn't: heat climbs while you hold it and the engine seizes at
|
|
6
|
+
// the top of the gauge, so the fast lap is the one that cools off in the right
|
|
7
|
+
// places. And a jump is only as good as its landing: you pitch the bike in the
|
|
8
|
+
// air, and coming down nose-first puts you over the handlebars.
|
|
9
|
+
import { acid, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
10
|
+
|
|
11
|
+
export const WIDTH = 46;
|
|
12
|
+
export const HEIGHT = 12;
|
|
13
|
+
|
|
14
|
+
export const GROUND = HEIGHT - 3;
|
|
15
|
+
export const RIDER = 8; // your column; the track comes to you
|
|
16
|
+
|
|
17
|
+
const BASE_SPEED = 0.35;
|
|
18
|
+
const MAX_SPEED = 1.5;
|
|
19
|
+
const TURBO = 0.55; // extra columns per tick while it is held
|
|
20
|
+
const HEAT_UP = 1.6;
|
|
21
|
+
const HEAT_DOWN = 0.8;
|
|
22
|
+
export const SEIZE_TICKS = 70; // how long a cooked engine costs you
|
|
23
|
+
const PITCH_LIMIT = 2.2;
|
|
24
|
+
// The nose drops on its own all the way down. Without this the pitch you leave
|
|
25
|
+
// the ramp with is the pitch you land on, and the landing — the whole second
|
|
26
|
+
// half of this game — is something you can simply ignore.
|
|
27
|
+
const PITCH_DROP = 0.075;
|
|
28
|
+
export const LAND_OK = 1.1; // how far from level you may land
|
|
29
|
+
const CRASH_TICKS = 45;
|
|
30
|
+
export const FINISH = 900; // columns of track in a race
|
|
31
|
+
const TIME = 2600;
|
|
32
|
+
|
|
33
|
+
const dirt = rgb(170, 130, 90);
|
|
34
|
+
|
|
35
|
+
/** A ramp is three columns of take-off; hit one moving and you are airborne. */
|
|
36
|
+
export const RAMP_W = 3;
|
|
37
|
+
|
|
38
|
+
export function spawn(state) {
|
|
39
|
+
const { rng } = state;
|
|
40
|
+
state.things.push({ kind: "ramp", x: WIDTH + 2 });
|
|
41
|
+
state.next = 18 + rng() * 22;
|
|
42
|
+
return state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const rampSpan = (thing) => {
|
|
46
|
+
const x = Math.round(thing.x);
|
|
47
|
+
return [x, x + RAMP_W - 1];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Speed right now, with everything that is holding it back applied. */
|
|
51
|
+
export function speedOf(state) {
|
|
52
|
+
if (state.crash > 0) return 0;
|
|
53
|
+
if (state.seized > 0) return BASE_SPEED * 0.4;
|
|
54
|
+
return Math.min(MAX_SPEED, state.throttle + (state.turbo ? TURBO : 0));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function crash(state, why) {
|
|
58
|
+
state.crash = CRASH_TICKS;
|
|
59
|
+
state.air = 0;
|
|
60
|
+
state.pitch = 0;
|
|
61
|
+
state.throttle = BASE_SPEED;
|
|
62
|
+
state.spills++;
|
|
63
|
+
state.last = why;
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One tick of track. Exported so a test can ride a whole race with no clock. */
|
|
68
|
+
export function step(state) {
|
|
69
|
+
state.clock++;
|
|
70
|
+
if (state.clock >= TIME) {
|
|
71
|
+
state.over = `out of time · ${Math.round(state.dist)} of ${FINISH}`;
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
if (state.crash > 0) { state.crash--; return state; }
|
|
75
|
+
|
|
76
|
+
// The gauge. Turbo is a loan, and this is where it is called in.
|
|
77
|
+
if (state.turbo && !state.seized) state.heat = Math.min(100, state.heat + HEAT_UP);
|
|
78
|
+
else state.heat = Math.max(0, state.heat - HEAT_DOWN);
|
|
79
|
+
if (state.heat >= 100 && !state.seized) { state.seized = SEIZE_TICKS; state.turbo = false; }
|
|
80
|
+
if (state.seized > 0) { state.seized--; if (!state.seized) state.heat = 40; }
|
|
81
|
+
|
|
82
|
+
const speed = speedOf(state);
|
|
83
|
+
state.dist += speed;
|
|
84
|
+
for (const thing of state.things) thing.x -= speed;
|
|
85
|
+
state.things = state.things.filter((t) => rampSpan(t)[1] > -3);
|
|
86
|
+
state.next -= speed;
|
|
87
|
+
if (state.next <= 0) spawn(state);
|
|
88
|
+
|
|
89
|
+
if (state.air > 0) {
|
|
90
|
+
state.air--;
|
|
91
|
+
state.pitch = Math.min(PITCH_LIMIT, state.pitch + PITCH_DROP);
|
|
92
|
+
if (!state.air) {
|
|
93
|
+
// Landing: level enough is a landing, anything else is a tumble.
|
|
94
|
+
if (Math.abs(state.pitch) > LAND_OK) return crash(state, "over the handlebars");
|
|
95
|
+
// A flat landing carries the speed; a wobbly one scrubs some off.
|
|
96
|
+
state.throttle = Math.min(MAX_SPEED, state.throttle + (Math.abs(state.pitch) < 0.4 ? 0.12 : -0.1));
|
|
97
|
+
state.score += 50;
|
|
98
|
+
state.pitch = 0;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
const ramp = state.things.find((t) => {
|
|
102
|
+
const [from, to] = rampSpan(t);
|
|
103
|
+
return RIDER >= from && RIDER <= to;
|
|
104
|
+
});
|
|
105
|
+
if (ramp && !ramp.used) {
|
|
106
|
+
ramp.used = true;
|
|
107
|
+
// The faster you hit it, the longer you are in the air — and the more time
|
|
108
|
+
// you have to get the pitch wrong.
|
|
109
|
+
state.air = Math.round(10 + speed * 14);
|
|
110
|
+
state.pitch = 0.6; // it launches you nose-up, and you ride it down
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (state.dist >= FINISH) {
|
|
115
|
+
state.race++;
|
|
116
|
+
state.score += Math.max(200, 2000 - state.clock);
|
|
117
|
+
state.dist = 0;
|
|
118
|
+
state.clock = 0;
|
|
119
|
+
state.things = [];
|
|
120
|
+
state.heat = 0;
|
|
121
|
+
}
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const EXCITEBIKE = {
|
|
126
|
+
key: "excitebike",
|
|
127
|
+
aliases: ["bike", "moto", "excite"],
|
|
128
|
+
title: "EXCITEBIKE",
|
|
129
|
+
blurb: "turbo until it cooks, and land the way you took off",
|
|
130
|
+
keys: "← → throttle · space turbo · ↑ ↓ pitch in the air · q quit",
|
|
131
|
+
tickMs: 55,
|
|
132
|
+
|
|
133
|
+
create({ rng = Math.random } = {}) {
|
|
134
|
+
return {
|
|
135
|
+
things: [],
|
|
136
|
+
next: 16,
|
|
137
|
+
throttle: BASE_SPEED,
|
|
138
|
+
turbo: false,
|
|
139
|
+
heat: 0,
|
|
140
|
+
seized: 0,
|
|
141
|
+
air: 0,
|
|
142
|
+
pitch: 0,
|
|
143
|
+
crash: 0,
|
|
144
|
+
spills: 0,
|
|
145
|
+
dist: 0,
|
|
146
|
+
race: 1,
|
|
147
|
+
clock: 0,
|
|
148
|
+
score: 0,
|
|
149
|
+
last: null,
|
|
150
|
+
over: null,
|
|
151
|
+
rng,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
tick: step,
|
|
156
|
+
|
|
157
|
+
onKey(state, pressed) {
|
|
158
|
+
if (state.crash > 0) return state;
|
|
159
|
+
if (pressed === "right") state.throttle = Math.min(MAX_SPEED, state.throttle + 0.12);
|
|
160
|
+
else if (pressed === "left") state.throttle = Math.max(BASE_SPEED * 0.5, state.throttle - 0.12);
|
|
161
|
+
else if (pressed === "space" || pressed === "enter") state.turbo = !state.turbo;
|
|
162
|
+
else if (pressed === "up" || pressed === "down") {
|
|
163
|
+
// Pitch only means anything off the ground; on it, this does nothing at
|
|
164
|
+
// all, which is the honest answer.
|
|
165
|
+
if (!state.air) return state;
|
|
166
|
+
state.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, state.pitch + (pressed === "up" ? -0.35 : 0.35)));
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
status(state) {
|
|
172
|
+
if (state.over) return state.over;
|
|
173
|
+
const gauge = Math.round(state.heat / 10);
|
|
174
|
+
const bar = `${"█".repeat(gauge)}${"░".repeat(10 - gauge)}`;
|
|
175
|
+
return `race ${state.race} · ${Math.round(state.dist)}/${FINISH} · heat ${bar}${state.seized ? " seized" : ""}`;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
render(state) {
|
|
179
|
+
const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
|
|
180
|
+
const put = (x, y, glyph) => {
|
|
181
|
+
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
|
|
182
|
+
grid[y][x] = glyph;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
for (const thing of state.things) {
|
|
186
|
+
const [from] = rampSpan(thing);
|
|
187
|
+
// A ramp climbs, so it is drawn climbing.
|
|
188
|
+
[...("▁▄█")].forEach((c, i) => put(from + i, GROUND - (i > 1 ? 1 : 0), dirt(c)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const height = state.air ? Math.min(4, 1 + Math.round(state.air / 6)) : 0;
|
|
192
|
+
const nose = state.pitch < -LAND_OK ? "◜" : state.pitch > LAND_OK ? "◞" : state.air ? "◠" : "◉";
|
|
193
|
+
put(RIDER, GROUND - 1 - height, state.crash ? danger("✷") : acid(nose));
|
|
194
|
+
if (!state.air && !state.crash) put(RIDER + 1, GROUND - 1, ash("·"));
|
|
195
|
+
|
|
196
|
+
return grid.map((row, y) => row.map((cell, x) => {
|
|
197
|
+
if (cell) return cell;
|
|
198
|
+
if (y === GROUND) return dirt("▀");
|
|
199
|
+
if (y > GROUND) return dim("░");
|
|
200
|
+
// The finish, coming up the track.
|
|
201
|
+
const toGo = FINISH - state.dist;
|
|
202
|
+
if (toGo < WIDTH - RIDER && x === RIDER + Math.round(toGo)) return bone("┋");
|
|
203
|
+
return " ";
|
|
204
|
+
}).join(""));
|
|
205
|
+
},
|
|
206
|
+
};
|
|
@@ -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
|
+
};
|
package/src/games-hangman.mjs
CHANGED
|
@@ -54,7 +54,12 @@ export const HANGMAN = {
|
|
|
54
54
|
aliases: ["hang", "gallows"],
|
|
55
55
|
title: "HANGMAN",
|
|
56
56
|
blurb: "six wrong letters and you are done for",
|
|
57
|
-
keys: "a–z guess · r new word · q quit",
|
|
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,
|
|
58
63
|
|
|
59
64
|
create({ rng = Math.random } = {}) {
|
|
60
65
|
return {
|