moshcode 0.44.0 → 0.45.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/package.json +1 -1
- package/src/games-breakout.mjs +26 -8
- package/src/games-pong.mjs +36 -9
- package/src/games.mjs +106 -22
package/package.json
CHANGED
package/src/games-breakout.mjs
CHANGED
|
@@ -16,13 +16,31 @@ export const BRICK_TOP = 1;
|
|
|
16
16
|
|
|
17
17
|
export const PADDLE_W = 7;
|
|
18
18
|
export const PADDLE_ROW = HEIGHT - 1;
|
|
19
|
-
const PADDLE_STEP = 2;
|
|
19
|
+
const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How often the wall is stepped. See the note in games-pong.mjs: the ball can
|
|
23
|
+
* only be drawn on whole cells, so smoothness comes from ticking often enough
|
|
24
|
+
* that the frames where it has not crossed into the next one go by too fast to
|
|
25
|
+
* read as a stall. Those frames are identical, and `runGame` does not write
|
|
26
|
+
* identical frames, so the extra ticks cost nothing on the wire.
|
|
27
|
+
*
|
|
28
|
+
* Ticking finer buys this game a second thing: at 0.34 rows a tick the ball
|
|
29
|
+
* used to cross a whole brick row between two samples, so which side it bounced
|
|
30
|
+
* off was a guess. It now samples inside every row it enters.
|
|
31
|
+
*/
|
|
32
|
+
export const TICK_MS = 16;
|
|
33
|
+
|
|
34
|
+
/** The speeds below are still written per 50ms, the rate this was tuned at. */
|
|
35
|
+
const SCALE = TICK_MS / 50;
|
|
20
36
|
|
|
21
37
|
const LIVES = 3;
|
|
22
|
-
const BASE_VX = 0.62;
|
|
23
|
-
const BASE_VY = 0.34; //
|
|
24
|
-
const SPIN = 0.5;
|
|
25
|
-
const
|
|
38
|
+
const BASE_VX = 0.62 * SCALE;
|
|
39
|
+
const BASE_VY = 0.34 * SCALE; // half of vx, because a row is two columns
|
|
40
|
+
const SPIN = 0.5 * SCALE;
|
|
41
|
+
const MAX_VX = 1.4 * SCALE;
|
|
42
|
+
const MIN_VX = 0.15 * SCALE; // never let it go vertical and unsteerable
|
|
43
|
+
const LEVEL_UP = 1.12; // a multiplier on pace, so it does not scale
|
|
26
44
|
|
|
27
45
|
/** Top rows are worth more, which is what makes the ball worth risking. */
|
|
28
46
|
export const ROW_POINTS = [50, 40, 30, 20, 10];
|
|
@@ -98,8 +116,8 @@ export function step(state) {
|
|
|
98
116
|
ball.y = PADDLE_ROW - 1;
|
|
99
117
|
ball.vy = -Math.abs(ball.vy);
|
|
100
118
|
// 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, -
|
|
102
|
-
if (Math.abs(ball.vx) <
|
|
119
|
+
ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -MAX_VX, MAX_VX);
|
|
120
|
+
if (Math.abs(ball.vx) < MIN_VX) ball.vx = ball.vx < 0 ? -MIN_VX : MIN_VX;
|
|
103
121
|
}
|
|
104
122
|
}
|
|
105
123
|
|
|
@@ -129,7 +147,7 @@ export const BREAKOUT = {
|
|
|
129
147
|
title: "BREAKOUT",
|
|
130
148
|
blurb: "dig a channel up the side and let the ball do the rest",
|
|
131
149
|
keys: "← → paddle · space launch · q quit",
|
|
132
|
-
tickMs:
|
|
150
|
+
tickMs: TICK_MS,
|
|
133
151
|
|
|
134
152
|
create({ rng = Math.random } = {}) {
|
|
135
153
|
const state = {
|
package/src/games-pong.mjs
CHANGED
|
@@ -20,11 +20,38 @@ export const YOU_COL = 2;
|
|
|
20
20
|
export const THEM_COL = WIDTH - 3;
|
|
21
21
|
export const TARGET = 7; // first to this many
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
/**
|
|
24
|
+
* How often the table is stepped.
|
|
25
|
+
*
|
|
26
|
+
* The board is a grid of characters, so wherever the ball really is it can only
|
|
27
|
+
* ever be drawn on a whole cell — and what reads as smooth is not the size of
|
|
28
|
+
* that step but the evenness of it. At 55ms a ball crossing fifteen columns a
|
|
29
|
+
* second advances a cell on six ticks out of seven and stands still on the
|
|
30
|
+
* seventh, and that one stalled frame, arriving three times a second, is the
|
|
31
|
+
* jiggle. Ticking at 16ms does not move the ball anywhere different at any
|
|
32
|
+
* given moment; it shrinks the stall from 55ms to 16ms, which is under what the
|
|
33
|
+
* eye reads as a stop. It is close to free, too: a tick that leaves the ball in
|
|
34
|
+
* the same cell renders an identical frame, and `runGame` never writes one of
|
|
35
|
+
* those to the terminal.
|
|
36
|
+
*/
|
|
37
|
+
export const TICK_MS = 16;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The speeds below are still written per 55ms — the rate this game was tuned
|
|
41
|
+
* at — and scaled to the tick. Keeping the tuned numbers legible matters more
|
|
42
|
+
* than saving a multiply: they are what makes the machine beatable off the end
|
|
43
|
+
* of the paddle and not from the middle, and that balance is the game.
|
|
44
|
+
*/
|
|
45
|
+
const SCALE = TICK_MS / 55;
|
|
46
|
+
|
|
47
|
+
const SERVE_SPEED = 0.85 * SCALE;
|
|
48
|
+
const MAX_SPEED = 1.7 * SCALE;
|
|
49
|
+
const SPIN = 0.55 * SCALE; // how much the edge of the paddle bends the ball
|
|
50
|
+
const THEM_SPEED = 0.3 * SCALE; // slow enough that a ball into the corner beats it
|
|
51
|
+
const FLAT = 0.08 * SCALE; // below this a rally has gone flat
|
|
52
|
+
const NUDGE = 0.12 * SCALE; // and this is the angle it is put back at
|
|
53
|
+
const MAX_VY = 0.5 * SCALE;
|
|
54
|
+
const YOU_STEP = 1; // a keypress, not a tick — the same either way
|
|
28
55
|
|
|
29
56
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
30
57
|
|
|
@@ -35,7 +62,7 @@ export function serve(state, toward) {
|
|
|
35
62
|
y: HEIGHT / 2,
|
|
36
63
|
vx: toward * SERVE_SPEED,
|
|
37
64
|
// 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),
|
|
65
|
+
vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2) * SCALE,
|
|
39
66
|
};
|
|
40
67
|
return state;
|
|
41
68
|
}
|
|
@@ -52,10 +79,10 @@ const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5;
|
|
|
52
79
|
function returned(ball, top, dir) {
|
|
53
80
|
const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2);
|
|
54
81
|
ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06);
|
|
55
|
-
ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -
|
|
82
|
+
ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -MAX_VY, MAX_VY);
|
|
56
83
|
// Never let a rally go flat. A ball with no angle is one the machine can park
|
|
57
84
|
// in front of forever, and a rally that cannot end is not a game.
|
|
58
|
-
if (Math.abs(ball.vy) <
|
|
85
|
+
if (Math.abs(ball.vy) < FLAT) ball.vy = (ball.vy < 0 ? -1 : 1) * NUDGE;
|
|
59
86
|
return ball;
|
|
60
87
|
}
|
|
61
88
|
|
|
@@ -102,7 +129,7 @@ export const PONG = {
|
|
|
102
129
|
title: "PONG",
|
|
103
130
|
blurb: "first to seven, and the angle is all in where you hit it",
|
|
104
131
|
keys: "↑ ↓ move · q quit",
|
|
105
|
-
tickMs:
|
|
132
|
+
tickMs: TICK_MS,
|
|
106
133
|
|
|
107
134
|
create({ rng = Math.random } = {}) {
|
|
108
135
|
const state = {
|
package/src/games.mjs
CHANGED
|
@@ -194,9 +194,20 @@ const ESC = {
|
|
|
194
194
|
hideCursor: "\x1b[?25l",
|
|
195
195
|
showCursor: "\x1b[?25h",
|
|
196
196
|
up: (n) => (n > 0 ? `\x1b[${n}A` : ""),
|
|
197
|
+
down: (n) => (n > 0 ? `\x1b[${n}B` : ""),
|
|
197
198
|
eraseDown: "\x1b[0J",
|
|
199
|
+
eraseLine: "\x1b[K",
|
|
198
200
|
};
|
|
199
201
|
|
|
202
|
+
/**
|
|
203
|
+
* How many ticks a late clock is allowed to make up in one go.
|
|
204
|
+
*
|
|
205
|
+
* Enough to ride out a garbage collection or a busy event loop without the game
|
|
206
|
+
* quietly running slow; small enough that a laptop coming back from sleep
|
|
207
|
+
* resumes the game rather than fast-forwarding the ball across the board.
|
|
208
|
+
*/
|
|
209
|
+
const CATCH_UP = 4;
|
|
210
|
+
|
|
200
211
|
/**
|
|
201
212
|
* Play one game until `q`.
|
|
202
213
|
*
|
|
@@ -214,46 +225,115 @@ export async function runGame(game, deps = {}) {
|
|
|
214
225
|
// turn, deterministically, with no timers left running after the assertion.
|
|
215
226
|
setTimer = (fn, ms) => setTimeout(fn, ms),
|
|
216
227
|
clearTimer = (t) => clearTimeout(t),
|
|
228
|
+
// The clock the tick deadline is measured against. Injectable for the same
|
|
229
|
+
// reason the timer is: a test should be able to say what time it is.
|
|
230
|
+
now = () => Date.now(),
|
|
217
231
|
} = deps;
|
|
218
232
|
|
|
219
233
|
const ctx = { rng };
|
|
220
234
|
let state = game.create(ctx);
|
|
221
|
-
let height = 0;
|
|
222
235
|
let timer = null;
|
|
223
236
|
let closed = false;
|
|
224
237
|
|
|
238
|
+
/** The lines currently on the screen, so a redraw can write only the changes. */
|
|
225
239
|
let painted = null;
|
|
226
240
|
const draw = () => {
|
|
227
241
|
if (closed) return;
|
|
228
|
-
const
|
|
242
|
+
const lines = frame({
|
|
229
243
|
title: game.title,
|
|
230
244
|
status: game.status(state),
|
|
231
245
|
rows: game.render(state),
|
|
232
246
|
keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys,
|
|
233
|
-
});
|
|
247
|
+
}).split("\n");
|
|
248
|
+
|
|
234
249
|
// A frame identical to the one already on the screen is not written at all.
|
|
235
250
|
// Chess idles on its clock while it is your move, and repainting the same
|
|
236
251
|
// board twice a second is exactly the flicker that would make it feel busy.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
// It is also what lets the fast games tick at 60Hz for free: a ball that has
|
|
253
|
+
// not crossed into a new cell yet produces the same frame, and the same
|
|
254
|
+
// frame costs nothing.
|
|
255
|
+
const same = painted?.length === lines.length && lines.every((l, i) => l === painted[i]);
|
|
256
|
+
if (same) return;
|
|
257
|
+
|
|
258
|
+
if (!painted || painted.length !== lines.length) {
|
|
259
|
+
// First frame, or one that changed height: there is nothing to diff
|
|
260
|
+
// against, so clear what was there and lay the whole thing down.
|
|
261
|
+
output.write(`${ESC.up(painted?.length ?? 0)}${ESC.eraseDown}${lines.join("\n")}\n`);
|
|
262
|
+
} else {
|
|
263
|
+
// Only the rows that actually changed are written. A pong ball crossing a
|
|
264
|
+
// cell touches two rows out of twenty-one, and repainting the other
|
|
265
|
+
// nineteen is both the blink you can see — a full repaint has to erase
|
|
266
|
+
// first, and for that instant the board is not there — and, over the
|
|
267
|
+
// pit's socket, twenty times the bytes standing between a tick and a
|
|
268
|
+
// moved ball. Rows are skipped with a cursor-down rather than a newline
|
|
269
|
+
// so that a frame sitting at the bottom of the screen cannot scroll it.
|
|
270
|
+
let out = ESC.up(lines.length);
|
|
271
|
+
let row = 0;
|
|
272
|
+
for (let i = 0; i < lines.length; i++) {
|
|
273
|
+
if (lines[i] === painted[i]) continue;
|
|
274
|
+
out += `${ESC.down(i - row)}\r${lines[i]}${ESC.eraseLine}`;
|
|
275
|
+
row = i;
|
|
276
|
+
}
|
|
277
|
+
output.write(`${out}${ESC.down(lines.length - row)}\r`);
|
|
278
|
+
}
|
|
279
|
+
painted = lines;
|
|
241
280
|
};
|
|
242
281
|
|
|
282
|
+
const tickMs = () => (typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs);
|
|
243
283
|
const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } };
|
|
244
|
-
|
|
284
|
+
|
|
285
|
+
/** When the next tick is due. Null means the clock is not running. */
|
|
286
|
+
let dueAt = null;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Arm the clock for the next tick, on its own deadline.
|
|
290
|
+
*
|
|
291
|
+
* Sleeping a full period from the moment the last tick *finished* is how a
|
|
292
|
+
* game ends up running slower than the rate it asked for: the work and the
|
|
293
|
+
* timer's own overshoot are added to every period, and the error accumulates.
|
|
294
|
+
* Counting from a deadline instead keeps the ball on real time.
|
|
295
|
+
*/
|
|
296
|
+
const arm = () => {
|
|
245
297
|
stop();
|
|
246
|
-
if (!game.tickMs || state.over) return;
|
|
247
|
-
|
|
248
|
-
timer = setTimer((
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
298
|
+
if (!game.tickMs || state.over || closed) return;
|
|
299
|
+
if (dueAt === null) dueAt = now() + tickMs();
|
|
300
|
+
timer = setTimer(onTick, Math.max(0, dueAt - now()));
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* What a keypress is allowed to do to the clock: bring the next tick nearer,
|
|
305
|
+
* never push it away.
|
|
306
|
+
*
|
|
307
|
+
* Both halves matter. Chess runs its clock slowly while it is your move and
|
|
308
|
+
* quickly once it is the engine's, so the move you just made has to be able to
|
|
309
|
+
* pull the next tick forward or the reply arrives a beat late. But a key that
|
|
310
|
+
* could push the tick back is how the ball used to stall: a held arrow arrives
|
|
311
|
+
* as a burst of repeats, and re-arming on each one kept the next tick
|
|
312
|
+
* permanently a full period away, for as long as you held the key down.
|
|
313
|
+
*/
|
|
314
|
+
const nudge = () => {
|
|
315
|
+
if (!game.tickMs || state.over || closed) return;
|
|
316
|
+
const soonest = now() + tickMs();
|
|
317
|
+
if (timer !== null && dueAt !== null && dueAt <= soonest) return;
|
|
318
|
+
dueAt = soonest;
|
|
319
|
+
arm();
|
|
255
320
|
};
|
|
256
321
|
|
|
322
|
+
function onTick() {
|
|
323
|
+
timer = null;
|
|
324
|
+
if (closed || state.over) return;
|
|
325
|
+
const ms = tickMs();
|
|
326
|
+
// One step for the tick that just came due, plus any whole ticks the event
|
|
327
|
+
// loop was too busy to deliver, so a stall shows up as a jump rather than
|
|
328
|
+
// as the whole game quietly slowing down and speeding back up.
|
|
329
|
+
const late = Math.max(0, now() - dueAt);
|
|
330
|
+
const steps = Math.min(CATCH_UP, 1 + Math.floor(late / ms));
|
|
331
|
+
dueAt += steps * ms;
|
|
332
|
+
for (let i = 0; i < steps && !state.over; i++) state = game.tick(state, ctx) || state;
|
|
333
|
+
draw();
|
|
334
|
+
arm();
|
|
335
|
+
}
|
|
336
|
+
|
|
257
337
|
const wasRaw = Boolean(input.isRaw);
|
|
258
338
|
const restore = () => {
|
|
259
339
|
if (closed) return;
|
|
@@ -271,16 +351,20 @@ export async function runGame(game, deps = {}) {
|
|
|
271
351
|
if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
|
|
272
352
|
if (key === "r" && (state.over || game.restartable !== false)) {
|
|
273
353
|
state = game.create(ctx);
|
|
354
|
+
dueAt = null; // a new game starts its clock from now, not from the old one
|
|
274
355
|
draw();
|
|
275
|
-
|
|
356
|
+
arm();
|
|
276
357
|
continue;
|
|
277
358
|
}
|
|
278
359
|
if (state.over) continue; // a finished board takes r and q, nothing else
|
|
279
360
|
state = game.onKey(state, key, ctx) || state;
|
|
280
361
|
draw();
|
|
281
|
-
// A key can end a real-time game
|
|
282
|
-
//
|
|
283
|
-
if (game.tickMs)
|
|
362
|
+
// A key can end a real-time game — a hard drop into the ceiling — so the
|
|
363
|
+
// clock is stopped when that happens. Otherwise see `nudge`.
|
|
364
|
+
if (game.tickMs) {
|
|
365
|
+
if (state.over) stop();
|
|
366
|
+
else nudge();
|
|
367
|
+
}
|
|
284
368
|
}
|
|
285
369
|
}
|
|
286
370
|
|
|
@@ -296,7 +380,7 @@ export async function runGame(game, deps = {}) {
|
|
|
296
380
|
process.on("SIGTERM", onSignal);
|
|
297
381
|
|
|
298
382
|
draw();
|
|
299
|
-
|
|
383
|
+
arm();
|
|
300
384
|
await done;
|
|
301
385
|
restore();
|
|
302
386
|
process.off("SIGINT", onSignal);
|