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,186 @@
1
+ // Breakout. A wall, a paddle, and one ball that is always your fault.
2
+ //
3
+ // The bounce off the paddle is not a mirror: where the ball lands on the paddle
4
+ // decides the angle it leaves at, so the paddle is a steering wheel rather than
5
+ // a wall. Without that you cannot dig a channel up the side of the wall, and
6
+ // digging a channel is the entire reason anybody still plays this.
7
+ import { acid, amber, bone, danger, rgb } from "./ui.mjs";
8
+
9
+ export const WIDTH = 40;
10
+ export const HEIGHT = 17;
11
+
12
+ export const BRICK_W = 4;
13
+ export const BRICK_COLS = WIDTH / BRICK_W; // 10
14
+ export const BRICK_ROWS = 5;
15
+ export const BRICK_TOP = 1;
16
+
17
+ export const PADDLE_W = 7;
18
+ export const PADDLE_ROW = HEIGHT - 1;
19
+ const PADDLE_STEP = 2;
20
+
21
+ const LIVES = 3;
22
+ const BASE_VX = 0.62;
23
+ const BASE_VY = 0.34; // rows per tick — half of vx, because a row is two columns
24
+ const SPIN = 0.5;
25
+ const LEVEL_UP = 1.12;
26
+
27
+ /** Top rows are worth more, which is what makes the ball worth risking. */
28
+ export const ROW_POINTS = [50, 40, 30, 20, 10];
29
+ const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)];
30
+
31
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
32
+
33
+ /** A full wall: every brick standing. */
34
+ export const buildWall = () => Array.from({ length: BRICK_ROWS }, () => Array.from({ length: BRICK_COLS }, () => true));
35
+
36
+ export const bricksLeft = (wall) => wall.reduce((n, row) => n + row.filter(Boolean).length, 0);
37
+
38
+ /** The brick under a cell, or null. */
39
+ export function brickAt(wall, x, y) {
40
+ const row = y - BRICK_TOP;
41
+ if (row < 0 || row >= BRICK_ROWS) return null;
42
+ const col = Math.floor(x / BRICK_W);
43
+ if (col < 0 || col >= BRICK_COLS || !wall[row]?.[col]) return null;
44
+ return { row, col };
45
+ }
46
+
47
+ /** The ball sitting on the paddle, waiting for space. */
48
+ function rest(state) {
49
+ state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 };
50
+ state.stuck = true;
51
+ return state;
52
+ }
53
+
54
+ export function launch(state) {
55
+ if (!state.stuck) return state;
56
+ state.stuck = false;
57
+ state.ball.vx = (state.rng() < 0.5 ? -1 : 1) * BASE_VX * state.pace;
58
+ state.ball.vy = -BASE_VY * state.pace;
59
+ return state;
60
+ }
61
+
62
+ /** One tick. Exported so a test can clear a whole wall with no clock. */
63
+ export function step(state) {
64
+ if (state.stuck) {
65
+ // A ball that has not been launched rides the paddle, so moving before you
66
+ // serve aims the serve.
67
+ state.ball.x = state.paddle + PADDLE_W / 2;
68
+ return state;
69
+ }
70
+
71
+ const ball = state.ball;
72
+ const wasCol = Math.round(ball.x);
73
+ const wasRow = Math.round(ball.y);
74
+ ball.x += ball.vx;
75
+ ball.y += ball.vy;
76
+
77
+ if (ball.x < 0) { ball.x = -ball.x; ball.vx = Math.abs(ball.vx); }
78
+ if (ball.x > WIDTH - 1) { ball.x = 2 * (WIDTH - 1) - ball.x; ball.vx = -Math.abs(ball.vx); }
79
+ if (ball.y < 0) { ball.y = -ball.y; ball.vy = Math.abs(ball.vy); }
80
+
81
+ const col = Math.round(ball.x);
82
+ const row = Math.round(ball.y);
83
+ const brick = brickAt(state.wall, col, row);
84
+ if (brick) {
85
+ state.wall[brick.row][brick.col] = false;
86
+ state.score += ROW_POINTS[brick.row];
87
+ // Which way it bounces depends on which way it came in: through a row means
88
+ // the ball flips vertically, along a row means it flips sideways.
89
+ if (row !== wasRow) ball.vy = -ball.vy;
90
+ else if (col !== wasCol) ball.vx = -ball.vx;
91
+ else ball.vy = -ball.vy;
92
+ if (!bricksLeft(state.wall)) return cleared(state);
93
+ }
94
+
95
+ if (ball.vy > 0 && ball.y >= PADDLE_ROW - 1) {
96
+ const off = ball.x - (state.paddle + (PADDLE_W - 1) / 2);
97
+ if (Math.abs(off) <= PADDLE_W / 2 + 0.5) {
98
+ ball.y = PADDLE_ROW - 1;
99
+ ball.vy = -Math.abs(ball.vy);
100
+ // The steering wheel: the further out you take it, the flatter it leaves.
101
+ ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -1.4, 1.4);
102
+ if (Math.abs(ball.vx) < 0.15) ball.vx = ball.vx < 0 ? -0.15 : 0.15;
103
+ }
104
+ }
105
+
106
+ if (ball.y > PADDLE_ROW) {
107
+ state.lives--;
108
+ if (state.lives <= 0) {
109
+ state.lives = 0;
110
+ state.over = `out of balls · ${state.score} points`;
111
+ return state;
112
+ }
113
+ rest(state);
114
+ }
115
+ return state;
116
+ }
117
+
118
+ function cleared(state) {
119
+ state.level++;
120
+ state.pace *= LEVEL_UP;
121
+ state.wall = buildWall();
122
+ state.score += 100;
123
+ return rest(state);
124
+ }
125
+
126
+ export const BREAKOUT = {
127
+ key: "breakout",
128
+ aliases: ["arkanoid", "wall"],
129
+ title: "BREAKOUT",
130
+ blurb: "dig a channel up the side and let the ball do the rest",
131
+ keys: "← → paddle · space launch · q quit",
132
+ tickMs: 50,
133
+
134
+ create({ rng = Math.random } = {}) {
135
+ const state = {
136
+ wall: buildWall(),
137
+ paddle: Math.floor((WIDTH - PADDLE_W) / 2),
138
+ score: 0,
139
+ lives: LIVES,
140
+ level: 1,
141
+ pace: 1,
142
+ over: null,
143
+ rng,
144
+ };
145
+ return rest(state);
146
+ },
147
+
148
+ tick: step,
149
+
150
+ onKey(state, key) {
151
+ if (key === "left") state.paddle = clamp(state.paddle - PADDLE_STEP, 0, WIDTH - PADDLE_W);
152
+ else if (key === "right") state.paddle = clamp(state.paddle + PADDLE_STEP, 0, WIDTH - PADDLE_W);
153
+ else if (key === "space" || key === "up" || key === "enter") launch(state);
154
+ return state;
155
+ },
156
+
157
+ status(state) {
158
+ return state.over
159
+ ? state.over
160
+ : `${state.score} · level ${state.level} · ${"●".repeat(state.lives)}`;
161
+ },
162
+
163
+ render(state) {
164
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
165
+ const put = (x, y, glyph) => {
166
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
167
+ grid[y][x] = glyph;
168
+ };
169
+
170
+ for (let row = 0; row < BRICK_ROWS; row++) {
171
+ for (let col = 0; col < BRICK_COLS; col++) {
172
+ if (!state.wall[row][col]) continue;
173
+ // A brick is drawn exactly as wide as it is hit, with a seam so the wall
174
+ // reads as bricks rather than as one solid slab.
175
+ for (let i = 0; i < BRICK_W; i++) {
176
+ put(col * BRICK_W + i, BRICK_TOP + row, ROW_COLOR[row](i === BRICK_W - 1 ? "▓" : "█"));
177
+ }
178
+ }
179
+ }
180
+
181
+ for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
182
+ put(Math.round(state.ball.x), Math.round(state.ball.y), state.stuck ? amber("●") : bone("●"));
183
+
184
+ return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
185
+ },
186
+ };
@@ -0,0 +1,241 @@
1
+ // Centipede. It comes down through the mushrooms, and every piece you shoot out
2
+ // of the middle leaves you two of them.
3
+ //
4
+ // The trick that makes this cheap: the centipede is not a linked body, it is a
5
+ // list of segments that each obey the same rule — walk sideways, and when
6
+ // something is in the way, drop a row and turn round. Kept that way, a shot to
7
+ // the middle needs no surgery at all. You remove one segment and the ones
8
+ // behind it simply carry on, which is exactly what splitting looks like.
9
+ import { acid, amber, danger, dim, rgb } from "./ui.mjs";
10
+
11
+ export const WIDTH = 38;
12
+ export const HEIGHT = 18;
13
+
14
+ /** The bottom strip is yours; the centipede comes down into it. */
15
+ export const ZONE_TOP = HEIGHT - 5;
16
+ const MUSHROOM_HP = 4;
17
+ const LIVES = 3;
18
+ const SHOTS = 3;
19
+ const SHOT_SPEED = 2;
20
+
21
+ const shroom = rgb(120, 190, 40);
22
+ const MUSH_ART = ["▁", "▄", "▆", "█"]; // fuller the healthier
23
+
24
+ const key = (x, y) => `${x},${y}`;
25
+
26
+ /** A field of mushrooms, none of them in the row you stand on. */
27
+ export function seedField(rng, count = 34) {
28
+ const field = new Map();
29
+ for (let i = 0; i < count; i++) {
30
+ const x = Math.floor(rng() * WIDTH);
31
+ const y = 1 + Math.floor(rng() * (HEIGHT - 3));
32
+ if (y >= HEIGHT - 1) continue;
33
+ field.set(key(x, y), MUSHROOM_HP);
34
+ }
35
+ return field;
36
+ }
37
+
38
+ /** Chip a mushroom, and say whether one was there. A dead one is worth points. */
39
+ export function bite(field, x, y) {
40
+ const hp = field.get(key(x, y));
41
+ if (!hp) return 0;
42
+ if (hp <= 1) { field.delete(key(x, y)); return 5; }
43
+ field.set(key(x, y), hp - 1);
44
+ return 1;
45
+ }
46
+
47
+ /** A fresh centipede, strung out along the top row. */
48
+ export function newCentipede(length = 10) {
49
+ return Array.from({ length }, (_, i) => ({ x: length - 1 - i, y: 0, dir: 1, down: 1 }));
50
+ }
51
+
52
+ const blocked = (state, x, y) => x < 0 || x >= WIDTH || state.field.has(key(x, y));
53
+
54
+ /** Walk one segment: sideways if it can, otherwise down a row and about turn. */
55
+ export function walk(state, seg) {
56
+ if (!blocked(state, seg.x + seg.dir, seg.y)) { seg.x += seg.dir; return seg; }
57
+ seg.dir *= -1;
58
+ seg.y += seg.down;
59
+ // It bounces off the floor and climbs back up rather than vanishing, which is
60
+ // what keeps the bottom of the board dangerous instead of a safe corner.
61
+ if (seg.y >= HEIGHT - 1) { seg.y = HEIGHT - 1; seg.down = -1; }
62
+ if (seg.y <= 0) { seg.y = 0; seg.down = 1; }
63
+ return seg;
64
+ }
65
+
66
+ /** How many ticks between steps — shorter as the wave goes on. */
67
+ export const cadence = (state) => Math.max(2, 7 - state.wave);
68
+
69
+ /**
70
+ * The spider: in from the side, bouncing diagonally through your strip, eating
71
+ * mushrooms as it goes.
72
+ *
73
+ * It is here because without it the bottom of the board is safe. The centipede
74
+ * only reaches your row on the sweeps it happens to end on, so a player who
75
+ * simply never moves can survive a long time — which is not a game. The spider
76
+ * is the reason you cannot stand still.
77
+ */
78
+ export function spiderStep(state) {
79
+ const spider = state.spider;
80
+ if (!spider) return state;
81
+ spider.x += spider.dx;
82
+ spider.y += spider.dy;
83
+ if (spider.y < ZONE_TOP) { spider.y = ZONE_TOP; spider.dy = 1; }
84
+ if (spider.y > HEIGHT - 1) { spider.y = HEIGHT - 1; spider.dy = -1; }
85
+ // It leaves the way it came in rather than turning round at the wall, so it
86
+ // is a visitor and not a permanent resident.
87
+ if (spider.x < 0 || spider.x >= WIDTH) { state.spider = null; return state; }
88
+ state.field.delete(key(spider.x, spider.y));
89
+ return state;
90
+ }
91
+
92
+ export function spawnSpider(state) {
93
+ const fromLeft = state.rng() < 0.5;
94
+ state.spider = {
95
+ x: fromLeft ? 0 : WIDTH - 1,
96
+ y: HEIGHT - 1 - Math.floor(state.rng() * 3),
97
+ dx: fromLeft ? 1 : -1,
98
+ dy: state.rng() < 0.5 ? -1 : 1,
99
+ };
100
+ return state;
101
+ }
102
+
103
+ function hitPlayer(state) {
104
+ state.lives--;
105
+ if (state.lives <= 0) {
106
+ state.lives = 0;
107
+ state.over = `eaten · ${state.score} points`;
108
+ return state;
109
+ }
110
+ state.centipede = newCentipede(10);
111
+ state.shots = [];
112
+ state.spider = null;
113
+ state.player = { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 };
114
+ return state;
115
+ }
116
+
117
+ /** One tick. Exported so a test can clear a wave with no clock. */
118
+ export function step(state) {
119
+ for (let i = 0; i < SHOT_SPEED; i++) {
120
+ for (const shot of [...state.shots]) {
121
+ shot.y -= 1;
122
+ if (shot.y < 0) { state.shots = state.shots.filter((s) => s !== shot); continue; }
123
+ const points = bite(state.field, shot.x, shot.y);
124
+ if (points) { state.score += points; state.shots = state.shots.filter((s) => s !== shot); continue; }
125
+ if (state.spider && state.spider.x === shot.x && state.spider.y === shot.y) {
126
+ state.spider = null;
127
+ state.shots = state.shots.filter((s) => s !== shot);
128
+ state.score += 300;
129
+ continue;
130
+ }
131
+ const seg = state.centipede.find((s) => s.x === shot.x && s.y === shot.y);
132
+ if (!seg) continue;
133
+ state.shots = state.shots.filter((s) => s !== shot);
134
+ state.centipede = state.centipede.filter((s) => s !== seg);
135
+ state.score += 10;
136
+ // Every piece you take out of it leaves a mushroom where it fell, which
137
+ // is how the field thickens and the next wave gets harder for free.
138
+ state.field.set(key(seg.x, seg.y), MUSHROOM_HP);
139
+ }
140
+ }
141
+
142
+ if (!state.centipede.length) {
143
+ state.wave++;
144
+ state.centipede = newCentipede(10);
145
+ state.score += 100;
146
+ return state;
147
+ }
148
+
149
+ state.clock++;
150
+ if (state.clock >= cadence(state)) {
151
+ state.clock = 0;
152
+ for (const seg of state.centipede) walk(state, seg);
153
+ if (state.centipede.some((s) => s.x === state.player.x && s.y === state.player.y)) return hitPlayer(state);
154
+ }
155
+
156
+ state.spiderClock++;
157
+ if (state.spiderClock % 3 === 0) {
158
+ spiderStep(state);
159
+ const spider = state.spider;
160
+ if (spider && spider.x === state.player.x && spider.y === state.player.y) return hitPlayer(state);
161
+ }
162
+ if (!state.spider && state.rng() < 0.02) spawnSpider(state);
163
+ return state;
164
+ }
165
+
166
+ export const CENTIPEDE = {
167
+ key: "centipede",
168
+ aliases: ["cent", "bug", "millipede"],
169
+ title: "CENTIPEDE",
170
+ blurb: "shoot it in the middle and now there are two of them",
171
+ keys: "← ↑ ↓ → move · space fire · q quit",
172
+ tickMs: 55,
173
+
174
+ create({ rng = Math.random } = {}) {
175
+ return {
176
+ field: seedField(rng),
177
+ centipede: newCentipede(10),
178
+ player: { x: Math.floor(WIDTH / 2), y: HEIGHT - 1 },
179
+ shots: [],
180
+ spider: null,
181
+ spiderClock: 0,
182
+ score: 0,
183
+ lives: LIVES,
184
+ wave: 1,
185
+ clock: 0,
186
+ over: null,
187
+ rng,
188
+ };
189
+ },
190
+
191
+ tick: step,
192
+
193
+ onKey(state, pressed) {
194
+ const p = state.player;
195
+ // You are free in the bottom strip and nowhere else — the whole game is
196
+ // fought in five rows.
197
+ if (pressed === "left") p.x = Math.max(0, p.x - 1);
198
+ else if (pressed === "right") p.x = Math.min(WIDTH - 1, p.x + 1);
199
+ else if (pressed === "up") p.y = Math.max(ZONE_TOP, p.y - 1);
200
+ else if (pressed === "down") p.y = Math.min(HEIGHT - 1, p.y + 1);
201
+ else if (pressed === "space" || pressed === "enter") {
202
+ if (state.shots.length < SHOTS) state.shots.push({ x: p.x, y: p.y - 1 });
203
+ return state;
204
+ }
205
+ // Walking into a mushroom is walking into a wall.
206
+ if (state.field.has(key(p.x, p.y))) {
207
+ if (pressed === "left") p.x += 1;
208
+ else if (pressed === "right") p.x -= 1;
209
+ else if (pressed === "up") p.y += 1;
210
+ else if (pressed === "down") p.y -= 1;
211
+ }
212
+ return state;
213
+ },
214
+
215
+ status(state) {
216
+ return state.over
217
+ ? state.over
218
+ : `${state.score} · wave ${state.wave} · ${"▲".repeat(state.lives)}`;
219
+ },
220
+
221
+ render(state) {
222
+ const grid = Array.from({ length: HEIGHT }, () => Array.from({ length: WIDTH }, () => null));
223
+ const put = (x, y, glyph) => {
224
+ if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
225
+ grid[y][x] = glyph;
226
+ };
227
+
228
+ for (const [at, hp] of state.field) {
229
+ const [x, y] = at.split(",").map(Number);
230
+ put(x, y, shroom(MUSH_ART[hp - 1] ?? "▁"));
231
+ }
232
+ for (const seg of state.centipede) put(seg.x, seg.y, danger("◍"));
233
+ if (state.spider) put(state.spider.x, state.spider.y, rgb(190, 130, 255)("✻"));
234
+ for (const shot of state.shots) put(shot.x, shot.y, amber("│"));
235
+ put(state.player.x, state.player.y, state.over ? danger("✷") : acid("▲"));
236
+
237
+ return grid.map((row, y) => row.map((cell) => (
238
+ cell ?? (y === ZONE_TOP - 1 ? dim("┈") : " ")
239
+ )).join(""));
240
+ },
241
+ };
@@ -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
+ };