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.
@@ -0,0 +1,211 @@
1
+ // Pitfall. The jungle goes past whether you are ready or not: pits, logs,
2
+ // scorpions, and a vine over the worst of them.
3
+ //
4
+ // The vine is the reason this is not just another jumping game. A jump is short
5
+ // and you commit to it the moment you press it; a swing is long, and you can
6
+ // only start one where a vine is hanging — so the hazards that are too wide to
7
+ // jump are the ones that have a vine over them, and reading which is which as
8
+ // it comes at you is the game.
9
+ import { acid, ash, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 46;
12
+ export const HEIGHT = 13;
13
+
14
+ export const GROUND = 9; // the row you run along
15
+ const CANOPY = 2; // where the vines hang from
16
+ export const RUNNER = 9; // your column; the jungle moves, you do not
17
+
18
+ const JUMP = 10; // ticks in the air — about five columns of jungle
19
+ const SWING = 21; // ticks on a vine — a five-wide pit takes about sixteen
20
+ const REACH = 1; // how close to a vine you must be to catch it
21
+ const LIVES = 3;
22
+ const SPEED = 0.55; // columns of jungle per tick
23
+ const TIME = 2400; // ticks on the clock
24
+
25
+ const jungle = rgb(60, 140, 70);
26
+ const gold = rgb(255, 200, 60);
27
+
28
+ /** What the jungle throws at you, and what gets you past it. */
29
+ export const HAZARDS = {
30
+ pit: { w: 5, art: " ", paint: ash, cleared: "swing", death: "down a pit" },
31
+ log: { w: 2, art: "◙◙", paint: rgb(150, 100, 60), cleared: "jump", death: "rolled over by a log" },
32
+ scorpion: { w: 1, art: "%", paint: danger, cleared: "jump", death: "stung" },
33
+ };
34
+
35
+ /** The columns a thing covers. A vine and a bar of gold are one cell each. */
36
+ export const span = (thing) => {
37
+ const x = Math.round(thing.x);
38
+ return [x, x + (HAZARDS[thing.kind]?.w ?? 1) - 1];
39
+ };
40
+
41
+ const touching = (thing) => {
42
+ const [from, to] = span(thing);
43
+ return RUNNER >= from && RUNNER <= to;
44
+ };
45
+
46
+ /**
47
+ * The next thing down the trail, and how far behind it the one after that will
48
+ * be. A pit always comes with a vine over it: it is five columns wide and a
49
+ * jump covers three, so a pit with no vine is a pit nobody gets past.
50
+ */
51
+ export function spawn(state) {
52
+ const { rng } = state;
53
+ const edge = WIDTH + 2;
54
+ const roll = rng();
55
+ if (roll < 0.22) {
56
+ state.things.push({ kind: "treasure", x: edge });
57
+ state.next = 8 + rng() * 8;
58
+ } else if (roll < 0.5) {
59
+ state.things.push({ kind: "pit", x: edge });
60
+ state.things.push({ kind: "vine", x: edge - 3 });
61
+ state.next = 22 + rng() * 10;
62
+ } else if (roll < 0.78) {
63
+ state.things.push({ kind: "log", x: edge });
64
+ state.next = 16 + rng() * 10;
65
+ } else {
66
+ state.things.push({ kind: "scorpion", x: edge });
67
+ state.next = 16 + rng() * 10;
68
+ }
69
+ return state;
70
+ }
71
+
72
+ function lose(state, why) {
73
+ state.lives--;
74
+ if (state.lives <= 0) {
75
+ state.lives = 0;
76
+ state.over = `${why} · ${state.treasure} treasure`;
77
+ return state;
78
+ }
79
+ state.things = state.things.filter((t) => span(t)[1] < RUNNER - 2 || span(t)[0] > RUNNER + 12);
80
+ state.air = 0;
81
+ state.swinging = false;
82
+ state.clock = Math.max(0, state.clock - 120); // a fall costs you time as well
83
+ return state;
84
+ }
85
+
86
+ /** One tick of jungle. Exported so a test can run the trail with no clock. */
87
+ export function step(state) {
88
+ state.clock++;
89
+ state.dist += SPEED;
90
+ if (state.clock >= TIME) {
91
+ state.over = `out of daylight · ${state.treasure} treasure`;
92
+ return state;
93
+ }
94
+
95
+ if (state.air > 0) {
96
+ state.air--;
97
+ if (!state.air) state.swinging = false;
98
+ }
99
+
100
+ for (const thing of state.things) thing.x -= SPEED;
101
+ state.things = state.things.filter((t) => !t.taken && span(t)[1] > -3);
102
+
103
+ state.next -= SPEED;
104
+ if (state.next <= 0) spawn(state);
105
+
106
+ for (const thing of state.things) {
107
+ if (!touching(thing)) continue;
108
+ if (thing.kind === "vine") continue; // a vine is scenery until you grab it
109
+ if (thing.kind === "treasure") {
110
+ if (state.air) continue; // you cannot scoop it up mid-swing
111
+ thing.taken = true;
112
+ state.treasure++;
113
+ state.score += 500;
114
+ continue;
115
+ }
116
+ // In the air is past it, whichever way you got there.
117
+ if (state.air > 0) continue;
118
+ return lose(state, HAZARDS[thing.kind].death);
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /** Grab the vine you are under, if there is one. */
124
+ export function grab(state) {
125
+ if (state.air) return state;
126
+ const vine = state.things.find((t) => t.kind === "vine" && Math.abs(Math.round(t.x) - RUNNER) <= REACH);
127
+ if (!vine) return state;
128
+ state.air = SWING;
129
+ state.swinging = true;
130
+ return state;
131
+ }
132
+
133
+ export const PITFALL = {
134
+ key: "pitfall",
135
+ aliases: ["jungle", "vine"],
136
+ title: "PITFALL",
137
+ blurb: "jump the logs, swing the pits, and get the gold before dark",
138
+ keys: "space jump · ↑ grab a vine · q quit",
139
+ tickMs: 55,
140
+
141
+ create({ rng = Math.random } = {}) {
142
+ return {
143
+ things: [],
144
+ air: 0,
145
+ swinging: false,
146
+ next: 20,
147
+ dist: 0,
148
+ clock: 0,
149
+ treasure: 0,
150
+ score: 0,
151
+ lives: LIVES,
152
+ over: null,
153
+ rng,
154
+ };
155
+ },
156
+
157
+ tick: step,
158
+
159
+ onKey(state, pressed) {
160
+ if (pressed === "up") return grab(state);
161
+ if (pressed === "space" || pressed === "enter") {
162
+ if (!state.air) state.air = JUMP;
163
+ }
164
+ return state;
165
+ },
166
+
167
+ status(state) {
168
+ if (state.over) return state.over;
169
+ const left = Math.max(0, Math.round((TIME - state.clock) / 20));
170
+ return `${state.score} · ${state.treasure} gold · ${left}s · ${"▲".repeat(state.lives)}`;
171
+ },
172
+
173
+ render(state) {
174
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
175
+ const put = (x, y, glyph) => {
176
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
177
+ grid[y][x] = glyph;
178
+ };
179
+
180
+ for (const thing of state.things) {
181
+ const [from] = span(thing);
182
+ if (thing.kind === "vine") {
183
+ for (let y = CANOPY; y < GROUND - 2; y++) put(from, y, jungle("│"));
184
+ continue;
185
+ }
186
+ if (thing.kind === "treasure") { put(from, GROUND - 1, gold("▮")); continue; }
187
+ const { art, paint, w } = HAZARDS[thing.kind];
188
+ for (let i = 0; i < w; i++) {
189
+ // A pit is a hole in the floor rather than something drawn on it.
190
+ if (thing.kind === "pit") put(from + i, GROUND, dim(" "));
191
+ else put(from + i, GROUND - 1, paint(art[i]));
192
+ }
193
+ }
194
+
195
+ const y = state.swinging ? GROUND - 4 : state.air ? GROUND - 3 : GROUND - 1;
196
+ put(RUNNER, y, state.over ? danger("✷") : acid(state.swinging ? "⌾" : "◉"));
197
+ if (state.swinging) for (let v = CANOPY; v < y; v++) put(RUNNER, v, jungle("│"));
198
+
199
+ return grid.map((row, ry) => row.map((cell, x) => {
200
+ if (cell) return cell;
201
+ if (ry === GROUND) {
202
+ // The floor, with the pits left out of it.
203
+ const overPit = state.things.some((t) => t.kind === "pit" && x >= span(t)[0] && x <= span(t)[1]);
204
+ return overPit ? " " : jungle("▀");
205
+ }
206
+ if (ry === CANOPY - 1) return dim("╌");
207
+ if (ry > GROUND) return ash("░");
208
+ return " ";
209
+ }).join(""));
210
+ },
211
+ };
@@ -0,0 +1,147 @@
1
+ // Pong. The oldest one in the cabinet, and still the one that explains itself
2
+ // fastest: you are the left paddle, the ball is going that way, do something.
3
+ //
4
+ // The machine on the right is deliberately not perfect. It waits until the ball
5
+ // crosses the halfway line before it starts tracking, and then it moves slowly
6
+ // enough that it cannot reach a corner from the middle in the time it has left.
7
+ // A flat return it will always get; one taken off the end of your paddle it will
8
+ // not. That is the whole game, and it is why the angle off the paddle depends on
9
+ // where the ball hit it.
10
+ import { acid, bone, danger, dim } from "./ui.mjs";
11
+
12
+ export const WIDTH = 44;
13
+ export const HEIGHT = 16;
14
+
15
+ /** A row is worth two columns, so the ball travels at the angle it looks like. */
16
+ const ASPECT = 0.5;
17
+
18
+ export const PADDLE = 4; // rows tall
19
+ export const YOU_COL = 2;
20
+ export const THEM_COL = WIDTH - 3;
21
+ export const TARGET = 7; // first to this many
22
+
23
+ const SERVE_SPEED = 0.85;
24
+ const MAX_SPEED = 1.7;
25
+ const SPIN = 0.55; // how much the edge of the paddle bends the ball
26
+ const THEM_SPEED = 0.3; // slow enough that a ball into the corner beats it
27
+ const YOU_STEP = 1;
28
+
29
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
30
+
31
+ /** A ball in the middle, heading at whoever just lost the point. */
32
+ export function serve(state, toward) {
33
+ state.ball = {
34
+ x: WIDTH / 2,
35
+ y: HEIGHT / 2,
36
+ vx: toward * SERVE_SPEED,
37
+ // Never dead flat: a ball with no angle is a rally nobody can lose.
38
+ vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2),
39
+ };
40
+ return state;
41
+ }
42
+
43
+ /** Where a paddle's rows are, given its top row. */
44
+ export const paddleRows = (top) => Array.from({ length: PADDLE }, (_, i) => Math.round(top) + i);
45
+
46
+ const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5;
47
+
48
+ /**
49
+ * Bounce off a paddle, steeper the further from its middle you take it. This is
50
+ * the only way a player gets to aim, so it does more work than the physics.
51
+ */
52
+ function returned(ball, top, dir) {
53
+ const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2);
54
+ ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06);
55
+ ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -0.5, 0.5);
56
+ // Never let a rally go flat. A ball with no angle is one the machine can park
57
+ // in front of forever, and a rally that cannot end is not a game.
58
+ if (Math.abs(ball.vy) < 0.08) ball.vy = (ball.vy < 0 ? -1 : 1) * 0.12;
59
+ return ball;
60
+ }
61
+
62
+ /** One tick of rally. Exported so a test can play a whole match with no clock. */
63
+ export function step(state) {
64
+ const ball = state.ball;
65
+ ball.x += ball.vx;
66
+ ball.y += ball.vy;
67
+
68
+ // The top and bottom are walls, and the ball is put back inside rather than
69
+ // just reflected — at speed, a reflection alone can leave it outside.
70
+ if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); }
71
+ if (ball.y > HEIGHT - 1) { ball.y = 2 * (HEIGHT - 1) - ball.y; ball.vy = -Math.abs(ball.vy); }
72
+
73
+ if (ball.vx < 0 && ball.x <= YOU_COL) {
74
+ if (catches(state.you, ball.y)) { ball.x = YOU_COL; returned(ball, state.you, 1); }
75
+ } else if (ball.vx > 0 && ball.x >= THEM_COL) {
76
+ if (catches(state.them, ball.y)) { ball.x = THEM_COL; returned(ball, state.them, -1); }
77
+ }
78
+
79
+ if (ball.x < 0) { state.theirs++; point(state, 1); }
80
+ else if (ball.x > WIDTH - 1) { state.yours++; point(state, -1); }
81
+
82
+ // The machine: idle in the middle until the ball is on its half, then chase
83
+ // the ball's row. Perfect tracking here would make the game unloseable for it,
84
+ // which is the same thing as unplayable.
85
+ const chasing = state.ball.vx > 0 && state.ball.x > WIDTH * 0.4;
86
+ const want = chasing ? state.ball.y - (PADDLE - 1) / 2 : (HEIGHT - PADDLE) / 2;
87
+ const move = clamp(want - state.them, -THEM_SPEED, chasing ? THEM_SPEED : THEM_SPEED / 2);
88
+ state.them = clamp(state.them + move, 0, HEIGHT - PADDLE);
89
+
90
+ return state;
91
+ }
92
+
93
+ function point(state, toward) {
94
+ if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`;
95
+ else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`;
96
+ else serve(state, toward);
97
+ }
98
+
99
+ export const PONG = {
100
+ key: "pong",
101
+ aliases: ["tennis", "paddle"],
102
+ title: "PONG",
103
+ blurb: "first to seven, and the angle is all in where you hit it",
104
+ keys: "↑ ↓ move · q quit",
105
+ tickMs: 55,
106
+
107
+ create({ rng = Math.random } = {}) {
108
+ const state = {
109
+ you: (HEIGHT - PADDLE) / 2,
110
+ them: (HEIGHT - PADDLE) / 2,
111
+ yours: 0,
112
+ theirs: 0,
113
+ over: null,
114
+ rng,
115
+ };
116
+ return serve(state, rng() < 0.5 ? -1 : 1);
117
+ },
118
+
119
+ tick: step,
120
+
121
+ onKey(state, key) {
122
+ if (key === "up") state.you = clamp(state.you - YOU_STEP, 0, HEIGHT - PADDLE);
123
+ if (key === "down") state.you = clamp(state.you + YOU_STEP, 0, HEIGHT - PADDLE);
124
+ return state;
125
+ },
126
+
127
+ status(state) {
128
+ return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`;
129
+ },
130
+
131
+ render(state) {
132
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
133
+ const put = (x, y, glyph) => {
134
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
135
+ grid[y][x] = glyph;
136
+ };
137
+
138
+ for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█"));
139
+ for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█"));
140
+ put(Math.round(state.ball.x), Math.round(state.ball.y), bone("●"));
141
+
142
+ return grid.map((row, y) => row.map((cell, x) => (
143
+ // The net, which is only there so the middle of the table has a middle.
144
+ cell ?? (x === Math.floor(WIDTH / 2) && y % 2 === 0 ? dim("┊") : " ")
145
+ )).join(""));
146
+ },
147
+ };
@@ -0,0 +1,230 @@
1
+ // Spy Hunter. A road that will not hold still, traffic that will not get out of
2
+ // the way, and a gun. Stay on the tarmac, shoot the ones shooting back, and do
3
+ // not shoot the ones just driving home.
4
+ //
5
+ // The road is a list of rows, each one a left and a right edge, scrolled down
6
+ // under a car that only ever moves sideways. Generating the next row from the
7
+ // last one — rather than from a function of distance — is what makes the verge
8
+ // bend instead of zig-zag, and it is the only reason it reads as a road.
9
+ import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
10
+
11
+ export const WIDTH = 40;
12
+ export const HEIGHT = 18;
13
+
14
+ /** The row your car is on. It never changes; the road comes to you. */
15
+ export const CAR_ROW = HEIGHT - 3;
16
+ export const CAR_W = 2;
17
+
18
+ const MIN_ROAD = 13;
19
+ const MAX_ROAD = 24;
20
+ const BASE_SPEED = 0.34; // rows of road per tick
21
+ const MAX_SPEED = 0.75;
22
+ const LIVES = 3;
23
+ const GRACE = 25; // ticks of "the road is clear" after a wreck
24
+ const SHOT_SPEED = 1.6; // rows per tick, travelled in halves so nothing is skipped
25
+
26
+ /** The cars that are not you. */
27
+ export const TRAFFIC = {
28
+ enemy: { art: "▜▛", paint: danger, points: 50, homing: 0.06 },
29
+ civilian: { art: "▐▌", paint: bone, points: -100, homing: 0 },
30
+ };
31
+
32
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
33
+
34
+ /**
35
+ * The next row of road, bent a little from the one before it.
36
+ *
37
+ * The bend is random but pulled towards the middle of the screen, in proportion
38
+ * to how far out it already is. A drift with no pull is a random walk, and a
39
+ * random walk parks the road against one edge and leaves it there — which looks
40
+ * less like a road than like a bug.
41
+ */
42
+ export function nextRow(prev, rng) {
43
+ const width = clamp(prev.right - prev.left + (rng() < 0.3 ? (rng() < 0.5 ? -1 : 1) : 0), MIN_ROAD, MAX_ROAD);
44
+ const centre = (prev.left + prev.right) / 2;
45
+ const pull = clamp((WIDTH / 2 - centre) / 8, -0.45, 0.45);
46
+ const roll = rng() * 2 - 1 + pull;
47
+ const drift = Math.abs(roll) < 0.55 ? 0 : Math.sign(roll);
48
+ // One cell of verge on each side always stays on screen, so "the road bends"
49
+ // never reads as "the road ends".
50
+ const left = clamp(prev.left + drift, 1, WIDTH - width - 2);
51
+ return { left, right: left + width };
52
+ }
53
+
54
+ /** A straight run of road to start on, so the first thing you meet is not a bend. */
55
+ export function openRoad() {
56
+ const left = Math.floor((WIDTH - 20) / 2);
57
+ return Array.from({ length: HEIGHT }, () => ({ left, right: left + 20 }));
58
+ }
59
+
60
+ export const onRoad = (row, x) => row && x >= row.left && x + CAR_W - 1 <= row.right;
61
+
62
+ /** Whether two cars, each CAR_W wide, are in the same place. */
63
+ export const overlaps = (ax, bx) => Math.abs(Math.round(ax) - Math.round(bx)) < CAR_W;
64
+
65
+ function wreck(state, why) {
66
+ state.lives--;
67
+ if (state.lives <= 0) {
68
+ state.lives = 0;
69
+ state.over = `${why} · ${state.score} points`;
70
+ return state;
71
+ }
72
+ // A wreck clears the road ahead, or you respawn straight into the car that
73
+ // just got you and lose the rest of your lives in three ticks.
74
+ state.traffic = [];
75
+ state.grace = GRACE;
76
+ const row = state.road[CAR_ROW];
77
+ state.car = Math.round((row.left + row.right) / 2) - 1;
78
+ return state;
79
+ }
80
+
81
+ /** One tick of road. Exported so a test can drive a whole run with no clock. */
82
+ export function step(state) {
83
+ const { rng } = state;
84
+ state.dist += state.speed;
85
+ state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 900);
86
+ if (state.grace > 0) state.grace--;
87
+
88
+ // Scroll: the road only shifts on whole rows, so the verge never shimmers.
89
+ state.scroll += state.speed;
90
+ while (state.scroll >= 1) {
91
+ state.scroll -= 1;
92
+ state.road.pop();
93
+ state.road.unshift(nextRow(state.road[0], rng));
94
+ for (const car of state.traffic) car.y += 1;
95
+ for (const shot of state.shots) shot.y += 1;
96
+ }
97
+
98
+ // Shots move faster than a car is tall, so they travel in half-steps and are
99
+ // checked against the traffic after each one. Moving the whole way in one go
100
+ // lets a shot pass clean through a car that was between the two positions.
101
+ for (let half = 0; half < 2; half++) {
102
+ for (const shot of state.shots) shot.y -= SHOT_SPEED / 2;
103
+ hitTraffic(state);
104
+ }
105
+ state.shots = state.shots.filter((shot) => shot.y > -1);
106
+
107
+ for (const car of state.traffic) {
108
+ car.y += state.speed - car.speed;
109
+ // An enemy leans towards you; traffic just drives.
110
+ if (TRAFFIC[car.kind].homing) {
111
+ car.x += Math.sign(state.car - car.x) * TRAFFIC[car.kind].homing;
112
+ }
113
+ const row = state.road[Math.round(car.y)];
114
+ if (row) car.x = clamp(car.x, row.left, row.right - CAR_W + 1);
115
+ }
116
+ state.traffic = state.traffic.filter((car) => car.y < HEIGHT + 1 && car.y > -3);
117
+
118
+ if (!state.grace) {
119
+ const row = state.road[CAR_ROW];
120
+ if (!onRoad(row, state.car)) return wreck(state, "off the road");
121
+ const rammed = state.traffic.find((car) => Math.round(car.y) === CAR_ROW && overlaps(car.x, state.car));
122
+ if (rammed) return wreck(state, `rammed ${rammed.kind === "enemy" ? "an enemy" : "a civilian"}`);
123
+ }
124
+
125
+ if (!state.grace && state.traffic.length < 4 && rng() < 0.05) {
126
+ const row = state.road[0];
127
+ const kind = rng() < 0.6 ? "enemy" : "civilian";
128
+ state.traffic.push({
129
+ kind,
130
+ x: row.left + Math.floor(rng() * (row.right - row.left - CAR_W + 1)),
131
+ y: 0,
132
+ // Slower than you, or the road behind would never catch anybody up.
133
+ speed: 0.1 + rng() * 0.18,
134
+ });
135
+ }
136
+
137
+ state.score += Math.floor(state.dist / 10) - state.miles;
138
+ state.miles = Math.floor(state.dist / 10);
139
+ return state;
140
+ }
141
+
142
+ /** Shots meet traffic. A civilian you shoot is a civilian you pay for. */
143
+ function hitTraffic(state) {
144
+ for (const shot of [...state.shots]) {
145
+ const hit = state.traffic.find((car) => Math.abs(car.y - shot.y) < 0.9 && overlaps(car.x, shot.x - 0.5));
146
+ if (!hit) continue;
147
+ state.shots = state.shots.filter((s) => s !== shot);
148
+ state.traffic = state.traffic.filter((c) => c !== hit);
149
+ state.score = Math.max(0, state.score + TRAFFIC[hit.kind].points);
150
+ }
151
+ return state;
152
+ }
153
+
154
+ export const SPYHUNTER = {
155
+ key: "spyhunter",
156
+ aliases: ["spy", "chase", "hunter"],
157
+ title: "SPY HUNTER",
158
+ blurb: "keep it on the tarmac, shoot the ones shooting back",
159
+ keys: "← → steer · space fire · q quit",
160
+ tickMs: 55,
161
+
162
+ create({ rng = Math.random } = {}) {
163
+ const road = openRoad();
164
+ return {
165
+ road,
166
+ traffic: [],
167
+ shots: [],
168
+ car: Math.round((road[CAR_ROW].left + road[CAR_ROW].right) / 2) - 1,
169
+ speed: BASE_SPEED,
170
+ scroll: 0,
171
+ dist: 0,
172
+ miles: 0,
173
+ score: 0,
174
+ lives: LIVES,
175
+ grace: 0,
176
+ over: null,
177
+ rng,
178
+ };
179
+ },
180
+
181
+ tick: step,
182
+
183
+ onKey(state, key) {
184
+ if (key === "left") state.car -= 1;
185
+ else if (key === "right") state.car += 1;
186
+ else if (key === "space" || key === "up" || key === "enter") {
187
+ if (state.shots.length < 3) state.shots.push({ x: state.car + 0.5, y: CAR_ROW - 1 });
188
+ }
189
+ // Steering off the edge of the screen is a wreck like any other, so the car
190
+ // is only kept on the board, not on the road.
191
+ state.car = clamp(state.car, 0, WIDTH - CAR_W);
192
+ return state;
193
+ },
194
+
195
+ status(state) {
196
+ if (state.over) return state.over;
197
+ return `${state.score} · ${state.miles} mi · ${"▲".repeat(state.lives)}`;
198
+ },
199
+
200
+ render(state) {
201
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
202
+ const put = (x, y, glyph) => {
203
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
204
+ grid[y][x] = glyph;
205
+ };
206
+
207
+ for (const car of state.traffic) {
208
+ const kind = TRAFFIC[car.kind];
209
+ [...kind.art].forEach((c, i) => put(Math.round(car.x) + i, Math.round(car.y), kind.paint(c)));
210
+ }
211
+ for (const shot of state.shots) put(Math.round(shot.x), Math.round(shot.y), amber("•"));
212
+ if (state.over) {
213
+ [...("✷✷")].forEach((c, i) => put(state.car + i, CAR_ROW, danger(c)));
214
+ } else if (!state.grace || Math.floor(state.grace / 3) % 2) {
215
+ [...("▟▙")].forEach((c, i) => put(state.car + i, CAR_ROW, acid(c)));
216
+ }
217
+
218
+ return grid.map((row, y) => {
219
+ const edge = state.road[y];
220
+ return row.map((cell, x) => {
221
+ if (cell) return cell;
222
+ if (x < edge.left || x > edge.right) return ash("▒");
223
+ // The centre line, dashed, and moving — without it the road is a
224
+ // stationary corridor and you cannot tell you are going anywhere.
225
+ const middle = Math.round((edge.left + edge.right) / 2);
226
+ return x === middle && (y + Math.floor(state.dist)) % 4 < 2 ? dim("┆") : " ";
227
+ }).join("");
228
+ });
229
+ },
230
+ };