[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423/** * view — pure state -> Op[] for the 2048 demo. * * Two distinct primitive showcases live here: * * - The board tiles are floating elements positioned at pixel-cell offsets and * keyed on their stable tile id. Changing a tile's (row, col) moves its * floating x/y, and the `transition` on `["position", "bg"]` lets the layout * engine interpolate the slide and recolor. Tiles are `square(...)` so they * read as squares despite the ~1:2 cell aspect ratio. * * - The surrounding chrome (New Game, Undo, board-size selector, the * game-over "Play again") is built from the `button` primitive and driven by * hover + focus state. */
import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts";import type { FocusRing } from "./primitives/focus.ts";import { focusedId } from "./primitives/focus.ts";import { square, squareCols } from "./primitives/square.ts";import { button } from "./primitives/button.ts";import type { GameState } from "./game.ts";
export const TILE_ROWS = 3;export const TILE_COLS = squareCols(TILE_ROWS); // 6export const GAP = 1;export const PAD = 1;
export const BOARD_SIZES = [3, 4, 5] as const;
const ROOT_BG = rgba(20, 20, 26);const STAT_BG = rgba(52, 52, 68);const BOARD_BG = rgba(48, 44, 60);const EMPTY_BG = rgba(64, 60, 78);const TITLE = rgba(255, 214, 110);const LABEL = rgba(170, 170, 190);const HINT_KEY = rgba(255, 214, 110);const HINT_TEXT = rgba(150, 150, 170);const OVERLAY_BG = rgba(16, 16, 22);const FPS_COLOR = rgba(120, 190, 140);const KEYCAP_BG = rgba(70, 70, 92);const KEYCAP_FG = rgba(235, 235, 245);
export interface ViewModel { game: GameState; /** Element ids currently under the pointer (hover). */ entered: Set<string>; focus: FocusRing; canUndo: boolean; /** Smoothed render rate to display; 0 until the first animation runs. */ fps: number; /** Recent key/click labels for the keycaster overlay (oldest first). */ keys: string[];}
/** Classic 2048 tile background by value. */function tileBg(value: number): number { switch (value) { case 2: return rgba(238, 228, 218); case 4: return rgba(237, 224, 200); case 8: return rgba(242, 177, 121); case 16: return rgba(245, 149, 99); case 32: return rgba(246, 124, 95); case 64: return rgba(246, 94, 59); case 128: return rgba(237, 207, 114); case 256: return rgba(237, 204, 97); case 512: return rgba(237, 200, 80); case 1024: return rgba(237, 197, 63); case 2048: return rgba(237, 194, 46); default: return rgba(60, 58, 50); }}
function tileFg(value: number): number { return value <= 4 ? rgba(118, 110, 101) : rgba(249, 246, 242);}
export function boardWidth(size: number): number { return PAD * 2 + size * TILE_COLS + (size - 1) * GAP;}
export function boardHeight(size: number): number { return PAD * 2 + size * TILE_ROWS + (size - 1) * GAP;}
function cellX(col: number): number { return PAD + col * (TILE_COLS + GAP);}
function cellY(row: number): number { return PAD + row * (TILE_ROWS + GAP);}
function statBox(id: string, label: string, value: string): Op[] { return [ open(id, { layout: { width: fixed(12), height: fixed(3), direction: "ttb", alignX: "center", alignY: "center", }, bg: STAT_BG, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), text(label, { color: LABEL }), text(value, { color: rgba(255, 255, 255) }), close(), ];}
function header(vm: ViewModel): Op[] { return [ open("header", { layout: { width: grow(), height: fixed(3), direction: "ltr", gap: 2, alignY: "center", }, }), open("title", { layout: { alignY: "center" } }), text("2048", { color: TITLE }), close(), open("title-spacer", { layout: { width: grow() } }), close(), ...statBox("stat:score", "SCORE", String(vm.game.score)), ...statBox("stat:best", "BEST", String(vm.game.best)), close(), ];}
function toolbar(vm: ViewModel): Op[] { let hov = (id: string) => vm.entered.has(id); let foc = (id: string) => focusedId(vm.focus) === id;
let ops: Op[] = [ open("toolbar", { layout: { width: grow(), height: fixed(3), direction: "ltr", gap: 1, alignY: "center", }, }), ...button("btn:new", "New Game (n)", { hovered: hov("btn:new"), focused: foc("btn:new"), }), ...button("btn:undo", "Undo (u)", { hovered: hov("btn:undo"), focused: foc("btn:undo"), disabled: !vm.canUndo, }), open("toolbar-spacer", { layout: { width: grow() } }), close(), open("size-label", { layout: { alignY: "center" } }), text("board", { color: LABEL }), close(), ];
for (let n of BOARD_SIZES) { let id = `size:${n}`; ops.push( ...button(id, `${n}x${n}`, { hovered: hov(id), focused: foc(id), // The active size reads as "pressed" so it stays highlighted. pressed: vm.game.size === n, }), ); }
ops.push(close()); return ops;}
function boardCells(size: number): Op[] { let ops: Op[] = []; for (let r = 0; r < size; r++) { ops.push( open(`cellrow:${r}`, { layout: { direction: "ltr", height: fixed(TILE_ROWS), gap: GAP }, }), ); for (let c = 0; c < size; c++) { ops.push( open(`cell:${r}:${c}`, { layout: square(TILE_ROWS), bg: EMPTY_BG, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), close(), ); } ops.push(close()); } return ops;}
function boardTiles(vm: ViewModel): Op[] { let ops: Op[] = []; for (let t of vm.game.tiles) { // Tiles are always full size and aligned to whole cells. Sliding animates // `position`; merges animate `bg`. We deliberately do not animate `size`: // the v1 renderer snaps cells to the integer grid, so a fractional/sub-cell // size pop renders as a misaligned short block rather than a smooth scale. // (Smooth sub-cell motion is what the upcoming raster path is for.) ops.push( open(`tile:${t.id}`, { layout: { width: fixed(TILE_COLS), height: fixed(TILE_ROWS), alignX: "center", alignY: "center", }, bg: tileBg(t.value), floating: { x: cellX(t.col), y: cellY(t.row), attachTo: "parent", attachPoints: { element: "left-top", parent: "left-top" }, pointerCaptureMode: "passthrough", zIndex: 1, }, transition: { // A touch longer than feels necessary on a GPU terminal: it spans // more painted frames on slower (CPU-rendered) terminals, so the // slide reads as a glide rather than a couple of discrete jumps. duration: 0.18, easing: "easeInOut", properties: ["position", "bg"], }, }), text(String(t.value), { color: tileFg(t.value) }), close(), ); } return ops;}
function board(vm: ViewModel): Op[] { let size = vm.game.size; return [ open("board-area", { layout: { width: grow(), height: grow(), alignX: "center", alignY: "center", }, }), open("board", { layout: { width: fixed(boardWidth(size)), height: fixed(boardHeight(size)), direction: "ttb", padding: { left: PAD, right: PAD, top: PAD, bottom: PAD }, gap: GAP, }, bg: BOARD_BG, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), ...boardCells(size), ...boardTiles(vm), close(), // board close(), // board-area ];}
function footer(vm: ViewModel): Op[] { let status = vm.game.over ? "no moves left" : vm.game.won ? "you reached 2048!" : "arrows / wasd to move"; return [ open("footer", { layout: { width: grow(), height: fixed(1), direction: "ltr", gap: 2, }, }), ...hint("tab", "focus"), ...hint("enter", "activate"), ...hint("u", "undo"), ...hint("n", "new"), ...hint("q", "quit"), open("footer-spacer", { layout: { width: grow() } }), close(), open("footer-fps", { layout: {} }), text(vm.fps > 0 ? `${vm.fps} fps` : "-- fps", { color: FPS_COLOR }), close(), open("footer-status", { layout: {} }), text(status, { color: HINT_TEXT }), close(), close(), ];}
function hint(key: string, label: string): Op[] { return [ open(`hint:${key}`, { layout: { direction: "ltr" } }), text(key, { color: HINT_KEY }), text(` ${label}`, { color: HINT_TEXT }), close(), ];}
// Floating keycaster: a centered row of caps near the bottom that mirrors the// player's recent key presses and button clicks. Purely a presentation overlay// (passthrough pointer), so it never intercepts clicks on the board below.function keycaster(vm: ViewModel): Op[] { if (vm.keys.length === 0) return []; let ops: Op[] = [ open("keycaster", { layout: { direction: "ltr", gap: 1, alignY: "center" }, floating: { attachTo: "root", attachPoints: { element: "center-bottom", parent: "center-bottom" }, y: -3, zIndex: 50, pointerCaptureMode: "passthrough", }, }), ]; vm.keys.forEach((label, i) => { ops.push( open(`key:${i}`, { layout: { height: fixed(3), padding: { left: 2, right: 2 }, alignX: "center", alignY: "center", }, bg: KEYCAP_BG, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, }), text(label, { color: KEYCAP_FG }), close(), ); }); ops.push(close()); return ops;}
function modal(vm: ViewModel): Op[] { if (!vm.game.over) return []; return [ open("modal", { layout: { width: fixed(34), height: fixed(9), direction: "ttb", padding: { left: 2, right: 2, top: 1, bottom: 1 }, gap: 1, alignX: "center", alignY: "center", }, bg: OVERLAY_BG, border: { color: TITLE, left: 1, right: 1, top: 1, bottom: 1 }, cornerRadius: { tl: 2, tr: 2, bl: 2, br: 2 }, floating: { attachTo: "root", attachPoints: { element: "center-center", parent: "center-center" }, zIndex: 100, }, }), open("modal-title", { layout: { alignX: "center" } }), text("Game over", { color: TITLE }), close(), open("modal-score", { layout: { alignX: "center" } }), text(`score ${vm.game.score}`, { color: rgba(230, 230, 240) }), close(), ...button("btn:again", "Play again", { hovered: vm.entered.has("btn:again"), focused: focusedId(vm.focus) === "btn:again", }, { width: fixed(16) }), close(), ];}
export function view(vm: ViewModel): Op[] { return [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb", // Outer inset so the chrome breathes away from the terminal edges, plus // a row of air between each section so the header/toolbar don't pile up. padding: { top: 1, left: 3, right: 3, bottom: 1 }, gap: 1, }, bg: ROOT_BG, }), ...header(vm), ...toolbar(vm), ...board(vm), ...footer(vm), ...keycaster(vm), ...modal(vm), close(), ];}