Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions src/games-breakout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// decides the angle it leaves at, so the paddle is a steering wheel rather than
// a wall. Without that you cannot dig a channel up the side of the wall, and
// digging a channel is the entire reason anybody still plays this.
import { ballCell } from "./games-draw.mjs";
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
import { acid, amber, bone, danger, rgb } from "./ui.mjs";

export const WIDTH = 40;
Expand All @@ -17,7 +17,11 @@ export const BRICK_TOP = 1;

export const PADDLE_W = 7;
export const PADDLE_ROW = HEIGHT - 1;
const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
// A keypress, not a tick, so this is unchanged by the tick rate — but it does
// have to keep up with the ball, and a ball taken off the end of the paddle now
// crosses a column in under two ticks. Three columns a press stays ahead of it
// at a terminal's key-repeat rate; two only just did.
const PADDLE_STEP = 3;

/**
* How often the wall is stepped. See the note in games-pong.mjs: the ball can
Expand All @@ -32,8 +36,20 @@ const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
*/
export const TICK_MS = 16;

/**
* How hard the wall is played, against the pace it was first tuned at.
*
* Launched, the old ball took three seconds to cross the board and two and a
* half to fall the height of it, which is a slow enough ball that you can put
* the paddle under it and go and make a cup of tea. It also meant the drawn
* ball moved twenty times a second, and twenty steps a second does not read as
* travel however even they are. Everything below scales together, so a ball off
* the end of the paddle leaves at the angle it always did.
*/
const PACE = 1.9;

/** The speeds below are still written per 50ms, the rate this was tuned at. */
const SCALE = TICK_MS / 50;
const SCALE = (TICK_MS / 50) * PACE;

const LIVES = 3;
const BASE_VX = 0.62 * SCALE;
Expand Down Expand Up @@ -67,6 +83,7 @@ export function brickAt(wall, x, y) {
function rest(state) {
state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 };
state.stuck = true;
state.drawn = drawnBall(state.ball.x, state.ball.y);
return state;
}

Expand All @@ -82,8 +99,12 @@ export function launch(state) {
export function step(state) {
if (state.stuck) {
// A ball that has not been launched rides the paddle, so moving before you
// serve aims the serve.
// serve aims the serve. It is carried rather than travelling, so it is put
// where the paddle is rather than paced there — a stationary ball earns no
// steps, and would otherwise sit still while the paddle slid out from under
// it.
state.ball.x = state.paddle + PADDLE_W / 2;
snapBall(state.drawn, state.ball.x, state.ball.y);
return state;
}

Expand Down Expand Up @@ -122,11 +143,16 @@ export function step(state) {
}
}

advanceBall(state.drawn, ball);

if (ball.y > PADDLE_ROW) {
state.lives--;
if (state.lives <= 0) {
state.lives = 0;
state.over = `out of balls · ${state.score} points`;
// The last ball is left where it went, below the board and so off it,
// rather than resting on the row it fell past.
snapBall(state.drawn, ball.x, ball.y);
return state;
}
rest(state);
Expand Down Expand Up @@ -198,9 +224,9 @@ export const BREAKOUT = {
}

for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
// Drawn on half-rows, so the ball steps the same distance down the wall as
// it does across it. See games-draw.mjs.
const ball = ballCell(state.ball.x, state.ball.y);
// Drawn on half-rows and on its own even clock, so the ball steps the same
// distance down the wall as across it, and at a rate. See games-draw.mjs.
const ball = drawnCell(state.drawn);
put(ball.col, ball.row, (state.stuck ? amber : bone)(ball.glyph));

return grid.map((row) => row.map((cell) => cell ?? " ").join(""));
Expand Down
91 changes: 78 additions & 13 deletions src/games-draw.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,92 @@
// one, and the corner it turns is half as wide. It is also a better ball than
// `●` was — a square pixel moving on a square grid, rather than a round dot
// snapping between cells twice its own height apart.
//
// Half blocks fix the size of the steps. They do not fix when the steps happen,
// which turned out to be the other half of it — see `drawnBall` below.

/**
* Where to draw a ball whose true position is (x, y) in board coordinates.
* The ball's position in half-rows — the unit it is actually drawn in.
*
* `y` is a row centre, so row r covers y from r - 0.5 up to r + 0.5: below the
* centre the ball is in the top half of the cell, at or above it the bottom.
* Returns the cell to write into and the half block to write there.
* centre the ball is in the top half of that cell, at or above it the bottom.
* Half-rows and columns are the same size on screen, so this and the column
* together are a square lattice, and a step is a step whichever way it goes.
*/
export function ballCell(x, y) {
const col = Math.round(x);
export const halfRow = (y) => {
const row = Math.round(y);
return { col, row, glyph: y < row ? "▀" : "▄" };
return y < row ? row * 2 : row * 2 + 1;
};

/**
* A drawn ball, released on its own even clock.
*
* Equal pitch fixed the size of the ball's steps but not their timing, and the
* timing is the rest of the jiggle. Rounding the true position to the lattice
* moves the ball whenever it happens to cross a column edge or a half-row edge,
* and those two are on unrelated schedules: a ball with the two periods close
* but not equal — which is what a near-diagonal is, and what breakout launches
* at — beats between them. Measured, breakout held a cell for 16ms, then 80ms,
* then 48ms, five times a second. The steps were the right size and still the
* ball looked like it was struggling, because nothing was moving at a rate.
*
* So the true position is not what is drawn. It decides only which way the
* drawn ball owes a step; when that step is paid is decided by a clock that
* ticks at the ball's own speed. `owed` accrues at |vx| + 2|vy| lattice units
* per tick — the distance the true ball covers, measured the way the drawn one
* has to travel it — and a whole unit buys one step. The drawn ball therefore
* moves every 1/speed ticks whatever angle it is on, trailing the true one by
* under a unit, which is under half a character.
*
* It cannot fall behind: the same accrual that paces the ball also lets it pay
* two steps in a tick when the ball is genuinely moving that fast.
*/
export function drawnBall(x, y) {
return snapBall({ col: 0, half: 0, owed: 0 }, x, y);
}

/**
* The ball's position in half-rows — the unit it is actually drawn in.
* Put the drawn ball exactly where the real one is, with no debt either way.
*
* Only the tests use this, to assert that a step down the board is the same
* size as a step across it.
* For the moves that are not travel and so have nothing to smooth: a serve, a
* fresh ball on the paddle, the ball riding a paddle that is being aimed.
*/
export const halfRow = (y) => {
const row = Math.round(y);
return y < row ? row * 2 : row * 2 + 1;
};
export function snapBall(drawn, x, y) {
drawn.col = Math.round(x);
drawn.half = halfRow(y);
drawn.owed = 0;
return drawn;
}

/** Far enough apart that the ball was put there rather than travelled there. */
const TELEPORT = 4;

/** Pay out whatever steps the ball has earned this tick. */
export function advanceBall(drawn, ball) {
const col = Math.round(ball.x);
const half = halfRow(ball.y);
if (Math.abs(col - drawn.col) + Math.abs(half - drawn.half) >= TELEPORT) return snapBall(drawn, ball.x, ball.y);

drawn.owed += Math.abs(ball.vx) + Math.abs(ball.vy) * 2;
while (drawn.owed >= 1) {
const dcol = col - drawn.col;
const dhalf = half - drawn.half;
if (dcol === 0 && dhalf === 0) break;
// Whichever axis is further behind goes first, which is what keeps a
// diagonal a staircase instead of a sideways run and then a drop.
if (Math.abs(dcol) >= Math.abs(dhalf)) drawn.col += Math.sign(dcol);
else drawn.half += Math.sign(dhalf);
drawn.owed -= 1;
}
// A ball that has caught up banks at most one step, so that standing still
// for a moment cannot be turned into a lurch later.
if (drawn.owed > 1) drawn.owed = 1;
return drawn;
}

/** The cell and half block to write for a drawn ball. */
export const drawnCell = (drawn) => ({
col: drawn.col,
row: drawn.half >> 1,
glyph: drawn.half % 2 === 0 ? "▀" : "▄",
});
30 changes: 25 additions & 5 deletions src/games-pong.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// A flat return it will always get; one taken off the end of your paddle it will
// not. That is the whole game, and it is why the angle off the paddle depends on
// where the ball hit it.
import { ballCell } from "./games-draw.mjs";
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
import { acid, bone, danger, dim } from "./ui.mjs";

export const WIDTH = 44;
Expand Down Expand Up @@ -37,13 +37,25 @@ export const TARGET = 7; // first to this many
*/
export const TICK_MS = 16;

/**
* How hard the table is played, against the pace it was first tuned at.
*
* The old pace put the ball across the table in just under three seconds. That
* is not a ball being hit, it is a ball being carried, and it was also why the
* drawn ball only moved twenty times a second — too few steps for any of them
* to be smooth. Everything below is scaled by this, the machine along with the
* ball, so the balance is exactly the one that was tuned; only the clock it is
* played against changes.
*/
const PACE = 1.9;

/**
* The speeds below are still written per 55ms — the rate this game was tuned
* at — and scaled to the tick. Keeping the tuned numbers legible matters more
* than saving a multiply: they are what makes the machine beatable off the end
* of the paddle and not from the middle, and that balance is the game.
*/
const SCALE = TICK_MS / 55;
const SCALE = (TICK_MS / 55) * PACE;

const SERVE_SPEED = 0.85 * SCALE;
const MAX_SPEED = 1.7 * SCALE;
Expand All @@ -65,6 +77,8 @@ export function serve(state, toward) {
// Never dead flat: a ball with no angle is a rally nobody can lose.
vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2) * SCALE,
};
// A serve is a ball put on the table, not a ball that travelled there.
state.drawn = drawnBall(state.ball.x, state.ball.y);
return state;
}

Expand Down Expand Up @@ -106,6 +120,12 @@ export function step(state) {

if (ball.x < 0) { state.theirs++; point(state, 1); }
else if (ball.x > WIDTH - 1) { state.yours++; point(state, -1); }
// Anything else is the ball travelling, which is the only thing the drawn
// ball is asked to follow — a serve puts it back itself.
else advanceBall(state.drawn, ball);
// A match ends with the ball where it went out, off the table and so off the
// board, rather than parked on the edge it left by.
if (state.over) snapBall(state.drawn, ball.x, ball.y);

// The machine: idle in the middle until the ball is on its half, then chase
// the ball's row. Perfect tracking here would make the game unloseable for it,
Expand Down Expand Up @@ -165,9 +185,9 @@ export const PONG = {

for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█"));
for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█"));
// Drawn on half-rows, so the ball steps the same distance up the table as
// it does across it. See games-draw.mjs.
const ball = ballCell(state.ball.x, state.ball.y);
// Drawn on half-rows and on its own even clock, so the ball steps the same
// distance up the table as across it, and at a rate. See games-draw.mjs.
const ball = drawnCell(state.drawn);
put(ball.col, ball.row, bone(ball.glyph));

return grid.map((row, y) => row.map((cell, x) => (
Expand Down
71 changes: 66 additions & 5 deletions test/games.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ import {
import {
BREAKOUT, BRICK_ROWS, BRICK_COLS, BRICK_TOP, BRICK_W, PADDLE_W, PADDLE_ROW, ROW_POINTS,
brickAt, bricksLeft, buildWall, WIDTH as WIDTH_B, HEIGHT as HEIGHT_B,
TICK_MS as BREAKOUT_TICK_MS,
} from "../src/games-breakout.mjs";
import {
PONG, PADDLE, YOU_COL, TARGET as PONG_TARGET, WIDTH as WIDTH_P, HEIGHT as HEIGHT_P,
TICK_MS as PONG_TICK_MS,
} from "../src/games-pong.mjs";
import { ballCell, halfRow } from "../src/games-draw.mjs";
import { drawnBall, drawnCell, halfRow } from "../src/games-draw.mjs";
import {
TANK, TARGET as TANK_TARGET, drive as driveTank, isWall as isYardWall, lineOfSight, quarterTurn, stepToward,
WIDTH as WIDTH_T, HEIGHT as HEIGHT_T,
Expand Down Expand Up @@ -1249,10 +1250,12 @@ test("the breakout board is drawn to size", () => {

/* --------------------------------------------------------------- the ball */

const cellAt = (x, y) => drawnCell(drawnBall(x, y));

test("a ball above the middle of its row is drawn in the top half of it", () => {
assert.deepEqual(ballCell(4, 8), { col: 4, row: 8, glyph: "▄" });
assert.deepEqual(ballCell(4, 7.7), { col: 4, row: 8, glyph: "▀" });
assert.deepEqual(ballCell(4, 8.3), { col: 4, row: 8, glyph: "▄" });
assert.deepEqual(cellAt(4, 8), { col: 4, row: 8, glyph: "▄" });
assert.deepEqual(cellAt(4, 7.7), { col: 4, row: 8, glyph: "▀" });
assert.deepEqual(cellAt(4, 8.3), { col: 4, row: 8, glyph: "▄" });
// The halves meet at the row centre and the cell at its edges, with no gap
// and no row that two different heights round into the wrong way.
assert.equal(halfRow(7.6) + 1, halfRow(8.0), "the two halves of a row are adjacent");
Expand Down Expand Up @@ -1292,12 +1295,70 @@ test("a ball crossing a row is drawn twice on the way", () => {
// falling one whole row passes through two drawn positions, not one.
const seen = new Set();
for (let y = 7.5; y < 8.5; y += 0.05) {
const { row, glyph } = ballCell(3, y);
const { row, glyph } = cellAt(3, y);
seen.add(`${row}${glyph}`);
}
assert.equal(seen.size, 2, "a row is two steps tall, not one");
});

/**
* Play a game and report every move the *drawn* ball made: how far it went, and
* how many ticks it had stood still first.
*/
function drawnMoves(game, state, ticks, act = () => {}) {
const moves = [];
let previous = null;
let last = 0;
for (let t = 0; t < ticks && !state.over; t++) {
act(state);
state = game.tick(state) || state;
if (state.over) break;
const at = { col: state.drawn.col, half: state.drawn.half };
if (previous && (at.col !== previous.col || at.half !== previous.half)) {
const jump = Math.hypot(at.col - previous.col, at.half - previous.half);
// A serve puts the ball back in the middle; that is not a step.
if (jump < 4) moves.push({ jump, waited: t - last });
last = t;
}
previous = at;
}
return moves;
}

test("the drawn ball moves at a rate, not whenever it happens to cross a line", () => {
// The two fixes before this one got the ball's steps to the right size and
// still left it looking like it was struggling, because the steps were not
// evenly spaced: rounding the true position moves the ball when it crosses a
// column edge or a half-row edge, and those are on unrelated schedules. Pong
// stepped after 16ms, then 64ms; breakout ran 16, 80, 48, five times a
// second. What is pinned here is the cadence, which is the thing that was
// actually wrong — see `drawnBall` in games-draw.mjs.
const cases = [
["pong", PONG, PONG_TICK_MS, () => PONG.create({ rng: seeded(9) }), () => {}],
["breakout", BREAKOUT, BREAKOUT_TICK_MS, () => BREAKOUT.create({ rng: seeded(4) }), (s) => { if (s.stuck) BREAKOUT.onKey(s, "space"); }],
];

for (const [name, game, tickMs, create, act] of cases) {
const moves = drawnMoves(game, create(), 6000, act);
assert.ok(moves.length > 100, `${name}: not enough of a rally to measure (${moves.length})`);

// One lattice unit at a time. A diagonal taken in a single frame is the
// lurch the old sampler produced whenever both crossings landed together.
const worst = Math.max(...moves.map((m) => m.jump));
assert.equal(worst, 1, `${name}: the ball jumped ${worst.toFixed(2)} units at once`);

// Nothing the eye can read as a stop. Three ticks is 48ms; the old sampler
// sat still for four and five.
const stalled = Math.max(...moves.map((m) => m.waited));
assert.ok(stalled <= 3, `${name}: the ball stood still for ${stalled * tickMs}ms`);

// And it has to be moving. Twenty steps a second — where both games were —
// is a ball being carried rather than hit.
const perSecond = 1000 / ((moves.reduce((n, m) => n + m.waited, 0) / moves.length) * tickMs);
assert.ok(perSecond > 35, `${name}: only ${perSecond.toFixed(1)} steps a second`);
}
});

/* ------------------------------------------------------------------- pong */

test("a serve is never dead flat", () => {
Expand Down