moshcode 0.41.0 → 0.43.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,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
+ };
@@ -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
+ };