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.
- package/README.md +22 -2
- package/package.json +1 -1
- package/src/cli-schema.mjs +6 -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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Tank. Two of them in a walled yard, one shell each in the air at a time, five
|
|
2
|
+
// hits and it is over.
|
|
3
|
+
//
|
|
4
|
+
// Everything is on the grid and turned in quarter turns, because a tank you
|
|
5
|
+
// cannot line up is a tank you cannot aim, and lining up *is* the shot. Your
|
|
6
|
+
// keys are one action each — a press turns you or moves you one cell — so
|
|
7
|
+
// holding an arrow drives, and tapping it nudges.
|
|
8
|
+
import { acid, ash, bone, danger, dim } from "./ui.mjs";
|
|
9
|
+
|
|
10
|
+
export const TARGET = 5;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The yard. `#` is wall, and the outer ring is closed — a shell that leaves the
|
|
14
|
+
* board is a shell nobody saw stop.
|
|
15
|
+
*/
|
|
16
|
+
export const YARD = [
|
|
17
|
+
"##########################################",
|
|
18
|
+
"# #",
|
|
19
|
+
"# #### ###### #### #",
|
|
20
|
+
"# # # # #",
|
|
21
|
+
"# # #### # #### # #",
|
|
22
|
+
"# # # # # #",
|
|
23
|
+
"# ##### # # #### # # #### #",
|
|
24
|
+
"# # # # # #",
|
|
25
|
+
"# # #### # #### # #",
|
|
26
|
+
"# # # # #",
|
|
27
|
+
"# #### ###### #### #",
|
|
28
|
+
"# #",
|
|
29
|
+
"##########################################",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const isWall = (x, y) => (YARD[y]?.[x] ?? "#") === "#";
|
|
33
|
+
|
|
34
|
+
// Derived from the yard rather than declared beside it, so the two can never
|
|
35
|
+
// disagree about how big the board is.
|
|
36
|
+
export const WIDTH = YARD[0].length;
|
|
37
|
+
export const HEIGHT = YARD.length;
|
|
38
|
+
|
|
39
|
+
/** Quarter turns, and the glyph a tank wears pointing that way. */
|
|
40
|
+
export const HEADINGS = [
|
|
41
|
+
{ dx: 0, dy: -1, glyph: "▲" },
|
|
42
|
+
{ dx: 1, dy: 0, glyph: "▶" },
|
|
43
|
+
{ dx: 0, dy: 1, glyph: "▼" },
|
|
44
|
+
{ dx: -1, dy: 0, glyph: "◀" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SHELL_SPEED = 1; // cells per tick
|
|
48
|
+
const THEM_EVERY = 4; // the machine gets a move every this many ticks
|
|
49
|
+
|
|
50
|
+
const spawnYou = () => ({ x: 2, y: 6, dir: 1, cool: 0 });
|
|
51
|
+
const spawnThem = () => ({ x: WIDTH - 3, y: 6, dir: 3, cool: 0 });
|
|
52
|
+
|
|
53
|
+
/** Move a tank one cell if there is floor there. Walls simply refuse. */
|
|
54
|
+
export function drive(tank, sign) {
|
|
55
|
+
const { dx, dy } = HEADINGS[tank.dir];
|
|
56
|
+
const x = tank.x + dx * sign;
|
|
57
|
+
const y = tank.y + dy * sign;
|
|
58
|
+
if (isWall(x, y)) return false;
|
|
59
|
+
tank.x = x;
|
|
60
|
+
tank.y = y;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const fire = (tank, owner) => ({
|
|
65
|
+
x: tank.x + HEADINGS[tank.dir].dx,
|
|
66
|
+
y: tank.y + HEADINGS[tank.dir].dy,
|
|
67
|
+
dir: tank.dir,
|
|
68
|
+
owner,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a tank can see another down the barrel: same row or column, nothing
|
|
73
|
+
* but floor in between. This is both how the machine decides to shoot and the
|
|
74
|
+
* only thing it is good at.
|
|
75
|
+
*/
|
|
76
|
+
export function lineOfSight(from, to) {
|
|
77
|
+
const { dx, dy } = HEADINGS[from.dir];
|
|
78
|
+
let x = from.x + dx;
|
|
79
|
+
let y = from.y + dy;
|
|
80
|
+
for (let i = 0; i < Math.max(WIDTH, HEIGHT); i++) {
|
|
81
|
+
if (isWall(x, y)) return false;
|
|
82
|
+
if (x === to.x && y === to.y) return true;
|
|
83
|
+
x += dx;
|
|
84
|
+
y += dy;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The next cell on the shortest way from one point to another, and the heading
|
|
91
|
+
* that gets there.
|
|
92
|
+
*
|
|
93
|
+
* This is a breadth-first search of the whole yard on every decision, which
|
|
94
|
+
* sounds extravagant for 546 cells and is not: it is the difference between a
|
|
95
|
+
* tank that comes around the block after you and one that drives into the same
|
|
96
|
+
* wall forever, which is what "just turn towards the enemy" does the moment
|
|
97
|
+
* there is anything between the two of you.
|
|
98
|
+
*/
|
|
99
|
+
export function stepToward(from, to) {
|
|
100
|
+
if (from.x === to.x && from.y === to.y) return null;
|
|
101
|
+
const seen = new Set([`${from.x},${from.y}`]);
|
|
102
|
+
const queue = [{ x: from.x, y: from.y, first: null }];
|
|
103
|
+
for (let head = 0; head < queue.length; head++) {
|
|
104
|
+
const cur = queue[head];
|
|
105
|
+
for (let dir = 0; dir < HEADINGS.length; dir++) {
|
|
106
|
+
const x = cur.x + HEADINGS[dir].dx;
|
|
107
|
+
const y = cur.y + HEADINGS[dir].dy;
|
|
108
|
+
const key = `${x},${y}`;
|
|
109
|
+
if (isWall(x, y) || seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const first = cur.first ?? { x, y, dir };
|
|
112
|
+
if (x === to.x && y === to.y) return first;
|
|
113
|
+
queue.push({ x, y, first });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One quarter turn from `dir` towards `want`, the short way round. */
|
|
120
|
+
export const quarterTurn = (dir, want) => (dir + ((want - dir + 4) % 4 === 3 ? 3 : 1)) % 4;
|
|
121
|
+
|
|
122
|
+
function hit(state, who) {
|
|
123
|
+
if (who === "you") state.yours++; else state.theirs++;
|
|
124
|
+
state.shells = [];
|
|
125
|
+
state.you = spawnYou();
|
|
126
|
+
state.them = spawnThem();
|
|
127
|
+
if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`;
|
|
128
|
+
else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`;
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One tick: shells first, then the machine takes its turn. */
|
|
133
|
+
export function step(state) {
|
|
134
|
+
for (const shell of [...state.shells]) {
|
|
135
|
+
for (let i = 0; i < SHELL_SPEED; i++) {
|
|
136
|
+
const { dx, dy } = HEADINGS[shell.dir];
|
|
137
|
+
shell.x += dx;
|
|
138
|
+
shell.y += dy;
|
|
139
|
+
if (isWall(shell.x, shell.y)) { state.shells = state.shells.filter((s) => s !== shell); break; }
|
|
140
|
+
const target = shell.owner === "you" ? state.them : state.you;
|
|
141
|
+
if (shell.x === target.x && shell.y === target.y) {
|
|
142
|
+
state.shells = state.shells.filter((s) => s !== shell);
|
|
143
|
+
hit(state, shell.owner);
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (state.over) return state;
|
|
149
|
+
|
|
150
|
+
if (state.you.cool > 0) state.you.cool--;
|
|
151
|
+
if (state.them.cool > 0) state.them.cool--;
|
|
152
|
+
|
|
153
|
+
state.clock++;
|
|
154
|
+
if (state.clock % THEM_EVERY) return state;
|
|
155
|
+
|
|
156
|
+
// The machine: shoot if it is looking at you, otherwise turn towards you, and
|
|
157
|
+
// drive when it is already pointed the right way. It is not clever, but it is
|
|
158
|
+
// relentless, and in a yard this size that is enough.
|
|
159
|
+
const them = state.them;
|
|
160
|
+
if (lineOfSight(them, state.you)) {
|
|
161
|
+
if (!them.cool && !state.shells.some((s) => s.owner === "them")) {
|
|
162
|
+
state.shells.push(fire(them, "them"));
|
|
163
|
+
them.cool = 6;
|
|
164
|
+
}
|
|
165
|
+
return state;
|
|
166
|
+
}
|
|
167
|
+
const next = stepToward(them, state.you);
|
|
168
|
+
if (!next) return state;
|
|
169
|
+
if (them.dir !== next.dir) them.dir = quarterTurn(them.dir, next.dir);
|
|
170
|
+
else drive(them, 1);
|
|
171
|
+
return state;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const TANK = {
|
|
175
|
+
key: "tank",
|
|
176
|
+
aliases: ["tanks", "combat"],
|
|
177
|
+
title: "TANK",
|
|
178
|
+
blurb: "two tanks, one yard, five hits — line it up and let go",
|
|
179
|
+
keys: "← → turn · ↑ drive · ↓ reverse · space fire · q quit",
|
|
180
|
+
tickMs: 60,
|
|
181
|
+
|
|
182
|
+
create() {
|
|
183
|
+
return {
|
|
184
|
+
you: spawnYou(),
|
|
185
|
+
them: spawnThem(),
|
|
186
|
+
shells: [],
|
|
187
|
+
yours: 0,
|
|
188
|
+
theirs: 0,
|
|
189
|
+
clock: 0,
|
|
190
|
+
over: null,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
tick: step,
|
|
195
|
+
|
|
196
|
+
onKey(state, key) {
|
|
197
|
+
const you = state.you;
|
|
198
|
+
if (key === "left") you.dir = (you.dir + 3) % 4;
|
|
199
|
+
else if (key === "right") you.dir = (you.dir + 1) % 4;
|
|
200
|
+
else if (key === "up") drive(you, 1);
|
|
201
|
+
else if (key === "down") drive(you, -1);
|
|
202
|
+
else if (key === "space" || key === "enter") {
|
|
203
|
+
// One shell of yours in the air at a time, same as the machine. Two would
|
|
204
|
+
// turn a duel into a hosepipe.
|
|
205
|
+
if (you.cool || state.shells.some((s) => s.owner === "you")) return state;
|
|
206
|
+
const shell = fire(you, "you");
|
|
207
|
+
if (!isWall(shell.x, shell.y)) state.shells.push(shell);
|
|
208
|
+
you.cool = 4;
|
|
209
|
+
}
|
|
210
|
+
return state;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
status(state) {
|
|
214
|
+
return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
render(state) {
|
|
218
|
+
const grid = YARD.map((row) => [...row].map((c) => (c === "#" ? ash("█") : null)));
|
|
219
|
+
const put = (x, y, glyph) => {
|
|
220
|
+
if (!grid[y] || x < 0 || x >= grid[y].length) return;
|
|
221
|
+
grid[y][x] = glyph;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
for (const shell of state.shells) put(shell.x, shell.y, (shell.owner === "you" ? bone : danger)("•"));
|
|
225
|
+
put(state.you.x, state.you.y, acid(HEADINGS[state.you.dir].glyph));
|
|
226
|
+
put(state.them.x, state.them.y, danger(HEADINGS[state.them.dir].glyph));
|
|
227
|
+
|
|
228
|
+
return grid.map((row) => row.map((cell) => cell ?? dim("·")).join(""));
|
|
229
|
+
},
|
|
230
|
+
};
|
package/src/games.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Twenty-two games, one frame. Every game here is the same shape (see GAME_SHAPE
|
|
4
4
|
// below) and is drawn by the same `frame()`, so they look like one arcade
|
|
5
|
-
// rather than
|
|
5
|
+
// rather than twenty-two weekend projects: a title, a status line, a boxed board, and
|
|
6
6
|
// one line of keys along the bottom. There is no menu, no options screen and no
|
|
7
7
|
// difficulty prompt — `/games tetris` is already playing by the time the frame
|
|
8
8
|
// lands, and `q` is always the way out.
|
|
@@ -18,6 +18,22 @@ import { PACMAN } from "./games-pacman.mjs";
|
|
|
18
18
|
import { TICTACTOE } from "./games-tictactoe.mjs";
|
|
19
19
|
import { HANGMAN } from "./games-hangman.mjs";
|
|
20
20
|
import { CHESS } from "./games-chess.mjs";
|
|
21
|
+
import { ASTEROIDS } from "./games-asteroids.mjs";
|
|
22
|
+
import { BLACKJACK } from "./games-blackjack.mjs";
|
|
23
|
+
import { STAGEDIVE } from "./games-stagedive.mjs";
|
|
24
|
+
import { INVADERS } from "./games-invaders.mjs";
|
|
25
|
+
import { BREAKOUT } from "./games-breakout.mjs";
|
|
26
|
+
import { PONG } from "./games-pong.mjs";
|
|
27
|
+
import { TANK } from "./games-tank.mjs";
|
|
28
|
+
import { SPYHUNTER } from "./games-spyhunter.mjs";
|
|
29
|
+
import { CENTIPEDE } from "./games-centipede.mjs";
|
|
30
|
+
import { FROGGER } from "./games-frogger.mjs";
|
|
31
|
+
import { DIGDUG } from "./games-digdug.mjs";
|
|
32
|
+
import { KONG } from "./games-kong.mjs";
|
|
33
|
+
import { PITFALL } from "./games-pitfall.mjs";
|
|
34
|
+
import { CHOPLIFTER } from "./games-choplifter.mjs";
|
|
35
|
+
import { EXCITEBIKE } from "./games-excitebike.mjs";
|
|
36
|
+
import { OUTRUN } from "./games-outrun.mjs";
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
39
|
* @typedef {object} Game — the whole contract, so a seventh game is an import.
|
|
@@ -27,6 +43,8 @@ import { CHESS } from "./games-chess.mjs";
|
|
|
27
43
|
* @property {string} blurb one line, for /games list
|
|
28
44
|
* @property {string} keys the footer; the only place controls are ever explained
|
|
29
45
|
* @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
|
|
46
|
+
* @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
|
|
47
|
+
* @property {boolean} [restartable] false when `r` is only a restart once the game is over
|
|
30
48
|
* @property {Function} create ({ rng }) => state
|
|
31
49
|
* @property {Function} onKey (state, key, { rng }) => state
|
|
32
50
|
* @property {Function} [tick] (state, { rng }) => state
|
|
@@ -35,7 +53,11 @@ import { CHESS } from "./games-chess.mjs";
|
|
|
35
53
|
*/
|
|
36
54
|
|
|
37
55
|
/** The cabinet. Order is the order `/games` lists them. */
|
|
38
|
-
export const GAMES = [
|
|
56
|
+
export const GAMES = [
|
|
57
|
+
TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG,
|
|
58
|
+
FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE,
|
|
59
|
+
TICTACTOE, BLACKJACK, CHESS, HANGMAN,
|
|
60
|
+
];
|
|
39
61
|
|
|
40
62
|
/** Games by name, following aliases. Case- and slash-insensitive. */
|
|
41
63
|
export function resolveGame(name) {
|
|
@@ -98,8 +120,12 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
|
|
|
98
120
|
* Games never see an escape sequence; they see "up", "enter", "a". A chunk can
|
|
99
121
|
* hold several keypresses (hold an arrow key down and they arrive in batches),
|
|
100
122
|
* which is why this returns a list.
|
|
123
|
+
*
|
|
124
|
+
* `vim: false` is for the games that read letters — hangman cannot ask for a
|
|
125
|
+
* word with an `h` in it while `h` means left, and blackjack wants `h` to be
|
|
126
|
+
* hit. Arrows are unaffected either way; they arrive as escape sequences.
|
|
101
127
|
*/
|
|
102
|
-
export function decodeKeys(chunk) {
|
|
128
|
+
export function decodeKeys(chunk, { vim = true } = {}) {
|
|
103
129
|
const input = String(chunk);
|
|
104
130
|
const keys = [];
|
|
105
131
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -119,10 +145,10 @@ export function decodeKeys(chunk) {
|
|
|
119
145
|
if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
|
|
120
146
|
if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
|
|
121
147
|
if (c === "\t") { keys.push("tab"); continue; }
|
|
122
|
-
// vim keys, everywhere, for free — every game reads arrows
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
148
|
+
// vim keys, everywhere, for free — every game that reads arrows gets them
|
|
149
|
+
// without knowing about them. A game that reads letters opts out.
|
|
150
|
+
const vimKey = vim ? { h: "left", j: "down", k: "up", l: "right" }[c] : null;
|
|
151
|
+
if (vimKey) { keys.push(vimKey); continue; }
|
|
126
152
|
if (c >= " " && c <= "~") keys.push(c.toLowerCase());
|
|
127
153
|
}
|
|
128
154
|
return keys;
|
|
@@ -241,7 +267,7 @@ export async function runGame(game, deps = {}) {
|
|
|
241
267
|
const onSignal = () => { restore(); process.exit(130); };
|
|
242
268
|
|
|
243
269
|
function onData(chunk) {
|
|
244
|
-
for (const key of decodeKeys(chunk)) {
|
|
270
|
+
for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
|
|
245
271
|
if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
|
|
246
272
|
if (key === "r" && (state.over || game.restartable !== false)) {
|
|
247
273
|
state = game.create(ctx);
|