From 111f19690dd6d9a8d415d994b4db3bc7026c401c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 09:01:56 -0700 Subject: [PATCH 1/2] Add falling pixel snow over the landing mountain mark --- src/tui-opentui/mark-anim.test.ts | 102 +++++++++++++++++++++++++----- src/tui-opentui/mark-anim.ts | 62 +++++++++++++++++- 2 files changed, 148 insertions(+), 16 deletions(-) diff --git a/src/tui-opentui/mark-anim.test.ts b/src/tui-opentui/mark-anim.test.ts index 0fd988fb7..e27cb9b0b 100644 --- a/src/tui-opentui/mark-anim.test.ts +++ b/src/tui-opentui/mark-anim.test.ts @@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test" import { MARK_PERIOD_SECONDS, + SNOW_CHAR, markFrame, markText, renderMark, smooth, } from "./mark-anim" -import { MARK_COLS, MARK_COVERAGE, MARK_LARGE, MARK_ROWS } from "./mark-shape" +import { MARK_COLS, MARK_LARGE, MARK_ROWS, MARK_SMALL } from "./mark-shape" import { UI } from "./theme" +const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█" + +const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char) +const isSnow = (char: string): boolean => char === SNOW_CHAR + describe("smooth", () => { test("clamps outside [0, 1] and eases inside it", () => { expect(smooth(-3)).toBe(0) @@ -62,8 +68,17 @@ describe("markFrame", () => { }) describe("renderMark", () => { - const emptyCells = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().filter((cell) => cell.char === " ").length + /** Mountain-block weight only — snow must not pollute silhouette metrics. */ + const mountainWeight = ( + grid: readonly (readonly { char: string }[])[], + ): number => + grid + .flat() + .reduce((sum, cell) => sum + Math.max(0, MOUNTAIN_CHARS.indexOf(cell.char) + 1), 0) + + const mountainCells = ( + grid: readonly (readonly { char: string }[])[], + ): number => grid.flat().filter((cell) => isMountain(cell.char)).length test("is the mark's cell dimensions", () => { const grid = renderMark({ nowMs: 0, still: true }) @@ -71,11 +86,18 @@ describe("renderMark", () => { for (const row of grid) expect(row).toHaveLength(MARK_COLS) }) - test("never paints outside the silhouette", () => { - const grid = renderMark({ nowMs: 2000, still: false }) + test("sky is empty or snow; mountain cells never hold snow", () => { + const grid = renderMark({ nowMs: 2000, still: false, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - if ((MARK_COVERAGE[y]?.[x] ?? 0) === 0) expect(cell.char).toBe(" ") + const coverage = MARK_LARGE.coverage[y]?.[x] ?? 0 + if (coverage === 0) { + expect(cell.char === " " || isSnow(cell.char)).toBe(true) + if (isSnow(cell.char)) expect(cell.fg).toBe(UI.textFaint) + } else if (isMountain(cell.char)) { + expect(cell.fg).toBe(UI.action) + expect(isSnow(cell.char)).toBe(false) + } }) }) }) @@ -84,17 +106,19 @@ describe("renderMark", () => { const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - expect(" ▁▂▃▄▅▆▇█").toContain(cell.char) + // Still mode has no snow — only space or mountain blocks. + expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char) if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█") }) }) }) - test("the still frame is clock-independent", () => { + test("the still frame is clock-independent and has no snow", () => { const a = markText(renderMark({ nowMs: 0, still: true })) const b = markText(renderMark({ nowMs: 987_654, still: true })) expect(b).toBe(a) expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0) + expect(a.includes(SNOW_CHAR)).toBe(false) }) test("the animated frame advances with the injected clock", () => { @@ -105,14 +129,14 @@ describe("renderMark", () => { }) test("the outline reveals left to right", () => { - // Early in the draw phase only the leftmost columns may be lit. + // Early in the draw phase only the leftmost mountain columns may be lit. const grid = renderMark({ nowMs: 0.06 * MARK_PERIOD_SECONDS * 1000, still: false }) const lit = grid.flatMap((row) => - row.flatMap((cell, col) => (cell.char === " " ? [] : [col])), + row.flatMap((cell, col) => (isMountain(cell.char) ? [col] : [])), ) expect(Math.max(...lit, -1)).toBeLessThan(MARK_COLS) const full = renderMark({ nowMs: 0.4 * MARK_PERIOD_SECONDS * 1000, still: false }) - expect(emptyCells(grid)).toBeGreaterThan(emptyCells(full)) + expect(mountainCells(full)).toBeGreaterThan(mountainCells(grid)) }) test("the fade thins the mark out toward empty", () => { @@ -121,12 +145,10 @@ describe("renderMark", () => { nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(emptyCells(fading)).toBeGreaterThan(emptyCells(held)) + expect(mountainCells(held)).toBeGreaterThan(mountainCells(fading)) }) test("filling makes the mark denser than its outline alone", () => { - const weight = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().reduce((sum, cell) => sum + " ▁▂▃▄▅▆▇█".indexOf(cell.char), 0) const outlineOnly = renderMark({ nowMs: 0.42 * MARK_PERIOD_SECONDS * 1000, still: false, @@ -135,6 +157,56 @@ describe("renderMark", () => { nowMs: 0.8 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(weight(filled)).toBeGreaterThan(weight(outlineOnly)) + expect(mountainWeight(filled)).toBeGreaterThan(mountainWeight(outlineOnly)) + }) + + test("snow drifts over time without overwriting the silhouette", () => { + // Sample across several seconds so flakes advance even at a slow fall rate. + const times = [0, 1500, 3000, 4500, 6000, 7500] + const snowSets = times.map((nowMs) => { + const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE }) + const snow: string[] = [] + grid.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + snow.push(`${y},${x}`) + // Flakes live only in sky cells — never on mountain coverage. + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + }) + }) + return snow.join("|") + }) + + const withSnow = snowSets.filter((s) => s.length > 0) + expect(withSnow.length).toBeGreaterThan(1) + expect(new Set(withSnow).size).toBeGreaterThan(1) + + // During the full-hold phase the ridgeline dominates the flake field. + const held = renderMark({ + nowMs: 0.82 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + let flakes = 0 + let mountains = 0 + held.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + flakes += 1 + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + if (isMountain(cell.char)) mountains += 1 + }) + }) + expect(mountains).toBeGreaterThan(20) + expect(mountains).toBeGreaterThan(flakes) + }) + + test("still mode freezes the mark with no snow motion", () => { + const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL }) + const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL }) + expect(markText(b)).toBe(markText(a)) + expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false) }) }) diff --git a/src/tui-opentui/mark-anim.ts b/src/tui-opentui/mark-anim.ts index e5be69807..dc6f4ed5f 100644 --- a/src/tui-opentui/mark-anim.ts +++ b/src/tui-opentui/mark-anim.ts @@ -7,6 +7,11 @@ * wave; at hero size a terminal renders that as visible noise rather than as * shimmer, so the terminal mark is opaque instead. * + * Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the + * same injected clock. Density and speed stay low so the ridgeline keeps its + * silhouette; `still` (idle or reduced motion) freezes the mark and drops the + * snow entirely. Mountain cells always win over flakes. + * * Everything here is pure and clock-injected: `nowMs` is the only time source, * so the caller's existing 250 ms status tick drives the animation and tests * drive it deterministically. There is no timer in this module. @@ -70,6 +75,18 @@ const EIGHTHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as cons */ const FILL_GAMMA = 0.6 +/** One snowflake pixel. Exported so tests can distinguish sky from mountain. */ +export const SNOW_CHAR = "·" + +/** + * Fraction of columns that host a flake. Kept low so the sky reads as empty + * with occasional drift rather than a storm. + */ +const SNOW_COLUMN_FRACTION = 0.18 + +/** Baseline rows-per-second fall rate. Slow enough to feel like drift. */ +const SNOW_FALL_SPEED = 0.55 + export type MarkCell = { readonly char: string readonly fg: string @@ -83,6 +100,35 @@ export type MarkInput = { readonly grid?: MarkGrid } +/** + * Stable unit hash in [0, 1) from integer seeds. Pure and clock-independent so + * flake columns and phases never jitter between frames. + */ +function unitHash(a: number, b = 0): number { + const n = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263) + const x = Math.imul(n ^ (n >>> 13), 1274126177) + return ((x >>> 0) % 10_000) / 10_000 +} + +/** + * Whether a sky cell at (row, col) holds a flake at `seconds`. Sparse columns + * only; each active column carries one flake with a private phase and a slight + * speed variation so the field does not march as a rigid lattice. + */ +function snowflakeAt( + row: number, + col: number, + seconds: number, + rows: number, +): boolean { + if (rows <= 0) return false + if (unitHash(col, 1) > SNOW_COLUMN_FRACTION) return false + const phase = unitHash(col, 2) * rows + const speed = SNOW_FALL_SPEED * (0.75 + unitHash(col, 3) * 0.5) + const wrapped = (((seconds * speed + phase) % rows) + rows) % rows + return Math.floor(wrapped) === row +} + /** * Composite one frame into a row-major cell grid. * @@ -91,6 +137,9 @@ export type MarkInput = { * slopes instead of staircasing. No dither texture survives inside the shape — * the mark is a mountain, and a mountain is opaque. * + * Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never + * overwrite mountain coverage, and `still` suppresses them entirely. + * * `alpha` has no terminal equivalent, so it scales the block height instead: * the mark sinks toward empty rather than blending to black. */ @@ -100,6 +149,7 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const { drawProg, fillProg, alpha } = markFrame(seconds, input.still) const revealed = drawProg * shape.cols const fillLine = shape.rows * (1 - fillProg) + const snowOn = !input.still const grid: MarkCell[][] = [] for (let row = 0; row < shape.rows; row++) { @@ -110,7 +160,17 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const coverage = shape.coverage[row]?.[col] ?? 0 const reveal = clamp01(revealed - col) if (coverage === 0 || reveal === 0) { - cells.push({ char: " ", fg: UI.action }) + // Snow only in true sky. Unrevealed mountain cells stay empty so the + // left-to-right draw still reads as a clean silhouette edge. + if ( + snowOn && + coverage === 0 && + snowflakeAt(row, col, seconds, shape.rows) + ) { + cells.push({ char: SNOW_CHAR, fg: UI.textFaint }) + } else { + cells.push({ char: " ", fg: UI.action }) + } continue } // The outline states the shape at its true coverage; filling lifts it From 59d8c26734023ff353c59132c09f89088d6b2ffe Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:08:43 -0700 Subject: [PATCH 2/2] Fade the snow out with the mark instead of leaving it at full density Critique review caught that the fade-out phase thinned the mountain silhouette toward empty while the snowfield kept animating at full density, so the decoration outlasted the mark it was drifting over. --- src/tui-opentui/mark-anim.test.ts | 9 +++++++++ src/tui-opentui/mark-anim.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tui-opentui/mark-anim.test.ts b/src/tui-opentui/mark-anim.test.ts index e27cb9b0b..cb321f7f5 100644 --- a/src/tui-opentui/mark-anim.test.ts +++ b/src/tui-opentui/mark-anim.test.ts @@ -209,4 +209,13 @@ describe("renderMark", () => { expect(markText(b)).toBe(markText(a)) expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false) }) + + test("snow drops out during the fade-out phase, matching the mark", () => { + const fading = renderMark({ + nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + expect(fading.flat().some((cell) => isSnow(cell.char))).toBe(false) + }) }) diff --git a/src/tui-opentui/mark-anim.ts b/src/tui-opentui/mark-anim.ts index dc6f4ed5f..a3a62dafe 100644 --- a/src/tui-opentui/mark-anim.ts +++ b/src/tui-opentui/mark-anim.ts @@ -149,7 +149,9 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const { drawProg, fillProg, alpha } = markFrame(seconds, input.still) const revealed = drawProg * shape.cols const fillLine = shape.rows * (1 - fillProg) - const snowOn = !input.still + // Fade out drops the snow too so the decoration doesn't outlast the mark + // it drifts over. + const snowOn = !input.still && alpha === 1 const grid: MarkCell[][] = [] for (let row = 0; row < shape.rows; row++) {