Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions imgs/icon_anime/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Process-wide singleton used by `fount logo`, the CLI log viewer, and the foregro
| `intro` | Enter animation, then background `hold` (no park) |
| `start` / `dismiss` | log_viewer while waiting for the server (`start` no-op if already running; `dismiss` when connected); server `dismiss`es when `init` returns `started` |
| `farewell` | On `on_shutdown` (safe mid-intro, e.g. `already_running`) |
| `signal` / `abort` | User abort of this icon session (Ctrl+C or hold Esc ≥4s). `dismiss` does not touch it |
| `signal` / `abort` | User abort of this icon session (Ctrl+C or hold Esc ≥4s, one-shot). `dismiss` does not touch it. Further ESC repeats / Ctrl+C are ignored until the next `player.start` so farewell exit can finish. |

Hosts own process-exit signaling and must wire `icon.signal` into it (log_viewer and server do). Non-TTY / no VT is gated only in `player.mjs` — session APIs stay callable (play paths no-op). On the alternate screen, `player` `block`/`unblock`s the global virtual console so console output is deferred; frame paint writes the native `targetStream` so the animation itself is not deferred.

Expand All @@ -31,7 +31,7 @@ fount logo watch # deno run --watch
fount test icon_anime --no-parallel
```

Controls: Ctrl+C or hold Esc ≥4s exits (icon teardown, then quit). Left quick-click → ripple; left hold/drag → cool spotlight (ambient dims). Right-drag → stroke wind; right long-still → tornado vortex (can suspend rain and lift free-liquid puddles). Alt-screen (`1049h`/`1049l`) restores prior scrollback on exit.
Controls: Ctrl+C or hold Esc ≥4s exits (teardown plays farewell exit, then quit). Left quick-click → ripple; left hold/drag → cool spotlight (ambient dims). Right-drag → stroke wind; right long-still → tornado vortex (can suspend rain and lift free-liquid puddles). Alt-screen (`1049h`/`1049l`) restores prior scrollback on exit.

## Modules

Expand Down Expand Up @@ -75,8 +75,10 @@ Open-stage: ungrown base columns do not splash — rain falls through until it h
- **One pressure language** / **one density language** — see [physics-notes.md](physics-notes.md). Do not invent parallel hydro models.
- **Viscosity ladder** is the sole branch knob: `≤ VISC_INERTIAL` → inertial gas velocity; `< VISC_SOLID` → Stokes mass flux; `≥ VISC_SOLID` → frozen.
- Water mass = `liq + moisture + condense + particles` (`totalWorldWater`); melt is separate. Closed transfers conserve; intentional sinks are world-edge / down-edge wipe / BODY impact. Particle expiry deposits back.
- Soil condense hangs on the gravity-down face; when ĝ leaves that open underside it reabsorbs into moisture. Drip glyphs follow `condenseDripSource` (ĝ), not screen-down.
- Terrain is **pedestal-anchored**; ungrown base keeps `HORIZON` until `POOL`/`SLOPE_*` overwrite. Resize shifts retained dynamics with the icon.
- Gravity is a continuous unit vector everywhere (particles + grid). Depth = projection on ĝ; neighbor transfer uses weights `max(0, d̂·ĝ)`.
- Four edges hold fractional roles `sink/source/wrap` from `n̂·ĝ` (sum to 1). Lava onset is **exposure work** `exposure[e] = max(0, exposure[e] + n̂·ĝ)` with decay when flipped (≥ `LAVA_ONSET_EXPOSURE`); 45° → two edges each need ~13·√2 s.
- Gravity acquire: `document` → browser APIs; Termux → `termux-sensor` (pretty JSON indent=2, `parseSensorStdout`); else no-op. path CLI installs `termux-api` on `fount logo` / `log` / `server` when missing.
- Rain edges weighted by `source`; side wrap by `wrap`. Meltdown absorb records absorb-time ĝ; regurgitate when `ĝ·absorbDir` drops.
- Composition bottom (pedestal / lava edge) never rains — even when inverted ĝ makes it a physical sky; quiet until sink-edge exposure yields lava.
11 changes: 6 additions & 5 deletions imgs/icon_anime/compose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
*/

import {
MAT, LIQ_DRAW, COND_DRAW, BUBBLE_MIN_CELLS,
isLiquidBarrier, isSoilMat, waterChar, liquidChar, dripChar, lavaChar,
MAT, LIQ_DRAW, BUBBLE_MIN_CELLS,
isLiquidBarrier, waterChar, liquidChar, dripChar, lavaChar,
condenseDripSource,
} from './fluid/index.mjs'
import { sampleLight, RIPPLE_SPEED, RIPPLE_WIDTH, torchEase } from './gesture/light.mjs'
import { ICON_W, ICON_BODY_H, PILLARS, BODY_DIST, maxBodyD } from './icon.mjs'
Expand Down Expand Up @@ -477,9 +478,9 @@ export const composeFrame = (state) => {
fg[i] = FG_BUBBLE
}
else {
const above = vy > 0 ? (vy - 1) * W + wx : -1
if (above >= 0 && isSoilMat(mat[above]) && condense[above] >= COND_DRAW) {
ch[i] = dripChar(condense[above], wx + frame)
const dripSoil = condenseDripSource(world, wx, vy)
if (dripSoil >= 0) {
ch[i] = dripChar(condense[dripSoil], wx + frame)
fg[i] = FG_SPLASH
}
else if (solid[wi] && vy === surface[wx]) {
Expand Down
2 changes: 1 addition & 1 deletion imgs/icon_anime/fluid/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export {
} from './gas.mjs'

/** 土壤湿度 / 凝结 / 滴落。 */
export { stepSoil } from './soil.mjs'
export { stepSoil, condenseDripSource } from './soil.mjs'

/** 热力与相变。 */
export { stepThermal, cellRho, meltVisc } from './thermal.mjs'
Expand Down
161 changes: 139 additions & 22 deletions imgs/icon_anime/fluid/soil.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* 土壤湿度 / 凝结 / 滴落。
*
* 吸收上方自由液体、土壤间渗流、天花板凝结、Matthew 邻格转移、COND_DRIP 滴落。
* 由 `stepLiquid` 在水力均衡前调用。
* 凝结膜挂在重力下沿;ĝ 转离开放下沿时收回水分。由 `stepLiquid` 在水力均衡前调用。
*/

import {
MAT, SOIL_CAP,
SOIL_ABSORB_RATE, SOIL_SIDE_FRAC, SOIL_DOWN_FRAC, SOIL_CONDENSE_FRAC,
COND_DRIP, COND_MATTHEW_RATE, COND_MATTHEW_NOISE,
COND_DRAW, COND_DRIP, COND_MATTHEW_RATE, COND_MATTHEW_NOISE,
isSoilMat, isLiquidBarrier, soilAbsorbFactor,
} from './mat.mjs'
import {
Expand Down Expand Up @@ -41,6 +41,14 @@ const soilQueues = {
feedN: 0,
}

/** 正交邻格,用于收回后溢流。 */
const ORTHOGONAL_OFFSETS = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 将 `moisture[cell]` 钳在 `[0, SOIL_CAP]`。
* @param {Float32Array} moisture 土壤湿度场
Expand Down Expand Up @@ -90,7 +98,89 @@ const pushFeed = (world, queues, from, amount) => {
}

/**
* 土壤渗流:吸收自由液体、共享湿度、供给凝结、Matthew 转移、滴落。
* 空气格是否应显示来自重力上方土壤的悬挂凝结。
* @param {FluidWorld} world 世界
* @param {number} x 列
* @param {number} y 行
* @returns {number} 土壤格索引,无则 -1
*/
export const condenseDripSource = (world, x, y) => {
const { mat, condense, worldW } = world
const up = gravityUpWeights(world)
for (let offsetIndex = 0; offsetIndex < up.n; offsetIndex++) {
const soilX = x + up.dx[offsetIndex]
const soilY = y + up.dy[offsetIndex]
if (!inWorld(world, soilX, soilY)) continue
const soil = soilY * worldW + soilX
if (isSoilMat(mat[soil]) && condense[soil] >= COND_DRAW) return soil
}
return -1
}

/**
* 重力下沿是否仍为开放空气(悬挂凝结的附着面)。
* @param {FluidWorld} world 世界
* @param {number} x 列
* @param {number} y 行
* @param {{ dx: number[], dy: number[], w: number[], n: number }} down 下向权重
* @returns {boolean} 是否开放
*/
const hasOpenUnderside = (world, x, y, down) => {
const { mat, worldW } = world
for (let offsetIndex = 0; offsetIndex < down.n; offsetIndex++) {
const belowX = x + down.dx[offsetIndex]
const belowY = y + down.dy[offsetIndex]
if (!inWorld(world, belowX, belowY)) continue
if (mat[belowY * worldW + belowX] === MAT.AIR) return true
}
return false
}

/**
* 将多余质量溢到邻接空气(按下向权重分配,再任意正交)。
* @param {FluidWorld} world 世界
* @param {number} x 列
* @param {number} y 行
* @param {number} amount 质量
* @param {{ dx: number[], dy: number[], w: number[], n: number }} down 下向权重
* @returns {number} 实际写入量
*/
const spillToAir = (world, x, y, amount, down) => {
if (amount <= 1e-8) return 0
let written = 0
let weightSum = 0
for (let offsetIndex = 0; offsetIndex < down.n; offsetIndex++) {
const belowX = x + down.dx[offsetIndex]
const belowY = y + down.dy[offsetIndex]
if (!inWorld(world, belowX, belowY)) continue
if (world.mat[belowY * world.worldW + belowX] !== MAT.AIR) continue
weightSum += down.w[offsetIndex]
}
if (weightSum > 1e-8)
for (let offsetIndex = 0; offsetIndex < down.n; offsetIndex++) {
const belowX = x + down.dx[offsetIndex]
const belowY = y + down.dy[offsetIndex]
if (!inWorld(world, belowX, belowY)) continue
if (world.mat[belowY * world.worldW + belowX] !== MAT.AIR) continue
written += addLiquid(world, belowX, belowY, amount * (down.w[offsetIndex] / weightSum))
}
let rest = amount - written
if (rest <= 1e-8) return written
for (const [offsetX, offsetY] of ORTHOGONAL_OFFSETS) {
const neighborX = x + offsetX
const neighborY = y + offsetY
if (!inWorld(world, neighborX, neighborY)) continue
if (world.mat[neighborY * world.worldW + neighborX] !== MAT.AIR) continue
const got = addLiquid(world, neighborX, neighborY, rest)
written += got
rest -= got
if (rest <= 1e-8) break
}
return written
}

/**
* 土壤渗流:吸收自由液体、共享湿度、供给凝结、Matthew 转移、滴落、收回。
* @param {FluidWorld} world 流体世界
*/
export const stepSoil = (world) => {
Expand Down Expand Up @@ -244,22 +334,27 @@ export const stepSoil = (world) => {
applyDelta(moveTargets, moveCount)
applyDelta(feedFrom, feedN)

// Matthew along gravity-perpendicular (sideB is the +1 direction of the pair).
const sideDx = sideB.dx
const sideDy = sideB.dy
for (let y = 0; y < H; y++)
for (let x = 0; x < W - 1; x++) {
for (let x = 0; x < W; x++) {
const nx = x + sideDx
const ny = y + sideDy
if (!inWorld(world, nx, ny)) continue
const cell = y * W + x
const right = cell + 1
if (!isSoilMat(mat[cell]) || !isSoilMat(mat[right])) continue
const other = ny * W + nx
if (!isSoilMat(mat[cell]) || !isSoilMat(mat[other])) continue
const leftCondense = condense[cell]
const rightCondense = condense[right]
const rightCondense = condense[other]
if (leftCondense < 1e-8 || rightCondense < 1e-8) continue
const mass = leftCondense + rightCondense
// Cheap LCG — Matthew only needs jitter, not cryptographic randomness.
const noise = ((((cell * 374761393) ^ (right * 668265263) ^ (step * 1274126177)) >>> 0) / 4294967296 - 0.5)
const noise = ((((cell * 374761393) ^ (other * 668265263) ^ (step * 1274126177)) >>> 0) / 4294967296 - 0.5)
* COND_MATTHEW_NOISE * mass
const bias = (leftCondense - rightCondense) + noise
if (Math.abs(bias) < 1e-8) continue
const rich = bias > 0 ? cell : right
const poor = bias > 0 ? right : cell
const rich = bias > 0 ? cell : other
const poor = bias > 0 ? other : cell
const take = Math.min(condense[poor] * COND_MATTHEW_RATE, Math.abs(bias) * COND_MATTHEW_RATE)
if (take <= 1e-8) continue
condense[poor] -= take
Expand All @@ -270,17 +365,39 @@ export const stepSoil = (world) => {
for (let x = 0; x < W; x++) {
const cell = y * W + x
if (!isSoilMat(mat[cell]) || condense[cell] < COND_DRIP) continue
for (let i = 0; i < down.n; i++) {
if (down.w[i] < 0.5) continue
const bx = x + down.dx[i]
const by = y + down.dy[i]
if (!inWorld(world, bx, by)) continue
const below = by * W + bx
if (mat[below] !== MAT.AIR) continue
const amount = condense[cell]
const added = addLiquid(world, bx, by, amount)
condense[cell] = amount - added
break
let weightSum = 0
for (let offsetIndex = 0; offsetIndex < down.n; offsetIndex++) {
const belowX = x + down.dx[offsetIndex]
const belowY = y + down.dy[offsetIndex]
if (!inWorld(world, belowX, belowY)) continue
if (mat[belowY * W + belowX] !== MAT.AIR) continue
weightSum += down.w[offsetIndex]
}
if (weightSum <= 1e-8) continue
const amount = condense[cell]
let written = 0
for (let offsetIndex = 0; offsetIndex < down.n; offsetIndex++) {
const belowX = x + down.dx[offsetIndex]
const belowY = y + down.dy[offsetIndex]
if (!inWorld(world, belowX, belowY)) continue
if (mat[belowY * W + belowX] !== MAT.AIR) continue
written += addLiquid(world, belowX, belowY, amount * (down.w[offsetIndex] / weightSum))
}
condense[cell] = amount - written
}

// ĝ 转离开放下沿 → 悬挂凝结收回(回潮,满则溢到邻接空气;溢不完留在 condense)。
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const cell = y * W + x
if (!isSoilMat(mat[cell]) || condense[cell] <= 1e-8) continue
if (hasOpenUnderside(world, x, y, down)) continue
const back = condense[cell]
const room = Math.max(0, SOIL_CAP - moisture[cell])
const toMoist = Math.min(back, room)
moisture[cell] += toMoist
const rest = back - toMoist
const spilled = rest > 1e-8 ? spillToAir(world, x, y, rest, down) : 0
condense[cell] = rest - spilled
}
}
4 changes: 2 additions & 2 deletions imgs/icon_anime/physics-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Day-to-day map / hosting: [AGENTS.md](AGENTS.md). Read this when changing fluid,
- Weighted ortho neighbors: `w = max(0, d̂·ĝ)` down / `d̂·(−ĝ)` up. Settle / buoyancy / bubbles / soil / free-surface follow these.
- Edge roles (`edgeRoles`): for outward normal n̂, `sink=max(0,n̂·ĝ)`, `source=max(0,−n̂·ĝ)`, `wrap=1−|n̂·ĝ|`.
- Exposure work: each tick `exposure[e] = max(0, exposure[e] + n̂_e·ĝ)`. Lava when `exposure[e] ≥ LAVA_ONSET_EXPOSURE` (312 under pure down = 13s@24fps). At 45°, two edges each accumulate cos45/frame → onset ≈ 13·√2 s.
- Rain spawn uses `source` weights (gravity-down edge never rains). Side wrap uses `wrap`; particles pick wrap vs out with `hash01`.
- Rain spawn uses `source` weights (gravity-down edge never rains). Composition bottom is never a rain sky (pedestal/lava edge) — inverted ĝ yields no rain there, then lava on the sink edge after exposure. Side wrap uses `wrap`; particles pick wrap vs out with `hash01`.
- Absorb on source-weighted edges records `absorbGx/Gy`; regurgitate when `ĝ·absorbDir < threshold`, ejecting on current source edges.

## Terrain
Expand Down Expand Up @@ -62,7 +62,7 @@ Day-to-day map / hosting: [AGENTS.md](AGENTS.md). Read this when changing fluid,
- `liqVx`/`liqVy`: EMA from mass transfers each `stepLiquid` — drives free-liquid glyphs.
- Communicating vessels: relax `φ = P/(ρg) - depth` along the liquid graph (BFS from lowest-φ surface — no teleport across disconnected air).
- `POOL` retains fill and spills/leaks; `BODY` is splash-only barrier; pillars are not materials.
- Soil: absorb diminishes as cell wets (`soilAbsorbFactor`); rain hits sink only `SOIL_HIT_ABSORB_FRAC`. Seepage slow enough for surface puddles. Sideways share + prefer below (gravity-weighted); air below → underside `condense`; Matthew transfer between condensation cells; `COND_DRAW` / `COND_DRIP` thresholds. Heating evaporates moisture before melt.
- Soil: absorb diminishes as cell wets (`soilAbsorbFactor`); rain hits sink only `SOIL_HIT_ABSORB_FRAC`. Seepage slow enough for surface puddles. Sideways share + prefer below (gravity-weighted); air below → underside `condense`; Matthew along ĝ⊥; `COND_DRAW` / `COND_DRIP` thresholds. When ĝ leaves an open underside, condense reabsorbs into moisture (excess spills to ortho air). Compose drips via `condenseDripSource` (gravity-up soil), not screen-Y. Heating evaporates moisture before melt.
- Material rebuild clears labels only; `releaseNonSoilWater` dumps moisture/condense from non-soil into free liquid so `POOL` overwrite does not erase water.
- `exit` stops when the icon is gone — no rain/liquid drain wait.

Expand Down
38 changes: 27 additions & 11 deletions imgs/icon_anime/player.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,37 @@ export const ESC_HOLD_GAP_MS = 500

/**
* ESC 长按计时:靠终端键重复确认仍在按住;无松开事件。
* 触发一次后保持闩锁,避免退场动画播放期间键重复再次 abort。
* @param {number} [holdMs] 触发中止的持续时长
* @param {number} [gapMs] 允许的最大重复间隔
* @returns {{ note: (now: number) => boolean, reset: () => void }} note 在达到 holdMs 时返回 true
* @returns {{ note: (now: number) => boolean, reset: () => void }} note 在达到 holdMs 时返回 true(仅一次)
*/
export function createEscHold(holdMs = ESC_HOLD_MS, gapMs = ESC_HOLD_GAP_MS) {
/** @type {number | null} */
let start = null
let last = 0
let fired = false
return {
/**
* @param {number} now 单调时钟(ms)
* @returns {boolean} 是否已连续按住满 holdMs
* @returns {boolean} 是否刚达到 holdMs(已触发过则恒 false)
*/
note(now) {
if (fired) return false
if (start == null || now - last > gapMs) start = now
last = now
return now - start >= holdMs
if (now - start < holdMs) return false
fired = true
return true
},
/** @returns {void} */
/**
* 清除 start、last、fired,重新允许一次触发。
* @returns {void}
*/
reset() {
start = null
last = 0
fired = false
},
}
}
Expand Down Expand Up @@ -200,23 +209,30 @@ export function start({ onResize, onPointer, onUserAbort } = {}) {
process.stdin.setRawMode(true)
process.stdin.resume()
const escHold = createEscHold()
/** 首次用户中止后闩上,避免 farewell 退场被 ESC 重复 / 连按 Ctrl+C 掐断。 */
let userAbortArmed = true
/**
* 触发一次用户中止(幂等)。
* @returns {void}
*/
const tripUserAbort = () => {
if (!userAbortArmed) return
userAbortArmed = false
abort()
onUserAbort?.()
}
/**
* Ctrl+C / 长按 ESC 中止;SGR 鼠标 → onPointer。
* @param {Buffer} buf stdin 块
* @returns {void}
*/
onData = (buf) => {
stdinCarry = consumeStdin(stdinCarry, buf, {
/** Ctrl+C:中止播放并通知会话。 */
abort: () => {
abort()
onUserAbort?.()
},
abort: tripUserAbort,
/** ESC 键重复:连续按住满 ESC_HOLD_MS 则中止。 */
onEsc: () => {
if (!escHold.note(performance.now())) return
abort()
onUserAbort?.()
tripUserAbort()
},
onPointer,
})
Expand Down
7 changes: 5 additions & 2 deletions imgs/icon_anime/scene.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,8 @@ const onParticleHit = (world, x, y, m, particle, wet, state) => {

/**
* 四边出雨权重(source 角色)。导出供测试。
* 组成底边(地形/岩浆侧)永不作出雨天:倒置时 ĝ 穿入底边会把雨从基座往上喷,
* 应静等 sink 边曝露后出岩浆,而不是底边冒雨。
* @param {number} gx 单位重力 x
* @param {number} gy 单位重力 y
* @returns {{ nx: number, ny: number, w: number }[]} 边权重
Expand All @@ -542,9 +544,10 @@ export const rainEdgeWeights = (gx, gy) => {
// source = max(0, −n̂·ĝ); sink edge never rains
e.w = Math.max(0, -dot)
}
// Pedestal / lava edge of the composition — never a rain sky.
edges[1].w = 0
// Side edges get a small base so left/right can randomly rain under default g.
const top = edges.find(e => e.ny < 0)
if (top && top.w > 0.5) {
if (edges[0].w > 0.5) {
edges[2].w = Math.max(edges[2].w, 0.12)
edges[3].w = Math.max(edges[3].w, 0.12)
}
Expand Down
Loading