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.
@@ -0,0 +1,179 @@
1
+ // Choplifter. Fly out, land, load them up, fly home. Do it before the tanks
2
+ // work out where you are going.
3
+ //
4
+ // The world is wider than the screen, which is the whole point — the camera
5
+ // follows the chopper and the base sits off the left edge, so "get back" is a
6
+ // real journey rather than a step. Everything is stored in world columns and
7
+ // only turned into screen columns at the very end, in render().
8
+ import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
9
+
10
+ export const WIDTH = 46; // what you can see
11
+ export const HEIGHT = 14;
12
+ export const WORLD = 150; // how far it actually goes
13
+
14
+ export const GROUND = HEIGHT - 2;
15
+ export const BASE = 6; // the pad, at the left-hand end of the world
16
+ const BASE_W = 7;
17
+ export const SEATS = 4; // how many fit in the back
18
+ const LIVES = 3;
19
+ const SHELL_EVERY = 46; // ticks between a tank taking a shot
20
+
21
+ const sand = rgb(200, 170, 110);
22
+
23
+ export const onPad = (x) => x >= BASE - 1 && x <= BASE + BASE_W;
24
+
25
+ /** Hostages waiting in the desert, and the tanks that would rather they stayed. */
26
+ export function populate(rng) {
27
+ const people = [];
28
+ for (let i = 0; i < 12; i++) {
29
+ people.push({ x: 40 + Math.floor(rng() * (WORLD - 55)), waving: true });
30
+ }
31
+ const tanks = [];
32
+ for (let i = 0; i < 4; i++) {
33
+ tanks.push({ x: 55 + Math.floor(rng() * (WORLD - 70)), dir: rng() < 0.5 ? -1 : 1 });
34
+ }
35
+ return { people, tanks };
36
+ }
37
+
38
+ function hit(state, why) {
39
+ state.lives--;
40
+ // Anybody in the back goes down with it. That is the cost of one more pickup.
41
+ state.aboard = 0;
42
+ if (state.lives <= 0) {
43
+ state.lives = 0;
44
+ state.over = `${why} · ${state.home} home`;
45
+ return state;
46
+ }
47
+ state.chopper = { x: BASE + 2, y: GROUND - 1, vy: 0 };
48
+ state.shells = [];
49
+ return state;
50
+ }
51
+
52
+ /** One tick. Exported so a test can fly a whole rescue with no clock. */
53
+ export function step(state) {
54
+ const chop = state.chopper;
55
+ chop.x = Math.max(0, Math.min(WORLD - 1, chop.x + state.throttle));
56
+ chop.y = Math.max(1, Math.min(GROUND, chop.y + chop.vy));
57
+ // Both axes drift to a stop rather than stopping dead, so it hovers when you
58
+ // let go instead of dropping out of the sky the moment you stop pressing up.
59
+ state.throttle *= 0.72;
60
+ if (Math.abs(state.throttle) < 0.05) state.throttle = 0;
61
+ chop.vy *= 0.72;
62
+ if (Math.abs(chop.vy) < 0.05) chop.vy = 0;
63
+
64
+ const landed = chop.y >= GROUND;
65
+
66
+ if (landed) {
67
+ // On the ground at the base: everybody out, and that is the score.
68
+ if (onPad(Math.round(chop.x))) {
69
+ state.home += state.aboard;
70
+ state.score += state.aboard * 100;
71
+ state.aboard = 0;
72
+ } else {
73
+ // Out in the desert: anybody close enough climbs in.
74
+ for (const person of state.people) {
75
+ if (state.aboard >= SEATS) break;
76
+ if (Math.abs(person.x - chop.x) > 2 || person.rescued) continue;
77
+ person.rescued = true;
78
+ state.aboard++;
79
+ }
80
+ state.people = state.people.filter((p) => !p.rescued);
81
+ }
82
+ }
83
+
84
+ state.clock++;
85
+ for (const tank of state.tanks) {
86
+ if (state.clock % 5 === 0) {
87
+ tank.x += tank.dir;
88
+ if (tank.x < 30 || tank.x > WORLD - 4) tank.dir *= -1;
89
+ }
90
+ // A tank shoots when you are overhead, and only then — you can outrun them.
91
+ if (state.clock % SHELL_EVERY === 0 && Math.abs(tank.x - chop.x) < 12) {
92
+ state.shells.push({ x: tank.x, y: GROUND - 1, vy: -0.55 });
93
+ }
94
+ }
95
+
96
+ for (const shell of state.shells) shell.y += shell.vy;
97
+ state.shells = state.shells.filter((s) => s.y > 0);
98
+ const struck = state.shells.find((s) => Math.abs(s.x - chop.x) < 2 && Math.abs(s.y - chop.y) < 1);
99
+ if (struck) return hit(state, "shot down");
100
+
101
+ const run_over = state.tanks.find((t) => landed && Math.abs(t.x - chop.x) < 2);
102
+ if (run_over) return hit(state, "flattened on the ground");
103
+
104
+ if (!state.people.length && !state.aboard) {
105
+ state.over = `everyone out · ${state.home} home`;
106
+ }
107
+ return state;
108
+ }
109
+
110
+ export const CHOPLIFTER = {
111
+ key: "choplifter",
112
+ aliases: ["chopper", "rescue", "heli"],
113
+ title: "CHOPLIFTER",
114
+ blurb: "fly out, land, fill the back, and get them home",
115
+ keys: "← → fly · ↑ ↓ climb and land · q quit",
116
+ tickMs: 55,
117
+
118
+ create({ rng = Math.random } = {}) {
119
+ const { people, tanks } = populate(rng);
120
+ return {
121
+ chopper: { x: BASE + 2, y: GROUND - 1, vy: 0 },
122
+ throttle: 0,
123
+ people,
124
+ tanks,
125
+ shells: [],
126
+ aboard: 0,
127
+ home: 0,
128
+ score: 0,
129
+ lives: LIVES,
130
+ clock: 0,
131
+ over: null,
132
+ rng,
133
+ };
134
+ },
135
+
136
+ tick: step,
137
+
138
+ onKey(state, pressed) {
139
+ const chop = state.chopper;
140
+ if (pressed === "left") state.throttle = Math.max(-1.4, state.throttle - 0.7);
141
+ else if (pressed === "right") state.throttle = Math.min(1.4, state.throttle + 0.7);
142
+ else if (pressed === "up") chop.vy = Math.max(-0.7, chop.vy - 0.45);
143
+ else if (pressed === "down") chop.vy = Math.min(0.7, chop.vy + 0.45);
144
+ return state;
145
+ },
146
+
147
+ status(state) {
148
+ if (state.over) return state.over;
149
+ return `${state.home} home · ${state.aboard}/${SEATS} aboard · ${state.people.length} waiting · ${"▲".repeat(state.lives)}`;
150
+ },
151
+
152
+ render(state) {
153
+ // The camera keeps the chopper in the middle until the world runs out.
154
+ const camera = Math.max(0, Math.min(WORLD - WIDTH, Math.round(state.chopper.x) - Math.floor(WIDTH / 2)));
155
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
156
+ const put = (worldX, y, glyph) => {
157
+ const x = Math.round(worldX) - camera;
158
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
159
+ grid[y][x] = glyph;
160
+ };
161
+
162
+ for (let i = 0; i < BASE_W; i++) put(BASE + i, GROUND, acid("═"));
163
+ for (const person of state.people) put(person.x, GROUND - 1, bone("Ω"));
164
+ for (const tank of state.tanks) put(tank.x, GROUND - 1, danger("▙"));
165
+ for (const shell of state.shells) put(shell.x, shell.y, amber("•"));
166
+ const chop = state.chopper;
167
+ put(chop.x, Math.round(chop.y), state.over ? danger("✷") : acid("╤"));
168
+ put(chop.x - 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
169
+ put(chop.x + 1, Math.round(chop.y), state.over ? danger("✷") : ash("─"));
170
+
171
+ return grid.map((row, y) => row.map((cell, x) => {
172
+ if (cell) return cell;
173
+ if (y === GROUND) return sand("▀");
174
+ if (y > GROUND) return sand("░");
175
+ // A horizon marker every ten columns of world, so flying feels like it.
176
+ return (x + camera) % 12 === 0 && y === 1 ? dim("│") : " ";
177
+ }).join(""));
178
+ },
179
+ };
@@ -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
+ };