Something went wrong. Try again.
Simple atproto game, 2048 tiles with the avatars of your friends
Something went wrong. Try again.
12 kB · 389 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390import type { Direction, GameState, MoveResult, NewGameOptions, Position, Tile,} from './types.js'import { nextInt, randomSeed } from './rng.js'
const DEFAULT_SIZE = 4const DEFAULT_WINNING_VALUE = 2048const DEFAULT_STARTING_TILES = 2/** * What a spawn may produce, drawn uniformly. Three 2s to one 4, so a 4 turns up * a quarter of the time rather than the tenth that standard 2048 uses. * * Not a tuning choice. It is what the engine behind `blue.2048.game` does, and * a recording only replays correctly against the exact spawn rule that produced * it. The game is faster and luckier for it: value accumulates quicker per * square of board, so 2048 arrives sooner and games run shorter. */const SPAWN_VALUES = [2, 2, 2, 4] as const
/** * Pure 2048 rules. Nothing here touches the DOM, the clock or `Math.random`, * so a game is a value: same seed plus same moves always gives the same board. * * Every function returns a new state and never mutates its input, which is what * makes undo a one-liner in the caller — keep the previous state and put it * back. The engine deliberately owns no undo stack of its own. */
/** Row/column deltas for each direction. */const VECTORS: Record<Direction, Position> = { up: { row: -1, col: 0 }, right: { row: 0, col: 1 }, down: { row: 1, col: 0 }, left: { row: 0, col: -1 },}
type Grid = (Tile | null)[][]
function emptyGrid(size: number): Grid { return Array.from({ length: size }, () => Array.from({ length: size }, () => null))}
function toGrid(state: GameState): Grid { const grid = emptyGrid(state.size) for (const tile of state.tiles) { const row = grid[tile.row] if (row) row[tile.col] = tile } return grid}
function cellAt(grid: Grid, pos: Position): Tile | null | undefined { return grid[pos.row]?.[pos.col]}
function withinBounds(size: number, pos: Position): boolean { return pos.row >= 0 && pos.row < size && pos.col >= 0 && pos.col < size}
function emptyCells(grid: Grid): Position[] { const cells: Position[] = [] for (let row = 0; row < grid.length; row++) { const cols = grid[row] if (!cols) continue for (let col = 0; col < cols.length; col++) { if (cols[col] === null) cells.push({ row, col }) } } return cells}
/** * Order in which cells must be visited for a given direction: always start from * the edge the tiles are moving towards, so the tile nearest the wall settles * first and later tiles stack behind it. */function traversalOrder(size: number, direction: Direction): Position[] { const rows = [...Array(size).keys()] const cols = [...Array(size).keys()] if (direction === 'down') rows.reverse() if (direction === 'right') cols.reverse()
const order: Position[] = [] for (const row of rows) { for (const col of cols) order.push({ row, col }) } return order}
/** * Walk from `from` along `vector` while the next cell is empty. Returns the * last empty cell reached (`farthest`) and the cell just past it (`next`), which * is the merge candidate. */function findFarthestPosition( grid: Grid, size: number, from: Position, vector: Position,): { farthest: Position; next: Position } { let previous: Position = from let cell: Position = { row: from.row + vector.row, col: from.col + vector.col }
while (withinBounds(size, cell) && cellAt(grid, cell) === null) { previous = cell cell = { row: cell.row + vector.row, col: cell.col + vector.col } }
return { farthest: previous, next: cell }}
function spawnTile( grid: Grid, rngState: number, nextTileId: number,): { tile: Tile | null; rngState: number; nextTileId: number } { const cells = emptyCells(grid) if (cells.length === 0) return { tile: null, rngState, nextTileId }
const pick = nextInt(rngState, cells.length) const cell = cells[pick.value] if (!cell) return { tile: null, rngState: pick.state, nextTileId }
// Two draws per spawn, cell first then value, because that is the order the // shared format's engine uses and the order decides every later spawn. const roll = nextInt(pick.state, SPAWN_VALUES.length) const value = SPAWN_VALUES[roll.value] ?? 2
const tile: Tile = { id: nextTileId, value, row: cell.row, col: cell.col, previous: null, mergedFrom: null, }
const row = grid[cell.row] if (row) row[cell.col] = tile
return { tile, rngState: roll.state, nextTileId: nextTileId + 1 }}
/** True if any move in any direction would change the board. */function hasAvailableMoves(grid: Grid, size: number): boolean { for (let row = 0; row < size; row++) { for (let col = 0; col < size; col++) { const tile = cellAt(grid, { row, col }) if (!tile) return true // Only need to look right and down; left and up are covered by symmetry. for (const vector of [VECTORS.right, VECTORS.down]) { const neighbour = cellAt(grid, { row: row + vector.row, col: col + vector.col }) if (neighbour && neighbour.value === tile.value) return true } } } return false}
function gridTiles(grid: Grid): Tile[] { const tiles: Tile[] = [] for (const row of grid) { for (const cell of row) { if (cell) tiles.push(cell) } } return tiles}
export function createGame(options: NewGameOptions = {}): GameState { const size = options.size ?? DEFAULT_SIZE const seed = options.seed ?? randomSeed() const winningValue = options.winningValue ?? DEFAULT_WINNING_VALUE const startingTiles = options.startingTiles ?? DEFAULT_STARTING_TILES
if (size < 2) throw new RangeError(`Board size must be at least 2, got ${size}`)
const grid = emptyGrid(size) let rngState = seed let nextTileId = 1
for (let i = 0; i < startingTiles; i++) { const spawned = spawnTile(grid, rngState, nextTileId) rngState = spawned.rngState nextTileId = spawned.nextTileId }
const tiles = gridTiles(grid)
return { size, tiles, score: 0, moves: 0, won: tiles.some((tile) => tile.value >= winningValue), over: !hasAvailableMoves(grid, size), winningValue, seed, rngState, nextTileId, }}
/** * Apply one move. Returns `moved: false` and the state untouched when nothing * would change — no tile is spawned and the move counter does not advance, so a * swipe into a wall costs the player nothing. */export function move(state: GameState, direction: Direction): MoveResult { if (state.over) { return { state, moved: false, gained: 0, merged: [] } }
const { size } = state const vector = VECTORS[direction] const grid = toGrid(state)
// Tiles produced by a merge this move. A tile may only merge once per move, // so these are excluded as merge candidates for the rest of the pass. const mergedThisMove = new Set<number>() const merged: number[] = [] let gained = 0 let moved = false
// Strip last move's animation metadata before recomputing it. for (const position of traversalOrder(size, direction)) { const tile = cellAt(grid, position) if (!tile) continue const row = grid[position.row] if (row) { row[position.col] = { ...tile, previous: null, mergedFrom: null, } } }
for (const position of traversalOrder(size, direction)) { const tile = cellAt(grid, position) if (!tile) continue
const { farthest, next } = findFarthestPosition(grid, size, position, vector) const target = withinBounds(size, next) ? cellAt(grid, next) : null
if (target && target.value === tile.value && !mergedThisMove.has(target.id)) { const value = tile.value * 2 const mergedTile: Tile = { id: state.nextTileId + merged.length, value, row: next.row, col: next.col, previous: null, // Both sources keep their pre-move coordinates. The renderer animates // each from its own row/col to the merged tile's row/col, then swaps. mergedFrom: [tile, target], }
const sourceRow = grid[position.row] if (sourceRow) sourceRow[position.col] = null const targetRow = grid[next.row] if (targetRow) targetRow[next.col] = mergedTile
mergedThisMove.add(mergedTile.id) merged.push(value) gained += value moved = true continue }
if (farthest.row !== position.row || farthest.col !== position.col) { const sourceRow = grid[position.row] if (sourceRow) sourceRow[position.col] = null const destRow = grid[farthest.row] if (destRow) { destRow[farthest.col] = { ...tile, row: farthest.row, col: farthest.col, previous: { row: position.row, col: position.col }, mergedFrom: null, } } moved = true } }
if (!moved) { return { state, moved: false, gained: 0, merged: [] } }
let nextTileId = state.nextTileId + merged.length const spawned = spawnTile(grid, state.rngState, nextTileId) nextTileId = spawned.nextTileId
const tiles = gridTiles(grid) const score = state.score + gained
return { state: { ...state, tiles, score, moves: state.moves + 1, won: state.won || tiles.some((tile) => tile.value >= state.winningValue), over: !hasAvailableMoves(grid, size), rngState: spawned.rngState, nextTileId, }, moved: true, gained, merged, }}
/** True when at least one direction would change the board. */export function canMove(state: GameState): boolean { return hasAvailableMoves(toGrid(state), state.size)}
/** The highest tile value currently on the board. 0 for an empty board. */export function highestTile(state: GameState): number { return state.tiles.reduce((max, tile) => (tile.value > max ? tile.value : max), 0)}
/** * Build a state from a literal grid of values, 0 meaning empty. * * For tests and for replaying a stored board. Spawning stays seeded, so a game * resumed this way still behaves deterministically from here on. */export function createGameFromGrid( values: readonly (readonly number[])[], options: Omit<NewGameOptions, 'size' | 'startingTiles'> & { score?: number } = {},): GameState { const size = values.length if (size < 2) throw new RangeError(`Board size must be at least 2, got ${size}`) for (const row of values) { if (row.length !== size) { throw new RangeError(`Grid must be square; expected ${size} columns, got ${row.length}`) } }
const seed = options.seed ?? randomSeed() const winningValue = options.winningValue ?? DEFAULT_WINNING_VALUE
const grid = emptyGrid(size) let nextTileId = 1 for (let row = 0; row < size; row++) { for (let col = 0; col < size; col++) { const value = values[row]?.[col] ?? 0 if (value === 0) continue const target = grid[row] if (target) { target[col] = { id: nextTileId++, value, row, col, previous: null, mergedFrom: null } } } }
const tiles = gridTiles(grid)
return { size, tiles, score: options.score ?? 0, moves: 0, won: tiles.some((tile) => tile.value >= winningValue), over: !hasAvailableMoves(grid, size), winningValue, seed, rngState: seed, nextTileId, }}
/** Board as a plain number grid. For tests, debugging and the plain-numbers mode. */export function toValueGrid(state: GameState): number[][] { const grid = emptyGrid(state.size) for (const tile of state.tiles) { const row = grid[tile.row] if (row) row[tile.col] = tile } return grid.map((row) => row.map((cell) => cell?.value ?? 0))}