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,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
|
+
};
|