From a3679a28c65cf24fe200cf6bf351201df82e0e2f Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:24:31 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=202048=20example=20showcasing?= =?UTF-8?q?=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/2048/game.ts | 253 +++++++++++++++++ examples/2048/index.ts | 427 +++++++++++++++++++++++++++++ examples/2048/primitives/button.ts | 114 ++++++++ examples/2048/primitives/focus.ts | 63 +++++ examples/2048/primitives/square.ts | 37 +++ examples/2048/view.ts | 422 ++++++++++++++++++++++++++++ examples/README.md | 47 ++++ 7 files changed, 1363 insertions(+) create mode 100644 examples/2048/game.ts create mode 100644 examples/2048/index.ts create mode 100644 examples/2048/primitives/button.ts create mode 100644 examples/2048/primitives/focus.ts create mode 100644 examples/2048/primitives/square.ts create mode 100644 examples/2048/view.ts diff --git a/examples/2048/game.ts b/examples/2048/game.ts new file mode 100644 index 0000000..914e816 --- /dev/null +++ b/examples/2048/game.ts @@ -0,0 +1,253 @@ +/** + * Pure 2048 game logic — no rendering, no IO. + * + * The defining trait for the transitions demo is *tile identity*: every logical + * tile carries a stable numeric `id` that survives slides and merges. The + * renderer keys each tile's element on that id, so when a tile's (row, col) + * changes between frames the layout engine interpolates its position instead of + * teleporting it. Merged-away tiles are dropped immediately (v1 has no exit + * transitions); the surviving tile is flagged `merged` so the view can pop it. + */ + +export type Direction = "up" | "down" | "left" | "right"; + +export interface Tile { + id: number; + row: number; + col: number; + value: number; + /** This tile is the result of a merge on the most recent move (pop hint). */ + merged: boolean; + /** This tile was spawned by the most recent move (pop hint). */ + spawned: boolean; +} + +export interface GameState { + size: number; + tiles: Tile[]; + score: number; + best: number; + /** A 2048 tile has appeared at least once. */ + won: boolean; + /** No legal move remains. */ + over: boolean; + nextId: number; +} + +export function cloneGame(state: GameState): GameState { + return { + ...state, + tiles: state.tiles.map((t) => ({ ...t })), + }; +} + +function emptyCells(size: number, tiles: Tile[]): Array<[number, number]> { + let occupied = new Set(tiles.map((t) => t.row * size + t.col)); + let cells: Array<[number, number]> = []; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (!occupied.has(r * size + c)) cells.push([r, c]); + } + } + return cells; +} + +function spawn(state: GameState): void { + let cells = emptyCells(state.size, state.tiles); + if (cells.length === 0) return; + let [row, col] = cells[Math.floor(Math.random() * cells.length)]; + let value = Math.random() < 0.9 ? 2 : 4; + state.tiles.push({ + id: state.nextId++, + row, + col, + value, + merged: false, + spawned: true, + }); +} + +export function newGame(size: number, best = 0): GameState { + let state: GameState = { + size, + tiles: [], + score: 0, + best, + won: false, + over: false, + nextId: 1, + }; + spawn(state); + spawn(state); + // Freshly dealt tiles read as "spawned"; that is the intended first-frame pop. + return state; +} + +/** Lines are traversed from the edge the tiles move toward. */ +function lineOrder( + size: number, + dir: Direction, +): { lines: number[]; cells: (line: number) => Array<[number, number]> } { + let indices = Array.from({ length: size }, (_, i) => i); + return { + lines: indices, + cells(line: number) { + let out: Array<[number, number]> = []; + for (let i = 0; i < size; i++) { + switch (dir) { + case "left": + out.push([line, i]); + break; + case "right": + out.push([line, size - 1 - i]); + break; + case "up": + out.push([i, line]); + break; + case "down": + out.push([size - 1 - i, line]); + break; + } + } + return out; + }, + }; +} + +function place( + dir: Direction, + line: number, + slot: number, + size: number, +): [number, number] { + switch (dir) { + case "left": + return [line, slot]; + case "right": + return [line, size - 1 - slot]; + case "up": + return [slot, line]; + case "down": + return [size - 1 - slot, line]; + } +} + +export interface MoveResult { + state: GameState; + moved: boolean; +} + +export function move(prev: GameState, dir: Direction): MoveResult { + let size = prev.size; + // Work on fresh tile objects so `prev` (and anything holding it, e.g. the undo + // history) is never mutated. Index them by cell to walk lines, and remember + // each tile's origin to detect whether anything actually shifted. + let working = prev.tiles.map((t) => ({ + ...t, + merged: false, + spawned: false, + })); + let origin = new Map(); + let grid: (Tile | null)[][] = Array.from( + { length: size }, + () => Array.from({ length: size }, () => null), + ); + for (let t of working) { + grid[t.row][t.col] = t; + origin.set(t.id, [t.row, t.col]); + } + + let survivors: Tile[] = []; + let removed = 0; + let gained = 0; + let { lines, cells } = lineOrder(size, dir); + + for (let line of lines) { + let incoming: Tile[] = []; + for (let [r, c] of cells(line)) { + let t = grid[r][c]; + if (t) incoming.push(t); + } + + let merged: Tile[] = []; + let lockedLast = false; + for (let t of incoming) { + let last = merged[merged.length - 1]; + if (last && !lockedLast && last.value === t.value) { + last.value *= 2; + last.merged = true; + gained += last.value; + lockedLast = true; + removed++; + // `t` is consumed by the merge: it is not carried forward. + } else { + merged.push(t); + lockedLast = false; + } + } + + for (let slot = 0; slot < merged.length; slot++) { + let t = merged[slot]; + let [row, col] = place(dir, line, slot, size); + t.row = row; + t.col = col; + survivors.push(t); + } + } + + let moved = removed > 0; + if (!moved) { + for (let t of survivors) { + let o = origin.get(t.id)!; + if (o[0] !== t.row || o[1] !== t.col) { + moved = true; + break; + } + } + } + + let next: GameState = { + ...prev, + tiles: survivors, + score: prev.score + gained, + }; + + if (moved) { + spawn(next); + } + + next.best = Math.max(prev.best, next.score); + next.won = next.won || next.tiles.some((t) => t.value >= 2048); + next.over = !hasMoves(next); + + return { state: next, moved }; +} + +function hasMoves(state: GameState): boolean { + let { size, tiles } = state; + if (emptyCells(size, tiles).length > 0) return true; + + let grid: (number | null)[][] = Array.from( + { length: size }, + () => Array.from({ length: size }, () => null), + ); + for (let t of tiles) grid[t.row][t.col] = t.value; + + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + let v = grid[r][c]; + if (v === null) continue; + if (c + 1 < size && grid[r][c + 1] === v) return true; + if (r + 1 < size && grid[r + 1][c] === v) return true; + } + } + return false; +} + +/** Strip the per-move pop hints so the next move starts clean. */ +export function clearFlags(state: GameState): GameState { + return { + ...state, + tiles: state.tiles.map((t) => ({ ...t, merged: false, spawned: false })), + }; +} diff --git a/examples/2048/index.ts b/examples/2048/index.ts new file mode 100644 index 0000000..b8fd557 --- /dev/null +++ b/examples/2048/index.ts @@ -0,0 +1,427 @@ +/** + * 2048 — a motion-first showcase for `@bomb.sh/tty` transitions and a proving + * ground for `bombshell-dev/ui` primitives. + * + * - The board tiles slide and recolor via the v1 `transition` field (position, + * bg), keyed on stable tile ids (see `game.ts` / `view.ts`). + * - The chrome is built from the `button` primitive and is fully usable by both + * mouse (hover + click) and keyboard (Tab to focus, Enter/Space to activate), + * closing the focus gap tty leaves open (see `primitives/`). + * + * Controls: arrows or WASD to move, Tab/Shift+Tab to move focus, Enter/Space to + * activate the focused button, n new game, u undo, q or Ctrl+C to quit. + * + * Run: `deno run examples/2048/index.ts` + */ + +import { + createChannel, + createSignal, + each, + ensure, + main, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { createTerm, type InputEvent, type PointerEvent } from "../../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../../settings.ts"; +import { useInput } from "../use-input.ts"; +import { useStdin } from "../use-stdin.ts"; +import { + clearFlags, + cloneGame, + type Direction, + type GameState, + move, + newGame, +} from "./game.ts"; +import { + focusBy, + focusedId, + type FocusRing, + focusRing, + focusTo, + isActivate, + tabDirection, +} from "./primitives/focus.ts"; +import { BOARD_SIZES, view, type ViewModel } from "./view.ts"; + +const TOOLBAR_ITEMS = [ + "btn:new", + "btn:undo", + ...BOARD_SIZES.map((n) => `size:${n}`), +]; + +/** A single press shown by the on-screen keycaster, with the time it landed. */ +interface KeyChip { + label: string; + at: number; +} + +interface App { + game: GameState; + history: GameState[]; + entered: Set; + pointer: { x: number; y: number; down: boolean } | undefined; + focus: FocusRing; + /** Sliding-window count of frames produced in the last second (see `draw`). */ + fps: number; + /** Recent presses for the keycaster overlay (oldest first). */ + keys: KeyChip[]; +} + +/** How long a keycaster chip stays on screen after its press, in ms. */ +const KEY_WINDOW_MS = 1600; +/** Most chips shown at once; older ones drop off the left. */ +const KEY_MAX = 12; + +/** Display label for a key press, or `null` to ignore it (e.g. quit keys). */ +function keyLabel(e: InputEvent): string | null { + if (e.type !== "keydown") return null; + if (e.ctrl && e.key === "c") return null; + switch (e.key) { + case "ArrowUp": + return "↑"; + case "ArrowDown": + return "↓"; + case "ArrowLeft": + return "←"; + case "ArrowRight": + return "→"; + case "Enter": + case "NumpadEnter": + return "⏎"; + case " ": + return "Space"; + case "Tab": + return e.shift ? "⇧Tab" : "Tab"; + case "Backtab": + return "⇧Tab"; + case "Escape": + return "Esc"; + } + if (e.code === "Tab") return e.shift ? "⇧Tab" : "Tab"; + if (typeof e.key === "string" && e.key.length === 1) { + return e.key.toUpperCase(); + } + return null; +} + +/** Display label for a clicked control, or `null` if it isn't a tracked one. */ +function clickLabel(id: string): string | null { + switch (id) { + case "btn:new": + return "New"; + case "btn:again": + return "Again"; + case "btn:undo": + return "Undo"; + case "size:3": + return "3x3"; + case "size:4": + return "4x4"; + case "size:5": + return "5x5"; + } + return null; +} + +function ringItems(game: GameState): string[] { + return game.over ? ["btn:again", ...TOOLBAR_ITEMS] : TOOLBAR_ITEMS; +} + +function syncFocus(app: App): void { + let items = ringItems(app.game); + let id = focusedId(app.focus); + let index = id ? items.indexOf(id) : -1; + app.focus = focusRing(items, index === -1 ? 0 : index); +} + +function startGame(app: App, size: number): void { + app.game = newGame(size, app.game.best); + app.history = []; + syncFocus(app); +} + +function undo(app: App): void { + let previous = app.history.pop(); + if (!previous) return; + app.game = previous; + syncFocus(app); +} + +function activate(app: App, id: string | undefined): void { + switch (id) { + case "btn:new": + case "btn:again": + startGame(app, app.game.size); + break; + case "btn:undo": + undo(app); + break; + case "size:3": + case "size:4": + case "size:5": + startGame(app, Number(id.slice(5))); + break; + } + if (id) app.focus = focusTo(app.focus, id); +} + +function doMove(app: App, dir: Direction): void { + if (app.game.over) return; + let snapshot = cloneGame(clearFlags(app.game)); + let { state, moved } = move(snapshot, dir); + if (!moved) return; + app.history.push(snapshot); + app.game = state; + syncFocus(app); +} + +function model(app: App): ViewModel { + return { + game: app.game, + entered: app.entered, + focus: app.focus, + canUndo: app.history.length > 0, + fps: Math.round(app.fps), + keys: app.keys.map((k) => k.label), + }; +} + +function ticker( + flag: { animating: boolean; keysVisible: boolean }, +): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + if (flag.animating) { + // ~125fps. We deliberately produce faster than a 60Hz refresh: our + // frame timer isn't synced to the display's vsync, so over-producing + // means every refresh has a fresh frame ready instead of occasionally + // landing between two of ours (judder). A render is ~0.08ms, so the + // extra frames are essentially free. + // + // We tried throttling this to ~60fps (16ms) to avoid handing a slow + // terminal more frames than it can paint, but it looked choppier, not + // smoother — capping at the refresh rate makes unsynced judder worse, + // not better. Over-producing is the safer default. + yield* sleep(8); + yield* ch.send(); + } else if (flag.keysVisible) { + // Nothing is moving, but keycaster chips are on screen and need to + // expire on their own. A lazy ~10fps tick is enough to retire them. + yield* sleep(100); + yield* ch.send(); + } else { + yield* sleep(50); + } + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let sub = { a: yield* a, b: yield* b }; + return yield* provide({ + *next() { + return yield* race([sub.a.next(), sub.b.next()]); + }, + }); + }); +} + +function* drain(stream: Stream) { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +const DIRECTIONS: Record = { + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + w: "up", + s: "down", + a: "left", + d: "right", +}; + +// Wipe the whole screen. The renderer diffs against its own buffer, so on +// resize we must clear physically-stale cells (out-of-bounds when shrinking, +// reflowed when growing) ourselves before the fresh term repaints. +const CLEAR_SCREEN = new TextEncoder().encode("\x1b[2J\x1b[H"); + +function consoleSize(): { width: number; height: number } { + let { columns, rows } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 80, rows: 24 }; + return { width: columns, height: rows }; +} + +await main(function* () { + let { width, height } = consoleSize(); + + Deno.stdin.setRaw(true); + yield* ensure(() => Deno.stdin.setRaw(false)); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width, height })); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + Deno.stdout.writeSync(tty.apply); + yield* ensure(() => { + Deno.stdout.writeSync(tty.revert); + }); + + // The native term has fixed-size buffers, and the input parser never emits + // resize events, so we bridge SIGWINCH into the event stream ourselves. The + // payload is just a wake-up; the handler reads the live size. + let resizes = createSignal<{ type: "resize" }, void>(); + let onWinch = () => resizes.send({ type: "resize" }); + if (Deno.build.os !== "windows") { + Deno.addSignalListener("SIGWINCH", onWinch); + yield* ensure(() => Deno.removeSignalListener("SIGWINCH", onWinch)); + } + + let app: App = { + game: newGame(3), + history: [], + entered: new Set(), + pointer: undefined, + focus: focusRing(TOOLBAR_ITEMS), + fps: 0, + keys: [], + }; + + let flag = { animating: false, keysVisible: false }; + let pointerEvents = createChannel(); + + // Frames-per-second is a sliding-window count of frames we push to stdout in + // the last second. This is the rate we *produce* frames, not the rate the + // terminal *paints* them: writeSync returns once bytes reach the pty, and a + // CPU-rendered terminal can coalesce/drop frames downstream where we can't + // observe it. So a low number here means we're slow; a high number does not + // guarantee the terminal kept up. + let frameTimes: number[] = []; + + function draw(): PointerEvent[] { + let now = performance.now(); + frameTimes.push(now); + while (frameTimes.length > 0 && now - frameTimes[0] > 1000) { + frameTimes.shift(); + } + app.fps = frameTimes.length; + + // Retire keycaster chips that have outlived their window, then cap the row. + app.keys = app.keys.filter((k) => now - k.at < KEY_WINDOW_MS); + if (app.keys.length > KEY_MAX) app.keys = app.keys.slice(-KEY_MAX); + flag.keysVisible = app.keys.length > 0; + + let { output, animating, events } = term.render(view(model(app)), { + pointer: app.pointer, + }); + + for (let ev of events) { + if (ev.type === "pointerenter") { + app.entered = new Set([...app.entered, ev.id]); + } else if (ev.type === "pointerleave") { + let next = new Set(app.entered); + next.delete(ev.id); + app.entered = next; + } else if (ev.type === "pointerclick") { + let label = clickLabel(ev.id); + if (label) app.keys.push({ label, at: now }); + if (ringItems(app.game).includes(ev.id)) activate(app, ev.id); + } + } + + Deno.stdout.writeSync(output); + flag.animating = animating; + + return events; + } + + draw(); + + let ticks = ticker(flag); + let events = merge(merge(merge(input, pointerEvents), ticks), resizes); + + for (let ev of yield* each(events)) { + if (ev !== undefined && typeof ev === "object" && "type" in ev) { + // Resize: rebuild the term at the new size, wipe stale cells, repaint. + // A drag-resize fires many SIGWINCHes; we read the live size each time + // and skip when unchanged, so only real size changes rebuild the term. + if ((ev as { type: string }).type === "resize") { + let next = consoleSize(); + if (next.width !== width || next.height !== height) { + width = next.width; + height = next.height; + term = yield* until(createTerm({ width, height })); + Deno.stdout.writeSync(CLEAR_SCREEN); + draw(); + } + yield* each.next(); + continue; + } + + let e = ev as InputEvent | PointerEvent; + + if (e.type === "keydown") { + if (e.ctrl && e.key === "c") break; + if (e.key === "q") break; + + let label = keyLabel(e); + if (label) app.keys.push({ label, at: performance.now() }); + + let tab = tabDirection(e); + if (tab !== null) { + app.focus = focusBy(app.focus, tab); + } else if (isActivate(e)) { + activate(app, focusedId(app.focus)); + } else if (e.key === "n") { + activate(app, "btn:new"); + } else if (e.key === "u") { + activate(app, "btn:undo"); + } else { + let dir = DIRECTIONS[e.key]; + if (dir) doMove(app, dir); + } + } + + if ("x" in e && "y" in e && typeof e.x === "number") { + app.pointer = { + x: e.x, + y: e.y, + down: e.type === "mousedown", + }; + } + } + + let pevents = draw(); + for (let pev of pevents) { + yield* pointerEvents.send(pev); + } + + yield* each.next(); + } +}); diff --git a/examples/2048/primitives/button.ts b/examples/2048/primitives/button.ts new file mode 100644 index 0000000..460cd81 --- /dev/null +++ b/examples/2048/primitives/button.ts @@ -0,0 +1,114 @@ +/** + * button — the primitive this whole demo exists to prove out. + * + * `@bomb.sh/tty` has no button widget: callers hand-roll a styled `open()` plus + * pointer bookkeeping, and there is no keyboard focus/activation at all. This + * module packages the *visual + interaction contract* of a button as a plain + * `Op[]`-returning function so it can be lifted into `bombshell-dev/ui`. + * + * Responsibilities split cleanly: + * - This file owns the *look*: how the four interaction states (hover, focus, + * press, disabled) map to background/border/text, with a transition so the + * feedback is smooth. + * - The caller owns *wiring*: pass `{ x, y, down }` into `term.render()` and + * route the resulting `pointerenter`/`pointerleave`/`pointerclick` events + * (keyed by the button `id`) back into `ButtonState`. Keyboard focus and + * Enter/Space activation are handled by the companion `focus.ts` helper, + * because tty has no focus system of its own. + */ + +import { + close, + fixed, + type Op, + open, + rgba, + type SizingAxis, + text, +} from "../../../mod.ts"; + +export interface ButtonState { + hovered?: boolean; + focused?: boolean; + pressed?: boolean; + disabled?: boolean; +} + +export interface ButtonTheme { + bg: number; + bgHover: number; + bgActive: number; + bgDisabled: number; + fg: number; + fgDisabled: number; + /** Border color when focused (the focus ring). */ + ring: number; +} + +export const defaultTheme: ButtonTheme = { + bg: rgba(58, 58, 78), + bgHover: rgba(84, 84, 112), + bgActive: rgba(110, 110, 150), + bgDisabled: rgba(40, 40, 52), + fg: rgba(236, 236, 244), + fgDisabled: rgba(110, 110, 130), + ring: rgba(255, 214, 110), +}; + +export interface ButtonOptions { + width?: SizingAxis; + height?: SizingAxis; + theme?: Partial; +} + +function bgFor(s: ButtonState, t: ButtonTheme): number { + if (s.disabled) return t.bgDisabled; + if (s.pressed) return t.bgActive; + if (s.hovered) return t.bgHover; + return t.bg; +} + +/** + * Render a button. Returns a balanced `open()` … `close()` op sequence; the + * element id is `id`, which is also the id the caller matches against pointer + * events. + */ +export function button( + id: string, + label: string, + state: ButtonState = {}, + options: ButtonOptions = {}, +): Op[] { + let theme = { ...defaultTheme, ...options.theme }; + let bg = bgFor(state, theme); + // A border is always present so the box never changes size when focused; only + // its color animates between the background tint and the focus ring. + let ringColor = state.focused && !state.disabled ? theme.ring : bg; + let fg = state.disabled ? theme.fgDisabled : theme.fg; + + return [ + open(id, { + layout: { + direction: "ltr", + // Omitted width falls back to "fit" (size to content) in the packer. + width: options.width, + height: options.height ?? fixed(3), + padding: { left: 2, right: 2 }, + alignX: "center", + alignY: "center", + }, + bg, + border: { color: ringColor, left: 1, right: 1, top: 1, bottom: 1 }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + // No transition: the button never moves, so a color-only transition would + // need Clay's `interactive` (transition-while-moving) machinery to be left + // off. In this v1 build, configuring a transition on a bordered, focusable + // element renders a stale duplicate of its border (an empty focus-ring box + // below the button) when focus changes. Focus/hover feedback is instant + // instead, which is crisp and correct. Revisit once transitions support + // color-only animation without the position-interaction coupling. + }), + text(label, { color: fg }), + close(), + ]; +} diff --git a/examples/2048/primitives/focus.ts b/examples/2048/primitives/focus.ts new file mode 100644 index 0000000..a9836e5 --- /dev/null +++ b/examples/2048/primitives/focus.ts @@ -0,0 +1,63 @@ +/** + * focus — a minimal keyboard focus manager. + * + * tty deliberately has no focus system (the input spec excludes focus, tab + * order, and keybindings). But a button is not really a button until you can + * Tab to it and press it with the keyboard, so we add the smallest thing that + * closes that gap at the app layer. + * + * A `FocusRing` is just an ordered list of element ids plus the index of the + * focused one. Focus owns tab order and "who is focused"; what Enter/Space + * means is up to the focused component (the app routes activation to + * `focusedId(ring)`). + */ + +import type { KeyEvent } from "../../../mod.ts"; + +export interface FocusRing { + items: string[]; + index: number; +} + +export function focusRing(items: string[], index = 0): FocusRing { + return { items, index: items.length === 0 ? 0 : clamp(index, items.length) }; +} + +export function focusedId(ring: FocusRing): string | undefined { + return ring.items[ring.index]; +} + +export function focusBy(ring: FocusRing, delta: number): FocusRing { + if (ring.items.length === 0) return ring; + let index = (ring.index + delta + ring.items.length) % ring.items.length; + return { ...ring, index }; +} + +export function focusTo(ring: FocusRing, id: string): FocusRing { + let index = ring.items.indexOf(id); + return index === -1 ? ring : { ...ring, index }; +} + +/** + * Tab navigation intent for a key event, or `null` if it is not a Tab. + * Returns +1 for forward (Tab) and -1 for backward (Shift+Tab / Backtab). + */ +export function tabDirection(e: KeyEvent): 1 | -1 | null { + if (e.type !== "keydown") return null; + if (e.code === "Backtab" || e.key === "Backtab") return -1; + if (e.code === "Tab" || e.key === "Tab") return e.shift ? -1 : 1; + return null; +} + +/** Whether a key event should activate the focused control. */ +export function isActivate(e: KeyEvent): boolean { + if (e.type !== "keydown") return false; + return e.key === "Enter" || e.key === " " || e.code === "NumpadEnter" || + e.key === "NumpadEnter"; +} + +function clamp(index: number, length: number): number { + if (index < 0) return 0; + if (index >= length) return length - 1; + return index; +} diff --git a/examples/2048/primitives/square.ts b/examples/2048/primitives/square.ts new file mode 100644 index 0000000..396a0c4 --- /dev/null +++ b/examples/2048/primitives/square.ts @@ -0,0 +1,37 @@ +/** + * square — an aspect-ratio helper. + * + * Terminal cells are roughly twice as tall as they are wide (~1:2 w:h; the + * bombshell sneak peek measured a 16x34px cell). A box that should *read* as a + * square therefore needs about two columns for every row. This helper hides the + * manual fudge factor that demos otherwise hand-roll. + * + * Lift target: this is a candidate primitive for `bombshell-dev/ui`. When the + * renderer exposes the real cell pixel size, `CELL_W_PER_H` can be replaced by + * `cellHeightPx / cellWidthPx` instead of this 2:1 constant. + */ + +import { fixed, type SizingAxis } from "../../../mod.ts"; + +/** Approximate columns-per-row for a visually square cell. */ +export const CELL_W_PER_H = 2; + +export interface SquareSize { + width: SizingAxis; + height: SizingAxis; +} + +/** Number of columns that reads as square for the given row count. */ +export function squareCols(rows: number): number { + return Math.max(1, Math.round(rows * CELL_W_PER_H)); +} + +/** + * Fixed sizing for a box that reads as a square `rows` cells tall. + * + * @example + * open("tile", { layout: square(3) }) // 6 cols wide, 3 rows tall + */ +export function square(rows: number): SquareSize { + return { width: fixed(squareCols(rows)), height: fixed(rows) }; +} diff --git a/examples/2048/view.ts b/examples/2048/view.ts new file mode 100644 index 0000000..eca0750 --- /dev/null +++ b/examples/2048/view.ts @@ -0,0 +1,422 @@ +/** + * 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); // 6 +export 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; + 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(), + ]; +} diff --git a/examples/README.md b/examples/README.md index 6f4c846..0e3476c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -75,6 +75,53 @@ What they show: - renderer `animating` state for scheduling follow-up frames - color and layout interpolation in an interactive keyboard/sidebar demo +## 2048 + +Path: `examples/2048/index.ts` + +Run it with: + +```sh +deno run examples/2048/index.ts +# or +node examples/2048/index.ts +``` + +Controls: arrows or `wasd` to move, `Tab`/`Shift+Tab` to move focus, +`Enter`/`Space` to activate the focused button, `n` new game, `u` undo, `q` or +`Ctrl+C` to quit. The board size selector and game-over panel are clickable too. + +What it shows: + +- a motion-first game where the board tiles slide and recolor using the v1 + `transition` field (`position`, `bg`), keyed on stable tile ids so the layout + engine interpolates each tile between frames +- the `animating` render signal gating a follow-up frame loop, so the process + only renders while something is moving +- pointer hit testing and keyboard focus working together on the chrome buttons +- an fps readout in the footer: a sliding-window count of frames pushed to + stdout in the last second. It measures how fast frames are _produced_, not how + fast the terminal _paints_ them — a CPU-rendered terminal (e.g. Terminal.app) + can coalesce or drop frames downstream where the process can't observe it, so + motion can look steppy even while this number stays high +- live resize handling: a `SIGWINCH` listener is bridged into the Effection + event loop, and because the native term has fixed-size buffers, each real size + change rebuilds the term, clears the screen, and repaints so the layout + re-centers to the new dimensions +- a keycaster overlay: recent key presses and button clicks appear as a centered + row of caps near the bottom (a floating, pointer-passthrough element) that + retire on a timer, handy for screen recordings and demos + +Known v1 limitations (deferred upstream, see `specs/transitions-spec.md` §13): + +- There are no enter/exit transitions, so newly spawned tiles simply appear in + place and merged-away tiles are dropped immediately rather than sliding out. +- Tiles animate `position` and `bg` but not `size`. The v1 renderer snaps cells + to the integer grid, so a sub-cell size/scale "pop" renders as a misaligned + short block instead of a smooth scale. Smooth sub-cell motion is exactly what + the upcoming raster rendering path is for; until then tiles stay aligned to + whole cells and only slide and recolor. + ## Inline Regions Path: `examples/inline-regions/index.ts`