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