diff --git a/src/games-breakout.mjs b/src/games-breakout.mjs index 91a0b90..2df8bcf 100644 --- a/src/games-breakout.mjs +++ b/src/games-breakout.mjs @@ -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; @@ -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 @@ -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; @@ -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; } @@ -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; } @@ -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); @@ -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("")); diff --git a/src/games-draw.mjs b/src/games-draw.mjs index 7c5a934..382bd26 100644 --- a/src/games-draw.mjs +++ b/src/games-draw.mjs @@ -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 ? "▀" : "▄", +}); diff --git a/src/games-pong.mjs b/src/games-pong.mjs index f6d0263..417bdb5 100644 --- a/src/games-pong.mjs +++ b/src/games-pong.mjs @@ -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; @@ -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; @@ -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; } @@ -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, @@ -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) => ( diff --git a/test/games.test.mjs b/test/games.test.mjs index 3fc3982..fd57329 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -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, @@ -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"); @@ -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", () => {