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.
- package/README.md +24 -2
- package/bin/moshcode.mjs +16 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +80 -2
- package/src/games-asteroids.mjs +265 -0
- package/src/games-blackjack.mjs +284 -0
- package/src/games-breakout.mjs +186 -0
- package/src/games-centipede.mjs +241 -0
- package/src/games-choplifter.mjs +179 -0
- package/src/games-digdug.mjs +267 -0
- package/src/games-excitebike.mjs +206 -0
- package/src/games-frogger.mjs +194 -0
- package/src/games-hangman.mjs +6 -1
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -0
- package/src/games-spyhunter.mjs +230 -0
- package/src/games-stagedive.mjs +206 -0
- package/src/games-tank.mjs +230 -0
- package/src/games.mjs +35 -9
- package/src/news-sources.mjs +154 -0
- package/src/news.mjs +1071 -0
- package/src/rss-ui.mjs +517 -0
- package/src/tui.mjs +20 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Stagedive. You are running the barricade, the stage is coming at you, and it
|
|
2
|
+
// does not stop. Hop the monitor wedges and the amp stacks, duck the
|
|
3
|
+
// crowdsurfers, take the picks. One mistake is the whole run.
|
|
4
|
+
//
|
|
5
|
+
// A side-scroller in a terminal is really a scrolling list: the runner never
|
|
6
|
+
// moves along the x axis at all. Everything else slides left past a fixed
|
|
7
|
+
// column, which is why `speed` is measured in columns per tick and why the gap
|
|
8
|
+
// between hazards is multiplied by it — a jump lasts a fixed number of ticks, so
|
|
9
|
+
// a fair gap has to get longer as the stage gets faster.
|
|
10
|
+
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
11
|
+
|
|
12
|
+
export const WIDTH = 50;
|
|
13
|
+
export const HEIGHT = 9;
|
|
14
|
+
|
|
15
|
+
/** The row the runner's feet are on, and the stage edge under it. */
|
|
16
|
+
export const GROUND = 7;
|
|
17
|
+
const FLOOR = GROUND + 1;
|
|
18
|
+
|
|
19
|
+
/** The runner's column. It never changes — the stage moves, not you. */
|
|
20
|
+
export const RUNNER = 8;
|
|
21
|
+
|
|
22
|
+
const BASE_SPEED = 0.85;
|
|
23
|
+
const MAX_SPEED = 1.75;
|
|
24
|
+
const GRAVITY = 0.15;
|
|
25
|
+
const JUMP = -1.3; // ~5 rows up and ~17 ticks in the air
|
|
26
|
+
const DUCK_TICKS = 8; // a tap of ↓ stays crouched this long, so a repeat holds it
|
|
27
|
+
const SLAM = 0.7; // ↓ in mid-air comes down early, which is how you save a bad jump
|
|
28
|
+
|
|
29
|
+
const crowd = rgb(255, 120, 180);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What is on the stage. `art` is one row's worth of cells and `rows` is which
|
|
33
|
+
* rows it fills, so its width and its hitbox are the same number by
|
|
34
|
+
* construction — a hazard that is drawn wider than it hits is the oldest bug in
|
|
35
|
+
* the genre. `death` is what the status line says when it gets you.
|
|
36
|
+
*/
|
|
37
|
+
export const HAZARDS = {
|
|
38
|
+
wedge: { art: "██", rows: [GROUND], paint: ash, death: "tripped over a monitor wedge" },
|
|
39
|
+
stack: { art: "███", rows: [GROUND - 1, GROUND], paint: bone, death: "ran into an amp stack" },
|
|
40
|
+
// Head height: a standing runner wears it, a crouched one does not.
|
|
41
|
+
surfer: { art: "╾●╼", rows: [GROUND - 1], paint: crowd, death: "wore a crowdsurfer" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const PICK = amber("♦");
|
|
45
|
+
|
|
46
|
+
/** The cells a thing covers, so a hit is decided by what the screen showed. */
|
|
47
|
+
export function cells(thing) {
|
|
48
|
+
const x = Math.round(thing.x);
|
|
49
|
+
if (thing.kind === "pick") return { cols: [x, x], rows: [thing.row] };
|
|
50
|
+
const { art, rows } = HAZARDS[thing.kind];
|
|
51
|
+
return { cols: [x, x + art.length - 1], rows };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The runner: two rows standing, one crouched — which is the whole point of ↓. */
|
|
55
|
+
export function runnerRows(state) {
|
|
56
|
+
const y = Math.round(state.y);
|
|
57
|
+
if (state.duck > 0 && !state.airborne) return [GROUND];
|
|
58
|
+
return [y - 1, y];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hits = (thing, rows) => {
|
|
62
|
+
const { cols, rows: theirs } = cells(thing);
|
|
63
|
+
return RUNNER >= cols[0] && RUNNER <= cols[1] && theirs.some((r) => rows.includes(r));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Put the next thing on the far edge, and decide how far behind it the one
|
|
68
|
+
* after that will be. The gap scales with speed because a jump is a fixed
|
|
69
|
+
* number of ticks: without that, the stage eventually outruns the jump and the
|
|
70
|
+
* game stops being losable-by-mistake and starts being unfair.
|
|
71
|
+
*/
|
|
72
|
+
export function spawn(state) {
|
|
73
|
+
const { rng } = state;
|
|
74
|
+
const edge = WIDTH + 2;
|
|
75
|
+
const roll = rng();
|
|
76
|
+
|
|
77
|
+
if (roll < 0.3) {
|
|
78
|
+
// A line of picks. Low ones are free; a high arc is paid for with a jump.
|
|
79
|
+
const high = rng() < 0.55;
|
|
80
|
+
const count = 3 + Math.floor(rng() * 3);
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
state.things.push({ kind: "pick", x: edge + i * 2, row: high ? (i === 0 || i === count - 1 ? 5 : 4) : GROUND });
|
|
83
|
+
}
|
|
84
|
+
state.next = (10 + rng() * 8) * state.speed;
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const kind = roll < 0.55 ? "wedge" : roll < 0.8 ? "stack" : "surfer";
|
|
89
|
+
state.things.push({ kind, x: edge });
|
|
90
|
+
state.next = (16 + rng() * 14) * state.speed;
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const meters = (state) => Math.floor(state.dist / 2);
|
|
95
|
+
|
|
96
|
+
/** One tick of stage. Exported so a test can run the whole set without a clock. */
|
|
97
|
+
export function step(state) {
|
|
98
|
+
state.dist += state.speed;
|
|
99
|
+
state.speed = Math.min(MAX_SPEED, BASE_SPEED + state.dist / 2200);
|
|
100
|
+
|
|
101
|
+
if (state.airborne) {
|
|
102
|
+
state.vy += GRAVITY;
|
|
103
|
+
state.y += state.vy;
|
|
104
|
+
if (state.y >= GROUND) { state.y = GROUND; state.vy = 0; state.airborne = false; }
|
|
105
|
+
}
|
|
106
|
+
if (state.duck > 0) state.duck--;
|
|
107
|
+
|
|
108
|
+
for (const thing of state.things) thing.x -= state.speed;
|
|
109
|
+
state.things = state.things.filter((t) => !t.taken && t.x > -4);
|
|
110
|
+
|
|
111
|
+
state.next -= state.speed;
|
|
112
|
+
if (state.next <= 0) spawn(state);
|
|
113
|
+
|
|
114
|
+
const rows = runnerRows(state);
|
|
115
|
+
for (const thing of state.things) {
|
|
116
|
+
if (!hits(thing, rows)) continue;
|
|
117
|
+
if (thing.kind === "pick") { thing.taken = true; state.picks++; continue; }
|
|
118
|
+
state.over = `${HAZARDS[thing.kind].death} · ${meters(state)} m`;
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const STAGEDIVE = {
|
|
125
|
+
key: "stagedive",
|
|
126
|
+
aliases: ["dive", "runner", "stage"],
|
|
127
|
+
title: "STAGEDIVE",
|
|
128
|
+
blurb: "run the barricade, hop the gear, duck the crowd, take the picks",
|
|
129
|
+
keys: "↑ jump · ↓ duck (and slam) · space jump · q quit",
|
|
130
|
+
tickMs: 55,
|
|
131
|
+
|
|
132
|
+
create({ rng = Math.random } = {}) {
|
|
133
|
+
return {
|
|
134
|
+
y: GROUND,
|
|
135
|
+
vy: 0,
|
|
136
|
+
airborne: false,
|
|
137
|
+
duck: 0,
|
|
138
|
+
dist: 0,
|
|
139
|
+
speed: BASE_SPEED,
|
|
140
|
+
picks: 0,
|
|
141
|
+
things: [],
|
|
142
|
+
next: 24, // a moment of clear stage before the first thing arrives
|
|
143
|
+
over: null,
|
|
144
|
+
rng,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
tick: step,
|
|
149
|
+
|
|
150
|
+
onKey(state, key) {
|
|
151
|
+
if (key === "up" || key === "space" || key === "enter") {
|
|
152
|
+
if (state.airborne) return state; // no second jump; the floor is the rule
|
|
153
|
+
state.vy = JUMP;
|
|
154
|
+
state.airborne = true;
|
|
155
|
+
state.duck = 0;
|
|
156
|
+
return state;
|
|
157
|
+
}
|
|
158
|
+
if (key === "down") {
|
|
159
|
+
// In the air this is a slam, on the ground it is a crouch. Both are the
|
|
160
|
+
// same key because both are "get low", and one key is easier to mean.
|
|
161
|
+
if (state.airborne) state.vy = Math.max(state.vy, SLAM);
|
|
162
|
+
else state.duck = DUCK_TICKS;
|
|
163
|
+
}
|
|
164
|
+
return state;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
status(state) {
|
|
168
|
+
return state.over
|
|
169
|
+
? `${state.over} · ${state.picks} picks`
|
|
170
|
+
: `${meters(state)} m · ${state.picks} picks`;
|
|
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 { cols, rows } = cells(thing);
|
|
182
|
+
if (thing.kind === "pick") { put(cols[0], rows[0], PICK); continue; }
|
|
183
|
+
const { art, paint } = HAZARDS[thing.kind];
|
|
184
|
+
for (const row of rows) [...art].forEach((c, i) => put(cols[0] + i, row, paint(c)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const y = Math.round(state.y);
|
|
188
|
+
if (state.over) {
|
|
189
|
+
put(RUNNER, GROUND, danger("✷"));
|
|
190
|
+
} else if (state.duck > 0 && !state.airborne) {
|
|
191
|
+
put(RUNNER, GROUND, acid("▄"));
|
|
192
|
+
} else {
|
|
193
|
+
put(RUNNER, y - 1, acid("○"));
|
|
194
|
+
// The legs alternate with the stage, so standing still looks like running.
|
|
195
|
+
put(RUNNER, y, acid(state.airborne ? "⋏" : Math.floor(state.dist) % 2 ? "⋀" : "⋏"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return grid.map((row, ry) => row.map((cell, x) => {
|
|
199
|
+
if (cell) return cell;
|
|
200
|
+
if (ry !== FLOOR) return " ";
|
|
201
|
+
// The stage edge, with a mark every few cells so the speed is visible even
|
|
202
|
+
// when nothing else is on screen.
|
|
203
|
+
return (x + Math.floor(state.dist)) % 7 === 0 ? dim("╪") : ash("═");
|
|
204
|
+
}).join(""));
|
|
205
|
+
},
|
|
206
|
+
};
|