"use client";
/*
* The scenery, and the numbers the run is tuned against.
*
* Split out of the game itself, which was eleven hundred lines in one file and
* therefore not something anybody was going to read. Everything here is decided
* before a run starts and does not change during one: the constants that make a
* jump clear an obstacle, the glyph fields the parallax is made of, and the
* baking that turns each field into a repeating pattern.
*
* The division is not cosmetic. Nothing in this file knows the game is running.
* It builds scenery and hands it over, which is what makes it possible to look
* at the loop next door without a screenful of tuning constants above it.
*/
export const HEIGHT = 200;
export const GROUND = 38;
export const RUNNER = 30;
export const GRAVITY = 2600; // px/s², tuned so one jump clears the tallest obstacle.
export const JUMP = 790;
export const START_SPEED = 300;
export const MAX_SPEED = 720;
export type Kind = "pine" | "boulder" | "spire";
/* `seed` is fixed at spawn, so a shape's variation holds still as it crosses. */
export type Obstacle = { x: number; height: number; kind: Kind; seed: number };
/* "ready" is a still board waiting on Play, not a board about to start. */
export type Phase = "ready" | "running" | "over";
export type Player = { label: string; isX: boolean };
export const SKY_GLYPHS = [".", "·", "*", "+", "'", "`", "˙", "•"];
export const RIDGE_GLYPHS = ["^", "^", "^", "'", "ʌ", "Λ"];
export const GRIT_GLYPHS = [".", "-", "·", "_", ",", "˙"];
/*
* Each parallax layer is a strip drawn once into its own canvas and then
* repeated as a fill pattern. Laid out per frame it was a couple of hundred
* fillText calls sixty times a second for scenery that never changes; as a
* pattern it is one fillRect a layer, and the scroll is a translate.
*
* Widths are a whole number of columns, so the strip meets itself cleanly, and
* no two share a factor, so the three layers do not line up into one visible
* repeat. They are long for the same reason: at the speeds these run, a short
* strip comes round often enough to read as a loop rather than as scenery.
*
* A strip is only as tall as the band it draws in. The sky occupies the top
* quarter of the board and the grit a strip of floor two characters deep •
* storing either at the full height would be most of a megabyte of nothing,
* three times over, on a page whose whole job is to apologise for itself.
*/
export const LAYERS = {
sky: {
glyphs: SKY_GLYPHS,
columns: 44,
spread: 38,
alpha: 0.2,
speed: 0.2,
// Absolute, and independent of the floor: this band hangs from the top.
// Flush with it, because the tallest glyph at the highest baseline reaches
// the very top and a band that started lower would shave its cap off.
band: () => ({ top: 0, height: 100 }),
field: { bias: 1.7, sizes: [9, 10, 12, 12, 14] },
},
/* Scrub along the foot of the hills, between the ranges and the floor. */
ridge: {
glyphs: RIDGE_GLYPHS,
columns: 29,
spread: 40,
alpha: 0.38,
speed: 0.5,
band: (floor: number) => ({ top: floor - 26, height: 28 }),
field: { bias: 0.8, sizes: [10, 12, 12, 13] },
},
grit: {
glyphs: GRIT_GLYPHS,
columns: 37,
spread: 34,
alpha: 0.32,
speed: 1,
band: (floor: number) => ({ top: floor - 4, height: 26 }),
field: { bias: 1, sizes: [9, 10, 12] },
},
} as const;
/*
* The two silhouetted ranges behind everything.
*
* Far is large, faint and nearly still; near is smaller, firmer and moves
* three times as fast. That pairing is the whole trick of depth here • a
* single range at a single speed reads as wallpaper however it is drawn, and
* two of them read as distance without either needing a gradient or a second
* colour.
*
* Their spans (1632 and 1196) share no useful factor, so the pair does not
* come back round together into one recognisable skyline.
*/
export const RANGES = {
far: { count: 24, spread: 68, low: 34, high: 64, alpha: 0.075, speed: 0.05 },
near: { count: 26, spread: 46, low: 18, high: 40, alpha: 0.13, speed: 0.15 },
} as const;
/*
* The shard is drawn at a fixed width whatever its height, so its hitbox is a
* fixed width too. Scaling it with height the way the other two do would put
* the edge of the box six pixels clear of a tall one • which is a death with
* nothing on screen to explain it.
*/
export const SPIRE_HALF = 10.5;
export type LayerName = keyof typeof LAYERS;
/*
* A range, as one continuous ridgeline.
*
* It used to be a row of separate triangles sharing a baseline, which is what
* it looked like: overlapping cones rather than land. A single closed polygon
* running peak to valley to peak reads as one mass with a far side, and costs
* the same forty line segments to draw.
*
* Points alternate: even indices are summits, odd ones the saddles between
* them. `count` must be even, and the last point repeats the first, so the
* range meets itself when it wraps and there is no step at the seam.
*/
export function makeRidgeline(
count: number,
spread: number,
low: number,
high: number,
) {
const points = Array.from({ length: count }, (_, i) => ({
x: i * spread,
y:
i % 2 === 0
? low + Math.random() * (high - low)
: low * (0.2 + Math.random() * 0.35),
}));
points.push({ x: count * spread, y: points[0].y });
return { points, span: count * spread };
}
export type Ridgeline = ReturnType<typeof makeRidgeline>;
export function readInk() {
const styles = getComputedStyle(document.documentElement);
const get = (name: string) => styles.getPropertyValue(name).trim();
const ink = get("--text-primary") || "#101010";
/*
* globals.css raises its edge alphas in dark mode because a dark hairline
* disappears on near-black. The background here needs the opposite
* correction: white on #191919 carries much further than black on #fcfcfc,
* so at one set of alphas the light board came out anaemic and the dark one
* came out loud. One gain, applied to every background value, is what keeps
* the two boards the same picture.
*
* lib/theme.ts toggles this class for every resolved theme, the system one
* included, so it is the same answer the CSS is working from.
*/
const dark = document.documentElement.classList.contains("dark");
return {
ink,
quiet: get("--text-tertiary") || "#9a9a9a",
surface: get("--surface") || "#ffffff",
gain: dark ? 0.78 : 1,
};
}
/*
* A fixed field, generated once. Rolling new characters every frame would make
* the background shimmer rather than scroll, and the point of a parallax layer
* is that it is the same scenery seen from further away.
*
* Every speck also gets a weight and a size. A field at one alpha and one size
* is a screen door: the eye reads the grid rather than the depth. Varying both
* is free • it is baked into the strip once • and it is the difference between
* a texture and a sky.
*/
export function makeField(
count: number,
glyphs: readonly string[],
spread: number,
options: { bias?: number; sizes?: readonly number[] } = {},
) {
const { bias = 1, sizes = [12] } = options;
const pick = <T>(from: readonly T[]) =>
from[Math.floor(Math.random() * from.length)];
return Array.from({ length: count }, (_, i) => ({
x: i * spread + Math.random() * spread,
// Above 1 this crowds the near edge of the band, which is what gives the
// sky a dense roof thinning out towards the horizon rather than an even
// scatter from top to bottom.
y: Math.random() ** bias,
glyph: pick(glyphs),
size: pick(sizes),
weight: 0.4 + Math.random() * 0.6,
}));
}
export type Field = ReturnType<typeof makeField>;
/*
* One layer, baked. The colour is painted in, so the strips are rebuilt when
* the theme flips • which happens about as often as somebody clicks the theme
* toggle, rather than sixty times a second.
*
* Glyphs near either end are drawn a second time a strip-width away, so a
* character straddling the seam is whole on both sides of it.
*/
export function bakeLayer(
name: LayerName,
field: Field,
colour: string,
floor: number,
dpr: number,
) {
const { spread, columns } = LAYERS[name];
const stripWidth = columns * spread;
const band = LAYERS[name].band(floor);
const strip = document.createElement("canvas");
strip.width = Math.round(stripWidth * dpr);
strip.height = Math.round(band.height * dpr);
const paint = strip.getContext("2d");
if (!paint) return null;
paint.setTransform(dpr, 0, 0, dpr, 0, 0);
paint.fillStyle = colour;
paint.textAlign = "center";
paint.textBaseline = "alphabetic";
for (const item of field) {
// Board coordinates, then shifted into the strip's own space.
const y =
(name === "sky"
? 18 + item.y * 70
: name === "ridge"
? floor - 5 - item.y * 12
: floor + 9 + item.y * 8) - band.top;
// Baked in, so the variation costs nothing at draw time.
paint.globalAlpha = item.weight;
paint.font = `${item.size}px ui-monospace, SFMono-Regular, Menlo, monospace`;
paint.fillText(item.glyph, item.x, y);
// The wrap: whatever hangs off one end is redrawn onto the other.
if (item.x < 12) paint.fillText(item.glyph, item.x + stripWidth, y);
if (item.x > stripWidth - 12)
paint.fillText(item.glyph, item.x - stripWidth, y);
}
paint.globalAlpha = 1;
const pattern = paint.createPattern(strip, "repeat");
// The strip is stored at device resolution; the pattern is used in CSS
// pixels, so it is scaled back down or every glyph doubles in size.
pattern?.setTransform(new DOMMatrix().scale(1 / dpr, 1 / dpr));
return pattern ? { pattern, stripWidth, band } : null;
}