Skip to content

Commit 111f196

Browse files
committed
Add falling pixel snow over the landing mountain mark
1 parent 4336578 commit 111f196

2 files changed

Lines changed: 148 additions & 16 deletions

File tree

src/tui-opentui/mark-anim.test.ts

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test"
22

33
import {
44
MARK_PERIOD_SECONDS,
5+
SNOW_CHAR,
56
markFrame,
67
markText,
78
renderMark,
89
smooth,
910
} from "./mark-anim"
10-
import { MARK_COLS, MARK_COVERAGE, MARK_LARGE, MARK_ROWS } from "./mark-shape"
11+
import { MARK_COLS, MARK_LARGE, MARK_ROWS, MARK_SMALL } from "./mark-shape"
1112
import { UI } from "./theme"
1213

14+
const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█"
15+
16+
const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char)
17+
const isSnow = (char: string): boolean => char === SNOW_CHAR
18+
1319
describe("smooth", () => {
1420
test("clamps outside [0, 1] and eases inside it", () => {
1521
expect(smooth(-3)).toBe(0)
@@ -62,20 +68,36 @@ describe("markFrame", () => {
6268
})
6369

6470
describe("renderMark", () => {
65-
const emptyCells = (grid: readonly (readonly { char: string }[])[]): number =>
66-
grid.flat().filter((cell) => cell.char === " ").length
71+
/** Mountain-block weight only — snow must not pollute silhouette metrics. */
72+
const mountainWeight = (
73+
grid: readonly (readonly { char: string }[])[],
74+
): number =>
75+
grid
76+
.flat()
77+
.reduce((sum, cell) => sum + Math.max(0, MOUNTAIN_CHARS.indexOf(cell.char) + 1), 0)
78+
79+
const mountainCells = (
80+
grid: readonly (readonly { char: string }[])[],
81+
): number => grid.flat().filter((cell) => isMountain(cell.char)).length
6782

6883
test("is the mark's cell dimensions", () => {
6984
const grid = renderMark({ nowMs: 0, still: true })
7085
expect(grid).toHaveLength(MARK_ROWS)
7186
for (const row of grid) expect(row).toHaveLength(MARK_COLS)
7287
})
7388

74-
test("never paints outside the silhouette", () => {
75-
const grid = renderMark({ nowMs: 2000, still: false })
89+
test("sky is empty or snow; mountain cells never hold snow", () => {
90+
const grid = renderMark({ nowMs: 2000, still: false, grid: MARK_LARGE })
7691
grid.forEach((row, y) => {
7792
row.forEach((cell, x) => {
78-
if ((MARK_COVERAGE[y]?.[x] ?? 0) === 0) expect(cell.char).toBe(" ")
93+
const coverage = MARK_LARGE.coverage[y]?.[x] ?? 0
94+
if (coverage === 0) {
95+
expect(cell.char === " " || isSnow(cell.char)).toBe(true)
96+
if (isSnow(cell.char)) expect(cell.fg).toBe(UI.textFaint)
97+
} else if (isMountain(cell.char)) {
98+
expect(cell.fg).toBe(UI.action)
99+
expect(isSnow(cell.char)).toBe(false)
100+
}
79101
})
80102
})
81103
})
@@ -84,17 +106,19 @@ describe("renderMark", () => {
84106
const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE })
85107
grid.forEach((row, y) => {
86108
row.forEach((cell, x) => {
87-
expect(" ▁▂▃▄▅▆▇█").toContain(cell.char)
109+
// Still mode has no snow — only space or mountain blocks.
110+
expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char)
88111
if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█")
89112
})
90113
})
91114
})
92115

93-
test("the still frame is clock-independent", () => {
116+
test("the still frame is clock-independent and has no snow", () => {
94117
const a = markText(renderMark({ nowMs: 0, still: true }))
95118
const b = markText(renderMark({ nowMs: 987_654, still: true }))
96119
expect(b).toBe(a)
97120
expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0)
121+
expect(a.includes(SNOW_CHAR)).toBe(false)
98122
})
99123

100124
test("the animated frame advances with the injected clock", () => {
@@ -105,14 +129,14 @@ describe("renderMark", () => {
105129
})
106130

107131
test("the outline reveals left to right", () => {
108-
// Early in the draw phase only the leftmost columns may be lit.
132+
// Early in the draw phase only the leftmost mountain columns may be lit.
109133
const grid = renderMark({ nowMs: 0.06 * MARK_PERIOD_SECONDS * 1000, still: false })
110134
const lit = grid.flatMap((row) =>
111-
row.flatMap((cell, col) => (cell.char === " " ? [] : [col])),
135+
row.flatMap((cell, col) => (isMountain(cell.char) ? [col] : [])),
112136
)
113137
expect(Math.max(...lit, -1)).toBeLessThan(MARK_COLS)
114138
const full = renderMark({ nowMs: 0.4 * MARK_PERIOD_SECONDS * 1000, still: false })
115-
expect(emptyCells(grid)).toBeGreaterThan(emptyCells(full))
139+
expect(mountainCells(full)).toBeGreaterThan(mountainCells(grid))
116140
})
117141

118142
test("the fade thins the mark out toward empty", () => {
@@ -121,12 +145,10 @@ describe("renderMark", () => {
121145
nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000,
122146
still: false,
123147
})
124-
expect(emptyCells(fading)).toBeGreaterThan(emptyCells(held))
148+
expect(mountainCells(held)).toBeGreaterThan(mountainCells(fading))
125149
})
126150

127151
test("filling makes the mark denser than its outline alone", () => {
128-
const weight = (grid: readonly (readonly { char: string }[])[]): number =>
129-
grid.flat().reduce((sum, cell) => sum + " ▁▂▃▄▅▆▇█".indexOf(cell.char), 0)
130152
const outlineOnly = renderMark({
131153
nowMs: 0.42 * MARK_PERIOD_SECONDS * 1000,
132154
still: false,
@@ -135,6 +157,56 @@ describe("renderMark", () => {
135157
nowMs: 0.8 * MARK_PERIOD_SECONDS * 1000,
136158
still: false,
137159
})
138-
expect(weight(filled)).toBeGreaterThan(weight(outlineOnly))
160+
expect(mountainWeight(filled)).toBeGreaterThan(mountainWeight(outlineOnly))
161+
})
162+
163+
test("snow drifts over time without overwriting the silhouette", () => {
164+
// Sample across several seconds so flakes advance even at a slow fall rate.
165+
const times = [0, 1500, 3000, 4500, 6000, 7500]
166+
const snowSets = times.map((nowMs) => {
167+
const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE })
168+
const snow: string[] = []
169+
grid.forEach((row, y) => {
170+
row.forEach((cell, x) => {
171+
if (isSnow(cell.char)) {
172+
snow.push(`${y},${x}`)
173+
// Flakes live only in sky cells — never on mountain coverage.
174+
expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0)
175+
}
176+
})
177+
})
178+
return snow.join("|")
179+
})
180+
181+
const withSnow = snowSets.filter((s) => s.length > 0)
182+
expect(withSnow.length).toBeGreaterThan(1)
183+
expect(new Set(withSnow).size).toBeGreaterThan(1)
184+
185+
// During the full-hold phase the ridgeline dominates the flake field.
186+
const held = renderMark({
187+
nowMs: 0.82 * MARK_PERIOD_SECONDS * 1000,
188+
still: false,
189+
grid: MARK_LARGE,
190+
})
191+
let flakes = 0
192+
let mountains = 0
193+
held.forEach((row, y) => {
194+
row.forEach((cell, x) => {
195+
if (isSnow(cell.char)) {
196+
flakes += 1
197+
expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0)
198+
}
199+
if (isMountain(cell.char)) mountains += 1
200+
})
201+
})
202+
expect(mountains).toBeGreaterThan(20)
203+
expect(mountains).toBeGreaterThan(flakes)
204+
})
205+
206+
test("still mode freezes the mark with no snow motion", () => {
207+
const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL })
208+
const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL })
209+
expect(markText(b)).toBe(markText(a))
210+
expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false)
139211
})
140212
})

src/tui-opentui/mark-anim.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
* wave; at hero size a terminal renders that as visible noise rather than as
88
* shimmer, so the terminal mark is opaque instead.
99
*
10+
* Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the
11+
* same injected clock. Density and speed stay low so the ridgeline keeps its
12+
* silhouette; `still` (idle or reduced motion) freezes the mark and drops the
13+
* snow entirely. Mountain cells always win over flakes.
14+
*
1015
* Everything here is pure and clock-injected: `nowMs` is the only time source,
1116
* so the caller's existing 250 ms status tick drives the animation and tests
1217
* drive it deterministically. There is no timer in this module.
@@ -70,6 +75,18 @@ const EIGHTHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as cons
7075
*/
7176
const FILL_GAMMA = 0.6
7277

78+
/** One snowflake pixel. Exported so tests can distinguish sky from mountain. */
79+
export const SNOW_CHAR = "·"
80+
81+
/**
82+
* Fraction of columns that host a flake. Kept low so the sky reads as empty
83+
* with occasional drift rather than a storm.
84+
*/
85+
const SNOW_COLUMN_FRACTION = 0.18
86+
87+
/** Baseline rows-per-second fall rate. Slow enough to feel like drift. */
88+
const SNOW_FALL_SPEED = 0.55
89+
7390
export type MarkCell = {
7491
readonly char: string
7592
readonly fg: string
@@ -83,6 +100,35 @@ export type MarkInput = {
83100
readonly grid?: MarkGrid
84101
}
85102

103+
/**
104+
* Stable unit hash in [0, 1) from integer seeds. Pure and clock-independent so
105+
* flake columns and phases never jitter between frames.
106+
*/
107+
function unitHash(a: number, b = 0): number {
108+
const n = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263)
109+
const x = Math.imul(n ^ (n >>> 13), 1274126177)
110+
return ((x >>> 0) % 10_000) / 10_000
111+
}
112+
113+
/**
114+
* Whether a sky cell at (row, col) holds a flake at `seconds`. Sparse columns
115+
* only; each active column carries one flake with a private phase and a slight
116+
* speed variation so the field does not march as a rigid lattice.
117+
*/
118+
function snowflakeAt(
119+
row: number,
120+
col: number,
121+
seconds: number,
122+
rows: number,
123+
): boolean {
124+
if (rows <= 0) return false
125+
if (unitHash(col, 1) > SNOW_COLUMN_FRACTION) return false
126+
const phase = unitHash(col, 2) * rows
127+
const speed = SNOW_FALL_SPEED * (0.75 + unitHash(col, 3) * 0.5)
128+
const wrapped = (((seconds * speed + phase) % rows) + rows) % rows
129+
return Math.floor(wrapped) === row
130+
}
131+
86132
/**
87133
* Composite one frame into a row-major cell grid.
88134
*
@@ -91,6 +137,9 @@ export type MarkInput = {
91137
* slopes instead of staircasing. No dither texture survives inside the shape —
92138
* the mark is a mountain, and a mountain is opaque.
93139
*
140+
* Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never
141+
* overwrite mountain coverage, and `still` suppresses them entirely.
142+
*
94143
* `alpha` has no terminal equivalent, so it scales the block height instead:
95144
* the mark sinks toward empty rather than blending to black.
96145
*/
@@ -100,6 +149,7 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
100149
const { drawProg, fillProg, alpha } = markFrame(seconds, input.still)
101150
const revealed = drawProg * shape.cols
102151
const fillLine = shape.rows * (1 - fillProg)
152+
const snowOn = !input.still
103153

104154
const grid: MarkCell[][] = []
105155
for (let row = 0; row < shape.rows; row++) {
@@ -110,7 +160,17 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] {
110160
const coverage = shape.coverage[row]?.[col] ?? 0
111161
const reveal = clamp01(revealed - col)
112162
if (coverage === 0 || reveal === 0) {
113-
cells.push({ char: " ", fg: UI.action })
163+
// Snow only in true sky. Unrevealed mountain cells stay empty so the
164+
// left-to-right draw still reads as a clean silhouette edge.
165+
if (
166+
snowOn &&
167+
coverage === 0 &&
168+
snowflakeAt(row, col, seconds, shape.rows)
169+
) {
170+
cells.push({ char: SNOW_CHAR, fg: UI.textFaint })
171+
} else {
172+
cells.push({ char: " ", fg: UI.action })
173+
}
114174
continue
115175
}
116176
// The outline states the shape at its true coverage; filling lifts it

0 commit comments

Comments
 (0)