Skip to content

Commit 49b77ea

Browse files
committed
Let snow fall over the landing mark
The sky above the ridgeline was dead space. A sparse field of pixel snow drifts through it on the clock the mark already runs on, so the hero has motion even while the silhouette is held. Density and fall rate stay low deliberately: the mark has to keep reading as a mark, and a storm would turn the one legible thing on the screen into texture. Flakes never land on mountain coverage, and they stop entirely when the mark is held still or is fading out, so the decoration never outlives the thing it drifts over. Absorbed from the standalone snow branch rather than reimplemented; the taste question it was open on is settled by making snow part of the default landing.
1 parent a1dd9fd commit 49b77ea

2 files changed

Lines changed: 159 additions & 16 deletions

File tree

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

Lines changed: 96 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,65 @@ 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)
211+
})
212+
213+
test("snow drops out during the fade-out phase, matching the mark", () => {
214+
const fading = renderMark({
215+
nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000,
216+
still: false,
217+
grid: MARK_LARGE,
218+
})
219+
expect(fading.flat().some((cell) => isSnow(cell.char))).toBe(false)
139220
})
140221
})

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

Lines changed: 63 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,9 @@ 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+
// Fade out drops the snow too so the decoration doesn't outlast the mark
153+
// it drifts over.
154+
const snowOn = !input.still && alpha === 1
103155

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

0 commit comments

Comments
 (0)