An animated metaball lava lamp in a side pane, in wax-in-liquid colours: /lava-lamp toggles it; /lava-lamp [veryslow|slow|normal|fast]…

An animated lava lamp in a Claude Code side pane. Type /lava-lamp to open it and again to close it. Closing it stops its timer, so it costs nothing while hidden.
The wax behaves like a real lamp's. Blobs warm in the pool at the base and rise while warmer than the liquid. They cool under the cap and sink back. A blob whose temperature settles near the liquid's hovers mid-glass for a while. Blobs close to each other drag each other along, cling a little and trade heat, so their paths bend.
/lava-lamp [words…]. Words are case-insensitive, go in any order and can be combined. Your last choice is kept for the session, so a bare /lava-lamp reopens with it.
| Kind | Words |
|---|---|
| Speed | veryslow, slow, normal, fast |
| Colour | orange, yellow, green, purple, blue, pink. Each means a classic wax-in-liquid pair, e.g. orange is orange wax in violet. |
| Pairs | Any <wax>-<liquid>: wax and liquid from orange, yellow, green, purple, blue, pink, red, turquoise, white, black, violet; the liquid may also be clear, which lets your terminal's background show through. A colour used as liquid is drawn as a deep version of itself, and kept far enough from the wax to show it. Pairs taken from real lamps (e.g. orange-yellow, white-red, orange-black) keep their hand-tuned colours. |
| Rotate | rotate (or rotating) blends slowly through eight pairs. rotate-clear rotates only the wax, over a clear liquid. Any colour word turns rotation off. |
| Bubbles | few (big and heavy), medium, many (small and lively) |
| Outline | lamp, nolamp (hide the cap and base) |
| Close | off |
Examples: /lava-lamp turquoise-violet few slow, /lava-lamp orange-clear, /lava-lamp white-black many.
claude plugin test plugins/lava-lamp.MIT licensed.
hooks/register.tsx 110 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { DEFAULT_OPTIONS, DT_MS, HELP, advanceFrame, createSim, describe, frame, parseArgs } from './lamp'
5import type { Sim } from './lamp'
6import type { LavaOptions } from '../types'
7
8const PANE = 'lava-lamp'
9const FRAME_MS = 100
10// Lamp time in ms (what `rotate` reads), kept so a reload does not restart the colours.
11// The wax itself is `lamp` below.
12const clock = atom({ plugin: 'lava-lamp', key: 'clockMs' } as const, 0)
13const options = atom({ plugin: 'lava-lamp', key: 'options' } as const, DEFAULT_OPTIONS)
14
15// Module state: a reload starts this over (and the engine cancels the old
16// timer), so the render hook restarts the clock when it finds none running, and
17// the wax starts again from a freshly settled lamp.
18let timer: Timer | undefined
19let lamp: Sim | undefined
20
21// The live simulation, made (settled) when there is none or when the blob count or
22// outline changed. Called after the caller's last await, so no other caller can
23// interleave between the check and the assignment.
24function simFor(look: LavaOptions, persistedMs: number): Sim {
25 const bubbles = look.bubbles ?? 'medium'
26 if (lamp === undefined || lamp.bubbles !== bubbles || lamp.lamp !== look.lamp)
27 lamp = createSim({ bubbles, lamp: look.lamp }, lamp?.clock ?? Math.round(persistedMs / DT_MS))
28 return lamp
29}
30
31function start($: EngineInterface) {
32 if (timer !== undefined) return
33 timer = $.clock.every(FRAME_MS, () => {
34 void (async () => {
35 const look = await read($, options)
36 const persistedMs = await read($, clock)
37 const sim = simFor(look, persistedMs)
38 advanceFrame(sim, look.speed)
39 const ms = sim.clock * DT_MS
40 await update($, clock, () => ms)
41 })()
42 })
43}
44
45function stop() {
46 timer?.cancel()
47 timer = undefined
48}
49
50const HINT =
51 '[veryslow|slow|normal|fast] [orange|yellow|green|purple|blue|pink|rotate|rotate-clear] [<wax>-<liquid>, e.g. orange-clear, turquoise-black, white-violet] [few|medium|many] [lamp|nolamp] [off]'
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 await $.command.register({ name: 'lava-lamp', description: 'Toggle the lava lamp pane, or set its speed, wax-in-liquid colours (any <wax>-<liquid>, or rotating colours), bubble count and outline', argumentHint: HINT })
56 return next(e)
57 })
58
59 on('command.run', { command: 'lava-lamp' }, async ($, e) => {
60 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
61 const parsed = parseArgs(e.args, await read($, options))
62 if (!parsed.ok)
63 return { text: `Lava lamp: unknown ${parsed.unknown.map(w => `"${w}"`).join(', ')}. Nothing changed. Words: ${HELP}.` }
64 if (parsed.words > 0) await update($, options, () => parsed.options)
65 const now = describe(parsed.options)
66 // Bare `/lava-lamp` toggles; `off` closes; any other words open (or keep open).
67 if (parsed.off || (parsed.words === 0 && isOpen)) {
68 if (isOpen) await $.ui.close({ id: PANE })
69 stop()
70 return { text: parsed.words > 1 ? `Lava lamp off (next time: ${now}).` : 'Lava lamp off.' }
71 }
72 if (!isOpen) {
73 const opened = await $.ui.open({ id: PANE, title: 'Lava lamp' })
74 if (!opened.isPlaced) return { text: `Lava lamp not shown: ${opened.reason}` }
75 }
76 start($)
77 return { text: `Lava lamp: ${now}.${parsed.words === 0 ? ' /lava-lamp again to turn it off.' : ''}` }
78 })
79
80 on('ui.close', async ($, e, next) => {
81 if (e.id === PANE) stop()
82 return next(e)
83 })
84
85 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
86 const { Box, Text } = $.ui.resolve(e)
87 start($)
88 const persistedMs = await read($, clock)
89 const look = await read($, options)
90 const columns = Math.max(1, e.props.bodyColumns)
91 const viewRows = e.viewport?.rows ?? 24
92 const rows =
93 e.props.placement === 'dock' ? Math.max(4, viewRows - 6) : Math.max(4, Math.min(16, Math.floor(viewRows / 2)))
94 const lines = frame(columns, rows, simFor(look, persistedMs), look)
95 return (
96 <Box flexDirection="column">
97 {lines.map(row => (
98 <Text wrap="truncate-end">
99 {row.map(seg => (
100 <Text color={seg.color} backgroundColor={seg.backgroundColor}>
101 {seg.text}
102 </Text>
103 ))}
104 </Text>
105 ))}
106 </Box>
107 )
108 })
109}
110hooks/lamp.ts 880 lines1// Pure lava-lamp maths: a small physics simulation of wax blobs in a heated
2// glass, drawn as a metaball field sampled on half-block pixels (two square-ish
3// pixels per terminal cell), shaded, then packed into runs.
4//
5// The motion is simulated, not scripted. Each blob carries a position, a
6// velocity, a temperature and whether it is afloat. Afloat wax rises until it has
7// cooled below T_LO; sinking wax sinks until it has warmed above T_HI. The liquid
8// stays between the two, so only the bulb (at the base) and the cap (at the top)
9// flip a blob: wax leaves the pool hot and fast and slows as it cools toward the
10// liquid, and leaves the cap cold and fast and slows as it warms. The liquid is thick
11// (strong drag), so everything is slow and smooth. Blobs also act on each other,
12// each force fading to nothing a few radii out: a moving blob drags a neighbour
13// toward its own velocity (entrainment), wax clings weakly to wax while a soft
14// core keeps blobs apart, and blobs in touch trade heat.
15//
16// The simulation advances in fixed steps of DT seconds. A speed word only changes
17// how many steps run per drawn frame, so one seed traces one path at every speed.
18//
19// Colour is a wax-in-liquid pair: a wax gradient (edge, mid, hot core) over a
20// liquid gradient (top to bottom, brighter at the bulb). Any `<wax>-<liquid>` word
21// parses: the liquid is the colour's own liquid (hand-tuned where one exists, else
22// derived from its wax), nudged until the wax stands off it (see `guarded`), unless a
23// hand-tuned pair of that name exists, which is used exactly as tuned.
24// A `clear` liquid has no colour of its own, so the terminal background shows.
25
26import type { LavaBubbles, LavaColourWord, LavaLiquidColour, LavaOptions, LavaPair, LavaPalette, LavaSpeed, LavaWaxColour } from '../types'
27
28// An ellipse of influence: centre (u across, v down) and radii, all in
29// glass-height units. `vel` is the vertical speed (glass heights per second,
30// positive downwards).
31export type Blob = { u: number; v: number; rx: number; ry: number; vel: number; kind: 'pool' | 'cap' | 'wax' }
32
33// One run of identical cells within a row: `text` is `text.length` cells wide.
34// An absent colour means the terminal's own (a distinct value when runs merge).
35export type Segment = { text: string; color?: string; backgroundColor?: string }
36
37// The glass in field units: its half-width at depth v, and the widest half-width.
38export type Glass = { half: (v: number) => number; widest: number }
39
40export const THRESHOLD = 1
41
42// ---------------------------------------------------------------- colour tables
43
44type RGB = [number, number, number]
45
46// `clear`: the liquid draws no background; `top` and `bottom` are then unused.
47type Scheme = { top: RGB; bottom: RGB; edge: RGB; mid: RGB; hot: RGB; clear: boolean }
48
49type Wax = { edge: RGB; mid: RGB; hot: RGB }
50type Liquid = { top: RGB; bottom: RGB }
51
52const WAX = {
53 orange: { edge: [150, 18, 20], mid: [235, 90, 20], hot: [255, 214, 90] },
54 // A deeper orange, so it stands out from the light yellow liquid it floats in.
55 orangeDeep: { edge: [128, 26, 6], mid: [206, 62, 8], hot: [238, 110, 22] },
56 // Amber at the rim, gold between, pale lemon at the core.
57 yellow: { edge: [176, 104, 0], mid: [244, 184, 16], hot: [255, 250, 170] },
58 green: { edge: [22, 112, 34], mid: [96, 204, 44], hot: [224, 255, 128] },
59 purple: { edge: [118, 34, 156], mid: [172, 64, 214], hot: [244, 178, 255] },
60 blue: { edge: [44, 112, 206], mid: [104, 186, 252], hot: [216, 246, 255] },
61 pink: { edge: [164, 22, 92], mid: [246, 84, 152], hot: [255, 204, 196] },
62 turquoise: { edge: [8, 118, 128], mid: [38, 212, 198], hot: [198, 255, 244] },
63 red: { edge: [118, 8, 20], mid: [240, 48, 52], hot: [255, 150, 118] },
64 white: { edge: [168, 160, 172], mid: [232, 228, 236], hot: [255, 255, 255] },
65 // Charcoal with a dim cool highlight at the core, so it still reads against a dark liquid.
66 black: { edge: [10, 10, 14], mid: [52, 54, 66], hot: [124, 134, 166] },
67 // Bluer and deeper than purple.
68 violet: { edge: [52, 24, 140], mid: [108, 66, 232], hot: [190, 160, 255] },
69} satisfies Record<string, Wax>
70
71// Every colour that can be the wax, and every liquid: the same colours, and `clear`.
72export const WAX_COLOURS: LavaWaxColour[] = ['orange', 'yellow', 'green', 'purple', 'blue', 'pink', 'red', 'turquoise', 'white', 'black', 'violet']
73export const LIQUID_COLOURS: LavaLiquidColour[] = [...WAX_COLOURS, 'clear']
74
75// Deep, saturated liquids; the bottom is the brighter end (the bulb's glow).
76const VIOLET: Liquid = { top: [50, 12, 100], bottom: [78, 22, 144] }
77const ROYAL_BLUE: Liquid = { top: [10, 24, 108], bottom: [16, 46, 160] }
78const PLUM: Liquid = { top: [40, 4, 34], bottom: [68, 8, 54] }
79const RED_LIQUID: Liquid = { top: [108, 6, 12], bottom: [158, 10, 20] }
80const BLACK_LIQUID: Liquid = { top: [4, 3, 6], bottom: [16, 9, 9] }
81// The light liquids: a luminous yellow, and a pale ivory.
82const LIGHT_YELLOW: Liquid = { top: [244, 214, 84], bottom: [255, 232, 122] }
83const IVORY: Liquid = { top: [238, 232, 214], bottom: [252, 248, 234] }
84
85// Rec. 709 luma of an RGB, 0..255: the one measure of "how far apart" the wax and liquid are.
86const luma = (c: readonly number[]) => 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!
87
88// A liquid derived from a colour: its wax's hue, saturated to at least LIQUID_MIN_SAT,
89// at value LIQUID_TOP_V at the top and LIQUID_BOTTOM_V at the bottom (the hand-tuned
90// liquids sit about there): a deep, darkened version of the colour, never grey.
91const LIQUID_MIN_SAT = 0.9
92const LIQUID_TOP_V = 0.4
93const LIQUID_BOTTOM_V = 0.58
94
95function deriveLiquid(wax: Wax): Liquid {
96 const [r, g, b] = wax.mid
97 const max = Math.max(r, g, b)
98 const span = max - Math.min(r, g, b)
99 let hue = 0
100 if (span > 0) hue = max === r ? ((g - b) / span + 6) % 6 : max === g ? (b - r) / span + 2 : (r - g) / span + 4
101 const sat = Math.max(LIQUID_MIN_SAT, max === 0 ? 0 : span / max)
102 const at = (v: number): RGB => {
103 const channel = (n: number) => 255 * v * (1 - sat * Math.max(0, Math.min((n + hue) % 6, 4 - ((n + hue) % 6), 1)))
104 return [channel(5), channel(3), channel(1)]
105 }
106 return { top: at(LIQUID_TOP_V), bottom: at(LIQUID_BOTTOM_V) }
107}
108
109// Where a colour has a hand-tuned liquid it is used; the others are derived.
110const LIQUID_TUNED: Partial<Record<LavaWaxColour, Liquid>> = {
111 violet: VIOLET,
112 blue: ROYAL_BLUE,
113 pink: PLUM,
114 yellow: LIGHT_YELLOW,
115 red: RED_LIQUID,
116 black: BLACK_LIQUID,
117 white: IVORY,
118}
119const liquidOf = (colour: LavaWaxColour): Liquid => LIQUID_TUNED[colour] ?? deriveLiquid(WAX[colour])
120
121// The contrast guard: the wax's mid colour and each end of the liquid differ by at least
122// CONTRAST_MARGIN of luma (0..255). A liquid already clear of it is left alone. Otherwise it is
123// pushed the way it already leans: a lighter liquid lighter, a darker one darker, switching to
124// the other way when there is no room (a liquid cannot go below DARKEST_LIQUID_LUMA, or
125// above white). Darkening scales both ends by one factor, so the hue and the top-to-bottom
126// gradient stay; lightening raises the top to the wax's luma plus the margin, and the bottom
127// as far above that as it was above the top (up to white), scaling up (at most LIFT_CAP,
128// keeping its saturation) and then mixing toward white.
129export const CONTRAST_MARGIN = 50
130const DARKEST_LIQUID_LUMA = 12
131const LIFT_CAP = 2.5
132
133function raised(c: RGB, target: number): RGB {
134 const scale = Math.min(LIFT_CAP, target / Math.max(luma(c), 1))
135 const scaled = c.map(x => Math.min(255, x * scale)) as RGB
136 const l = luma(scaled)
137 if (l >= target) return scaled
138 const k = (target - l) / (255 - l)
139 return scaled.map(x => x + (255 - x) * k) as RGB
140}
141
142function guarded(wax: Wax, liquid: Liquid): Liquid {
143 const mid = luma(wax.mid)
144 const [lt, lb] = [luma(liquid.top), luma(liquid.bottom)]
145 if (Math.abs(mid - lt) >= CONTRAST_MARGIN && Math.abs(mid - lb) >= CONTRAST_MARGIN) return liquid
146 const below = mid - CONTRAST_MARGIN
147 const above = mid + CONTRAST_MARGIN
148 const canDarken = below >= DARKEST_LIQUID_LUMA
149 const canLighten = above <= 255
150 const lighten = (lt + lb) / 2 > mid ? canLighten : !canDarken
151 if (!lighten) {
152 const f = Math.min(1, below / Math.max(lt, lb))
153 return { top: liquid.top.map(x => x * f) as RGB, bottom: liquid.bottom.map(x => x * f) as RGB }
154 }
155 // The top goes to the margin; the bottom keeps its lead over the top (up to white).
156 const fix = (c: RGB, target: number): RGB => (luma(c) < target ? raised(c, target) : c)
157 return { top: fix(liquid.top, above), bottom: fix(liquid.bottom, Math.min(255, above + Math.max(0, lb - lt))) }
158}
159
160const scheme = (wax: Wax, liquid: Liquid | undefined): Scheme => ({
161 top: liquid?.top ?? [0, 0, 0],
162 bottom: liquid?.bottom ?? [0, 0, 0],
163 edge: wax.edge,
164 mid: wax.mid,
165 hot: wax.hot,
166 clear: liquid === undefined,
167})
168
169// The hand-tuned pairs: each is used exactly as written, in place of the composition
170// of its wax and liquid. `label` is how a reply says it. Pairs that share a liquid word
171// are tuned to their wax (a royal blue under yellow, a teal under green), as real lamps'
172// blues differ.
173type TunedPair =
174 | 'orange-violet'
175 | 'yellow-blue'
176 | 'green-blue'
177 | 'purple-blue'
178 | 'blue-blue'
179 | 'pink-pink'
180 | 'turquoise-violet'
181 | 'red-violet'
182 | 'yellow-pink'
183 | 'orange-yellow'
184 | 'white-red'
185 | 'orange-black'
186 | 'yellow-clear'
187 | 'green-clear'
188 | 'purple-clear'
189
190const TUNED: Record<TunedPair, { label: string; scheme: Scheme }> = {
191 'orange-violet': { label: 'orange wax in violet', scheme: scheme(WAX.orange, VIOLET) },
192 'yellow-blue': { label: 'yellow wax in blue', scheme: scheme(WAX.yellow, ROYAL_BLUE) },
193 'green-blue': { label: 'green wax in blue', scheme: scheme(WAX.green, { top: [4, 40, 92], bottom: [8, 68, 132] }) },
194 'purple-blue': { label: 'purple wax in blue', scheme: scheme(WAX.purple, { top: [10, 16, 84], bottom: [16, 30, 128] }) },
195 // Same hue as its wax, the liquid very very dark.
196 'blue-blue': { label: 'light blue wax in dark blue', scheme: scheme(WAX.blue, { top: [3, 6, 20], bottom: [6, 15, 46] }) },
197 // A very dark plum-magenta, so the pink wax keeps its contrast.
198 'pink-pink': { label: 'pink wax in deep pink', scheme: scheme(WAX.pink, PLUM) },
199 'turquoise-violet': { label: 'turquoise wax in violet', scheme: scheme(WAX.turquoise, VIOLET) },
200 'red-violet': { label: 'red wax in violet', scheme: scheme(WAX.red, { top: [32, 10, 88], bottom: [52, 18, 128] }) },
201 'yellow-pink': { label: 'yellow wax in pink', scheme: scheme(WAX.yellow, { top: [96, 8, 62], bottom: [142, 16, 94] }) },
202 // The one bright background among them: a light, luminous liquid, under the deeper orange.
203 'orange-yellow': { label: 'orange wax in yellow', scheme: scheme(WAX.orangeDeep, LIGHT_YELLOW) },
204 'white-red': { label: 'white wax in red', scheme: scheme(WAX.white, RED_LIQUID) },
205 'orange-black': { label: 'orange wax in black', scheme: scheme(WAX.orange, BLACK_LIQUID) },
206 'yellow-clear': { label: 'yellow wax in clear liquid', scheme: scheme(WAX.yellow, undefined) },
207 'green-clear': { label: 'green wax in clear liquid', scheme: scheme(WAX.green, undefined) },
208 'purple-clear': { label: 'purple wax in clear liquid', scheme: scheme(WAX.purple, undefined) },
209}
210
211// The words of the hand-tuned pairs.
212export const PAIR_NAMES = Object.keys(TUNED) as LavaPair[]
213
214// A `<wax>-<liquid>` word, or undefined when it is not one.
215export function asPair(word: string): LavaPair | undefined {
216 const at = word.indexOf('-')
217 if (at < 0) return undefined
218 const wax = word.slice(0, at)
219 const liquid = word.slice(at + 1)
220 return (WAX_COLOURS as string[]).includes(wax) && (LIQUID_COLOURS as string[]).includes(liquid) ? (word as LavaPair) : undefined
221}
222
223// A pair's label and colours: the hand-tuned entry if there is one, else the wax over
224// its liquid (guarded), or over no liquid at all for `clear`.
225function pairEntry(wax: LavaWaxColour, liquid: LavaLiquidColour): { label: string; scheme: Scheme } {
226 const tuned = (TUNED as Partial<Record<string, { label: string; scheme: Scheme }>>)[`${wax}-${liquid}`]
227 if (tuned !== undefined) return tuned
228 const waxName = wax === 'blue' ? 'light blue' : wax
229 if (liquid === 'clear') return { label: `${waxName} wax in clear liquid`, scheme: scheme(WAX[wax], undefined) }
230 return { label: `${waxName} wax in ${liquid}`, scheme: scheme(WAX[wax], guarded(WAX[wax], liquidOf(liquid))) }
231}
232
233// Every `<wax>-<liquid>` pair, made once.
234const PAIR_TABLE = Object.fromEntries(
235 WAX_COLOURS.flatMap(wax => LIQUID_COLOURS.map(liquid => [`${wax}-${liquid}`, pairEntry(wax, liquid)])),
236) as Record<LavaPair, { label: string; scheme: Scheme }>
237
238// What each single colour word means. A saved option keeps the word as typed
239// and is resolved here at draw time, so options saved earlier stay valid.
240export const PAIR_OF: Record<LavaColourWord, LavaPair> = {
241 orange: 'orange-violet',
242 yellow: 'yellow-blue',
243 green: 'green-blue',
244 purple: 'purple-blue',
245 blue: 'blue-blue',
246 pink: 'pink-pink',
247}
248
249// The single colour words, then the same words and every pair as schemes.
250export const PALETTES: LavaColourWord[] = ['orange', 'purple', 'green', 'blue', 'pink', 'yellow']
251
252export const SCHEMES = Object.fromEntries([
253 ...(Object.keys(PAIR_TABLE) as LavaPair[]).map(name => [name, PAIR_TABLE[name].scheme]),
254 ...PALETTES.map(word => [word, PAIR_TABLE[PAIR_OF[word]].scheme]),
255]) as Record<LavaPalette, Scheme>
256
257// The pair a palette (word or pair) draws; an unrecognised saved value draws the default.
258export function pairOf(palette: LavaPalette): LavaPair {
259 if (Object.hasOwn(PAIR_OF, palette)) return PAIR_OF[palette as LavaColourWord]
260 return asPair(palette) ?? PAIR_OF.orange
261}
262
263// `rotate` walks these in order, every blend running between pairs whose wax
264// and liquid both move together. No clear liquid (nothing to blend) and none of
265// the bright or black backgrounds.
266export const ROTATION: LavaPair[] = ['orange-violet', 'yellow-blue', 'green-blue', 'turquoise-violet', 'purple-blue', 'pink-pink', 'red-violet', 'blue-blue']
267// Simulation seconds per pair: held for the first half, then blended into
268// the next over the second half. Simulation time already scales with the speed
269// word (see SPEED_STEP), so rotation speeds up and slows down with the motion:
270// 30 s per pair at normal, 120 s at veryslow, 60 s at slow, 15 s at fast.
271export const ROTATE_PERIOD_S = 30
272const ROTATE_HOLD = 0.5
273
274// ---------------------------------------------------------------- options
275
276export const DEFAULT_OPTIONS: LavaOptions = { speed: 'normal', palette: 'orange', lamp: true }
277
278// One simulation step is DT seconds; a speed word is how many steps run per
279// 100 ms frame (whole steps, so the same step count gives the same state at any
280// speed). At normal that is one second of lamp time per second of wall time.
281export const DT_MS = 25
282export const DT = DT_MS / 1000
283export const SPEED_STEP: Record<LavaSpeed, number> = { veryslow: 1, slow: 2, normal: 4, fast: 8 }
284
285export const SPEEDS: LavaSpeed[] = ['veryslow', 'slow', 'normal', 'fast']
286export const BUBBLE_WORDS: LavaBubbles[] = ['few', 'medium', 'many']
287// The fixed words; a `<wax>-<liquid>` pair is any other word `asPair` accepts.
288export const WORDS: string[] = [...SPEEDS, ...PALETTES, ...BUBBLE_WORDS, 'rotate', 'rotate-clear', 'lamp', 'nolamp', 'off']
289// Other spellings people reach for, read as the word they mean.
290const ALIASES: Record<string, string> = { rotating: 'rotate', 'rotating-clear': 'rotate-clear' }
291
292// The words, grouped, for the reply to an unknown one.
293export const HELP = [
294 `speeds: ${SPEEDS.join(', ')}`,
295 `colours: ${PALETTES.join(', ')}, rotate, rotate-clear`,
296 `pairs: <wax>-<liquid>, wax: ${WAX_COLOURS.join(', ')}; liquid: the same or clear`,
297 `bubbles: ${BUBBLE_WORDS.join(', ')}`,
298 'outline: lamp, nolamp',
299 'off',
300].join(' · ')
301
302// How many blobs, how big (a multiplier on radius) and how sluggish (a multiplier
303// on the liquid's drag: heavier blobs respond more slowly). The pool and the top
304// layer are the same in all three.
305export const BUBBLES: Record<LavaBubbles, { count: number; size: number; pace: number }> = {
306 few: { count: 3, size: 1.7, pace: 1.3 },
307 medium: { count: 5, size: 1, pace: 1 },
308 many: { count: 9, size: 0.62, pace: 0.95 },
309}
310
311const bubblesOf = (b: LavaBubbles | undefined) => BUBBLES[b ?? 'medium'] ?? BUBBLES.medium
312
313export type Parsed =
314 | { ok: true; options: LavaOptions; off: boolean; words: number }
315 | { ok: false; unknown: string[] }
316
317// `/lava-lamp` words, any order and case; a later word of the same kind wins.
318// A colour or pair word is fixed colour and turns rotation off; `rotate` replaces it.
319// Any unknown word rejects the whole line so nothing half-applies.
320export function parseArgs(args: string, prev: LavaOptions = DEFAULT_OPTIONS): Parsed {
321 const words = args
322 .trim()
323 .toLowerCase()
324 .split(/\s+/)
325 .filter(w => w.length > 0)
326 .map(w => ALIASES[w] ?? w)
327 const unknown = words.filter(w => !WORDS.includes(w) && asPair(w) === undefined)
328 if (unknown.length > 0) return { ok: false, unknown }
329 const options = { ...prev }
330 let off = false
331 for (const w of words) {
332 if ((SPEEDS as string[]).includes(w)) options.speed = w as LavaSpeed
333 else if ((PALETTES as string[]).includes(w) || asPair(w) !== undefined) {
334 options.palette = w as LavaPalette
335 delete options.rotate
336 } else if ((BUBBLE_WORDS as string[]).includes(w)) options.bubbles = w as LavaBubbles
337 else if (w === 'rotate') options.rotate = true
338 else if (w === 'rotate-clear') options.rotate = 'clear'
339 else if (w === 'lamp') options.lamp = true
340 else if (w === 'nolamp') options.lamp = false
341 else if (w === 'off') off = true
342 }
343 return { ok: true, options, off, words: words.length }
344}
345
346const BUBBLE_LABEL: Record<LavaBubbles, string> = { few: 'few big bubbles', medium: 'medium bubbles', many: 'many small bubbles' }
347
348export function describe(o: LavaOptions): string {
349 const speed = o.speed === 'normal' ? 'normal speed' : o.speed === 'veryslow' ? 'very slow' : o.speed
350 const colour =
351 o.rotate === true ? 'rotating colours' : o.rotate === 'clear' ? 'rotating wax in clear liquid' : PAIR_TABLE[pairOf(o.palette)].label
352 return `${speed} · ${colour} · ${BUBBLE_LABEL[o.bubbles ?? 'medium'] ?? BUBBLE_LABEL.medium} · ${o.lamp ? 'lamp outline' : 'no lamp outline'}`
353}
354
355// ---------------------------------------------------------------- motion
356
357// Deterministic pseudo-random in [0, 1) from two integers.
358function hash(a: number, b: number): number {
359 const s = Math.sin(a * 127.1 + b * 311.7 + 0.5) * 43758.5453
360 return s - Math.floor(s)
361}
362
363const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x))
364const smooth = (s: number) => s * s * (3 - 2 * s)
365const mixN = (a: number, b: number, k: number) => a + (b - a) * k
366const ramp = (x: number, from: number, to: number) => smooth(clamp((x - from) / (to - from), 0, 1))
367
368// The simulated glass is a fixed canonical one, whatever size the pane is: u is
369// across (glass-height units, 0 at the middle), v is down (0 top, 1 bottom). The
370// drawing stretches the result sideways to the real glass (see `blobsOf`).
371const WIDEST_LAMP = 0.43
372const WIDEST_PLAIN = 0.33
373// The lamp glass's half-width at depth v, as a fraction of its widest.
374const lampProfile = (v: number) => (v < 0.66 ? 0.25 + 0.21 * (v / 0.66) : 0.46 - 0.1 * ((v - 0.66) / 0.34)) / 0.46
375const canonicalWidest = (lamp: boolean) => (lamp ? WIDEST_LAMP : WIDEST_PLAIN)
376export const canonicalHalf = (lamp: boolean, v: number) => (lamp ? WIDEST_LAMP * lampProfile(clamp(v, 0, 1)) : WIDEST_PLAIN)
377
378// ---- the force model. Units: glass heights, seconds. Temperature runs 0..1.
379
380const R_REF = 0.1 // a medium blob's radius: the unit of weight and thermal mass
381const GAMMA = 1.5 // drag, 1/s: the liquid is thick, so a blob's speed follows its push
382export const BUOYANCY = 0.7 // upward acceleration per unit of temperature past the threshold in force
383// The two thresholds: afloat wax keeps rising until it is colder than T_LO, sinking wax keeps
384// sinking until it is warmer than T_HI. A single threshold gave every blob one resting height
385// where the stratified liquid matched it; two, with the liquid between them, give a cycle.
386export const T_LO = 0.35
387export const T_HI = 0.65
388export const V_MAX = 0.14 // speed clamp, per blob
389// Sideways motion is damped harder than vertical: nothing in a lamp pushes wax sideways on its
390// own, so a blob moves across only when a neighbour, the roll or a wall carries it.
391const LATERAL_DRAG = 2.5
392const SWAY = 0.03 // peak sideways acceleration of the seeded wobble, felt only while a blob travels
393const ROLL = 0.08 // 1/s: how firmly cooled wax settles toward its own side of the glass, warm wax toward the middle
394
395// Heat: the liquid is warm at the bulb and cool at the top (and breathes slowly),
396// the bulb heats wax near the base, the top cools it. Each blob trades heat with
397// the liquid slowly, scaled down by its size (a big blob has more to warm).
398// The liquid: LIQ_TOP under the cap, LIQ_TOP + LIQ_SPAN at the base, wobbling by LIQ_WOBBLE.
399export const LIQ_TOP = 0.4
400export const LIQ_SPAN = 0.2
401export const LIQ_WOBBLE = 0.02
402const liquidTemp = (v: number, t: number) => LIQ_TOP + LIQ_SPAN * v + LIQ_WOBBLE * Math.sin(t * 0.035 + 4 * v)
403const FLICKER = 0.02 // 1/s: the seeded wobble in each blob's heat; it staggers the blobs' phases, it does not drive them
404export const K_LIQUID = 0.3 // 1/s: fast enough that a blob's lift shrinks along its trip, so it slows
405export const K_BULB = 0.25 // 1/s, at full strength under the hot spot
406export const K_TOP = 0.25 // 1/s
407const BULB_FROM = 0.7 // the bulb's reach: none above this depth, full below BULB_TO
408const BULB_TO = 0.92
409// The cap's chill, keyed on a blob's top edge (a big blob's centre never gets near the cap):
410// none below CHILL_FROM, full above CHILL_TO.
411const CHILL_FROM = 0.14
412const CHILL_TO = 0.04
413
414// Blob against blob. `s` is the centre distance over the sum of the radii, so
415// s = 1 is rims touching. Everything fades to nothing at RANGE ("a few radii").
416const RANGE = 2.4
417const ENTRAIN = 0.4 // 1/s: pull of a neighbour's velocity on a blob's own
418// Entrainment reaches less far than cling: a blob passing close drags its neighbour, while blobs
419// merely nearby keep their own rhythm instead of locking into step.
420// Measured as the gap between the two rims, not as a multiple of their size: big blobs would
421// otherwise drag each other from across most of the glass and ride in step. ENTRAIN_GAP equals
422// what the old multiple gave two medium blobs, so those behave as before.
423const ENTRAIN_GAP = 0.12
424const CLING = 0.012 // peak attraction between wax and wax
425const CORE = 0.6 // soft repulsion, at its strongest when two blobs' centres coincide
426const CORE_S = 1.5 // inside this separation (in the sum of their radii) it pushes apart, beyond it wax clings
427// Heat is traded only while two touching blobs pass each other, one rising and one sinking:
428// a pass is brief, so it cannot pull two blobs into step, and blobs riding together (which
429// would) trade none. Full strength once both move at PASS_SPEED or more.
430const HEAT_PASS = 0.6 // 1/s
431const PASS_SPEED = 0.02
432// Two blobs passing close, one rising and one sinking, shoulder each other aside: a sideways push
433// apart, growing with the square of how fast they pass (a faster pass nudges harder), the lighter one giving way more. Sideways damping
434// then eases them back, so a pass reads as a sidestep, not a drift. Reaches PASS_RANGE radii-sums.
435const PASS_PUSH = 0.25
436const PASS_RANGE = 1.8
437// Two blobs going opposite ways that press into each other head-on would otherwise balance, the
438// riser propping up the sinker, both stalled (found in a tapered glass, where the walls stop them
439// sliding aside): DEFLECT is the share of the soft core's vertical push between them that is
440// taken away and turned sideways, so they squeeze past each other as soft wax does.
441const DEFLECT = 0.6
442
443const WALL_SOFT = 0.05 // the soft boundary's depth
444const WALL_PUSH = 0.35 // its acceleration at the wall itself
445// The soft walls are springs with a damper in the same zone (about critical for WALL_PUSH over
446// WALL_SOFT), so wax meeting the cap settles against it instead of being thrown back while hot.
447const WALL_DAMP = 6 // 1/s at the wall itself
448// The cushion under the cap is thinner than the side walls': at WALL_SOFT deep it held blobs
449// short of the cap. Its damper scales with its stiffer spring, so wax still settles without rebound.
450const CAP_SOFT = 0.02
451const CAP_DAMP = 9
452export const TOP_EDGE = 0.015 // the highest a blob's rim may go (the layer of wax at the top is above it)
453export const FLOOR = 0.985 // the lowest a blob's centre may go: down in the pool
454
455// A blob: `id` seeds its private traits; (x, y) the centre, y down; T its temperature.
456export type Body = { id: number; x: number; y: number; vx: number; vy: number; T: number; r: number; shape?: number; afloat?: boolean }
457
458// `share: false` turns off only the heat traded between passing blobs (a test's counterfactual).
459export type SimOptions = { bubbles?: LavaBubbles; lamp?: boolean; interact?: boolean; share?: boolean }
460
461// The whole state of a lamp. `tick` counts every step ever taken (the physics'
462// own clock, warm-up included); `clock` counts steps of lamp time shown, which is
463// what `rotate` reads, and which a restarted lamp may carry over.
464export type Sim = { bubbles: LavaBubbles; lamp: boolean; interact: boolean; share?: boolean; bodies: Body[]; tick: number; clock: number }
465
466export const WARMUP_STEPS = 4200
467
468// Seconds of lamp time a clock count stands for.
469export const simTime = (sim: Sim) => sim.clock * DT
470
471// The weakest bulb and chill a blob can draw, and the full spread of its density offset: the
472// thermal-cycle conditions in the tests are checked against these extremes.
473export const BULB_TRAIT_MIN = 0.5
474export const CHILL_TRAIT_MIN = 0.6
475export const DENSITY_SPREAD = 0.04
476
477// One blob's own traits, from its id and size.
478function traits(b: Body) {
479 const heft = b.r / R_REF
480 return {
481 weight: heft * heft,
482 thermal: Math.max(0.6, Math.sqrt(heft)) * (0.8 + 0.4 * hash(b.id, 16)),
483 bulb: BULB_TRAIT_MIN + hash(b.id, 11),
484 chill: CHILL_TRAIT_MIN + 0.8 * hash(b.id, 17),
485 density: (hash(b.id, 12) - 0.5) * DENSITY_SPREAD,
486 flicker: FLICKER * (0.5 + hash(b.id, 18)),
487 beat: 0.1 + 0.2 * hash(b.id, 19),
488 side: hash(b.id, 20) < 0.5 ? -1 : 1,
489 omega: 0.08 + 0.12 * hash(b.id, 13),
490 phase: 2 * Math.PI * hash(b.id, 14),
491 sway: SWAY * (0.5 + hash(b.id, 15)),
492 }
493}
494
495function newBody(id: number, glassW: number, lamp: boolean, size: number): Body {
496 const r = clamp(glassW * (0.2 + 0.12 * hash(id, 3)), 0.035, 0.11) * size
497 const y = 0.2 + 0.7 * hash(id, 8)
498 const room = Math.max(0, canonicalHalf(lamp, y) - r * 0.85)
499 return { id, x: (hash(id, 7) - 0.5) * 2 * room, y, vx: 0, vy: 0, T: 0.15 + 0.7 * hash(id, 9), r }
500}
501
502// A lamp from explicit blobs (a test places them), not yet stepped.
503export function simOf(bodies: Body[], options: SimOptions = {}, clock = 0): Sim {
504 return { bubbles: options.bubbles ?? 'medium', lamp: options.lamp ?? true, interact: options.interact ?? true, share: options.share ?? true, bodies, tick: 0, clock }
505}
506
507// The initial lamp for a seed: the options' blobs, then WARMUP_STEPS of settling,
508// so the wax is already spread through the glass rather than all in the pool.
509export function createSim(options: SimOptions = {}, clock = 0): Sim {
510 const lamp = options.lamp ?? true
511 const profile = bubblesOf(options.bubbles)
512 const bodies: Body[] = []
513 for (let i = 0; i < profile.count; i++) bodies.push(newBody(i, canonicalWidest(lamp), lamp, profile.size))
514 const sim = simOf(bodies, options, clock)
515 for (let i = 0; i < WARMUP_STEPS; i++) step(sim, false)
516 return sim
517}
518
519// A fresh lamp advanced `steps` steps (lamp time counted from 0).
520export function simulate(options: SimOptions, steps: number): Sim {
521 const sim = createSim(options)
522 for (let i = 0; i < steps; i++) step(sim)
523 return sim
524}
525
526// Closeness weight: 1 at touching rims or nearer, falling smoothly to 0 at RANGE.
527const nearness = (s: number) => (1 - clamp((s - 1) / (RANGE - 1), 0, 1)) ** 2
528// Heat passes only between blobs in touch.
529const contact = (s: number) => (1 - clamp((s - 0.9) / 0.9, 0, 1)) ** 2
530
531// One fixed step. `tick` is the physics clock; `counted` is whether it also
532// advances the lamp time shown.
533export function step(sim: Sim, counted = true): void {
534 const { bodies } = sim
535 const n = bodies.length
536 const t = sim.tick * DT
537 const pace = bubblesOf(sim.bubbles).pace
538 const widest = canonicalWidest(sim.lamp)
539 const acc = bodies.map(() => ({ ax: 0, ay: 0, dT: 0 }))
540 const own = bodies.map(traits)
541 const spotX = 0.6 * widest * Math.sin(t * 0.045)
542
543 for (let i = 0; i < n; i++) {
544 const b = bodies[i]!
545 const k = own[i]!
546 const a = acc[i]!
547 // Buoyancy lifts warm wax (y is down, so up is negative); drag resists; a seeded sway drifts it sideways.
548 if (b.afloat === undefined) b.afloat = b.T > (T_LO + T_HI) / 2
549 const lift = BUOYANCY * (b.T - (b.afloat ? T_LO : T_HI) - k.density)
550 const drag = GAMMA * pace
551 a.ay = -lift - drag * b.vy
552 // The convection roll: warm wax climbs the middle, cooled wax sinks down its side of the glass.
553 const sinking = ramp(b.vy, 0, 0.03)
554 const lane = k.side * 0.7 * Math.max(0, canonicalHalf(sim.lamp, b.y) - b.r) * sinking
555 // The wobble is a travelling blob's wake: a blob at rest or hovering does not wander sideways.
556 const travelling = ramp(Math.abs(b.vy), 0.004, 0.04)
557 a.ax = -drag * LATERAL_DRAG * b.vx + k.sway * travelling * Math.sin(k.omega * t + k.phase) + ROLL * (lane - b.x)
558 // Soft walls: the glass's side at this depth, the top, the floor.
559 const room = canonicalHalf(sim.lamp, b.y) - b.r * 0.85
560 const into = Math.abs(b.x) - (room - WALL_SOFT)
561 if (into > 0) a.ax -= Math.sign(b.x) * WALL_PUSH * Math.min(1, into / WALL_SOFT)
562 const high = TOP_EDGE + 0.9 * b.r + CAP_SOFT - b.y
563 if (high > 0) {
564 a.ay += WALL_PUSH * Math.min(1, high / CAP_SOFT)
565 a.ay -= CAP_DAMP * Math.min(1, high / CAP_SOFT) * b.vy
566 }
567 const low = b.y - (FLOOR - WALL_SOFT)
568 if (low > 0) a.ay -= WALL_PUSH * Math.min(1, low / WALL_SOFT)
569 // Heat: with the liquid, from the bulb (strongest over a slowly wandering hot spot), from the top's chill.
570 const bulb = ramp(b.y, BULB_FROM, BULB_TO) * (0.4 + 0.6 * Math.exp(-(((b.x - spotX) / (0.5 * widest)) ** 2)))
571 const chill = ramp(b.y - b.r, CHILL_FROM, CHILL_TO)
572 a.dT = (K_LIQUID * (liquidTemp(b.y, t) - b.T) + K_BULB * k.bulb * bulb * (1 - b.T) - K_TOP * k.chill * chill * b.T + k.flicker * Math.sin(k.beat * t + k.phase)) / k.thermal
573 }
574
575 if (sim.interact)
576 for (let i = 0; i < n; i++)
577 for (let j = i + 1; j < n; j++) {
578 const a = bodies[i]!
579 const b = bodies[j]!
580 const ai = acc[i]!
581 const aj = acc[j]!
582 let dx = b.x - a.x
583 let dy = b.y - a.y
584 let d = Math.hypot(dx, dy)
585 if (d < 1e-9) {
586 dx = (i + j) % 2 === 0 ? 1 : -1
587 dy = 0
588 d = 1
589 }
590 const s = d / (a.r + b.r)
591 if (s >= RANGE) continue
592 const near = nearness(s)
593 // The lighter blob gives way more, as momentum would have it.
594 const wa = (2 * own[j]!.weight) / (own[i]!.weight + own[j]!.weight)
595 const wb = 2 - wa
596 // Entrainment: each is pulled toward the other's velocity.
597 const grip = (1 - clamp((d - a.r - b.r) / ENTRAIN_GAP, 0, 1)) ** 2
598 const dvx = b.vx - a.vx
599 const dvy = b.vy - a.vy
600 ai.ax += ENTRAIN * grip * dvx * wa
601 ai.ay += ENTRAIN * grip * dvy * wa
602 aj.ax -= ENTRAIN * grip * dvx * wb
603 aj.ay -= ENTRAIN * grip * dvy * wb
604 // Cling and core: along the line between centres, positive toward the other.
605 const pull = s < CORE_S ? -CORE * (1 - s / CORE_S) ** 2 : CLING * Math.sin((Math.PI * (s - CORE_S)) / (RANGE - CORE_S))
606 const ux = dx / d
607 const uy = dy / d
608 // Wax going opposite ways is soft to each other: head-on, the core props the other up
609 // only (1 - DEFLECT) as hard, and the rest of its push turns sideways (below), so the two
610 // squeeze past even where a wall stops them sliding aside.
611 const opposed = a.afloat !== b.afloat && pull < 0
612 const prop = opposed ? 1 - DEFLECT : 1
613 ai.ax += pull * ux * wa
614 ai.ay += pull * uy * wa * prop
615 aj.ax -= pull * ux * wb
616 aj.ay -= pull * uy * wb * prop
617 // Passing: one floating and one sinking, judged by what the two want rather than how they
618 // move, so a stalled head-on pair still counts. A pass trades heat and shoulders the two
619 // apart sideways; head-on, part of the core's push turns sideways too.
620 const passing = a.afloat !== b.afloat ? 1 : 0
621 const close = (1 - clamp((s - 1) / (PASS_RANGE - 1), 0, 1)) ** 2
622 const dvp = b.vy - a.vy
623 const side = dx > 0 ? 1 : dx < 0 ? -1 : a.id < b.id ? 1 : -1
624 const deflect = opposed ? DEFLECT * -pull : 0
625 const nudge = (PASS_PUSH * close * passing * ((dvp * dvp) / PASS_SPEED) + deflect) * side
626 ai.ax -= nudge * wa
627 aj.ax += nudge * wb
628 const share = sim.share === false ? 0 : HEAT_PASS * contact(s) * passing * (b.T - a.T)
629 ai.dT += share * wa
630 aj.dT -= share * wb
631 }
632
633 for (let i = 0; i < n; i++) {
634 const b = bodies[i]!
635 const a = acc[i]!
636 b.vx += a.ax * DT
637 b.vy += a.ay * DT
638 const speed = Math.hypot(b.vx, b.vy)
639 if (speed > V_MAX) {
640 b.vx *= V_MAX / speed
641 b.vy *= V_MAX / speed
642 }
643 b.x += b.vx * DT
644 b.y += b.vy * DT
645 b.T = clamp(b.T + a.dT * DT, 0, 1)
646 // The flip: afloat wax that has cooled past T_LO starts to sink, sinking wax warmed past T_HI floats.
647 const dens = own[i]!.density
648 if (b.afloat && b.T < T_LO + dens) b.afloat = false
649 else if (!b.afloat && b.T > T_HI + dens) b.afloat = true
650 const want = shapeTarget(b)
651 b.shape = b.shape === undefined ? want : b.shape + (want - b.shape) * SHAPE_EASE
652 // The hard edge behind the soft one: never outside, never stuck (only the velocity into a wall is lost).
653 const room = Math.max(0, canonicalHalf(sim.lamp, b.y) - b.r * 0.85)
654 if (Math.abs(b.x) > room) {
655 b.x = Math.sign(b.x) * room
656 if (b.vx * b.x > 0) b.vx = 0
657 }
658 const top = TOP_EDGE + 0.9 * b.r
659 if (b.y < top) {
660 b.y = top
661 if (b.vy < 0) b.vy = 0
662 } else if (b.y > FLOOR) {
663 b.y = FLOOR
664 if (b.vy > 0) b.vy = 0
665 }
666 }
667 sim.tick++
668 if (counted) sim.clock++
669}
670
671// One drawn frame's worth of simulation at `speed`: a speed word is only how many
672// fixed steps that is, so every speed walks the same path.
673export function advanceFrame(sim: Sim, speed: LavaSpeed): void {
674 for (let i = 0; i < SPEED_STEP[speed]; i++) step(sim)
675}
676
677// Every metaball of a lamp drawn in `glass`: the bottom pool, a thin cap at the
678// top, and the simulated blobs. The simulated glass is stretched sideways to the
679// real one, and the blobs grow or shrink with it, within limits.
680// The shape a blob's motion asks for: hot rising wax is soft and pulls long; cooler sinking wax
681// stays rounder; wax that has all but stopped under the cap slumps wider.
682function shapeTarget(b: Body): number {
683 const vel = b.vy
684 const flat = 1 - 0.2 * ramp(b.y, 0.22, 0.06) * (1 - Math.min(1, Math.abs(vel) / 0.02))
685 return flat * (1 + Math.min(0.6, Math.abs(vel) * (vel < 0 ? 8 : 5)))
686}
687// Wax changes shape slowly: the drawn shape eases toward the target over about SHAPE_TAU, so a
688// blob stopping under the cap spreads out instead of snapping from tall to flat.
689const SHAPE_TAU = 0.7 // s
690const SHAPE_EASE = 1 - Math.exp(-DT / SHAPE_TAU)
691
692export function blobsOf(sim: Sim, glass: Glass): Blob[] {
693 const W = glass.widest
694 const t = sim.tick * DT
695 const k = W / canonicalWidest(sim.lamp)
696 const size = clamp(k, 0.6, 1.4)
697 const list: Blob[] = []
698 for (let j = -1; j <= 1; j++)
699 list.push({
700 u: j * 0.55 * W,
701 v: 1.01,
702 rx: 0.6 * W,
703 ry: 0.08 + 0.012 * Math.sin(t * 0.13 + j * 2.1),
704 vel: 0,
705 kind: 'pool',
706 })
707 list.push({ u: 0.1 * W * Math.sin(t * 0.05), v: -0.035, rx: 0.55 * W, ry: 0.03 + 0.012 * Math.sin(t * 0.09 + 1), vel: 0, kind: 'cap' })
708 for (const b of sim.bodies) {
709 const vel = b.vy
710 const stretch = b.shape ?? shapeTarget(b)
711 const r = b.r * size
712 // A pane narrower than the canonical glass shrinks blobs by `size` to keep them round, which
713 // drew wax well short of the cap in tall panes. Height shrinks only by its square root: blobs
714 // grow a little taller than wide there, and reach nearer the top (owner's choice, the middle way).
715 list.push({ u: b.x * k, v: b.y, rx: r / Math.sqrt(stretch), ry: b.r * Math.sqrt(size) * stretch, vel, kind: 'wax' })
716 }
717 return list
718}
719
720// Each blob's influence falls smoothly to nothing at twice its radius (a
721// compact kernel, as xscreensaver's lavalite uses, so a dozen blobs do not sum
722// into one fog), scaled so a lone blob crosses THRESHOLD exactly at its rim.
723const REACH2 = 4
724const RIM = (1 - 1 / REACH2) ** 2
725export function field(list: Blob[], u: number, v: number): number {
726 let sum = 0
727 for (const b of list) {
728 const q = ((u - b.u) / b.rx) ** 2 + ((v - b.v) / b.ry) ** 2
729 if (q < REACH2) sum += (1 - q / REACH2) ** 2 / RIM
730 }
731 return sum
732}
733
734// ---------------------------------------------------------------- colour
735
736const hex = (c: number[]) =>
737 '#' + c.map(x => Math.round(clamp(x, 0, 255)).toString(16).padStart(2, '0')).join('')
738
739const mix = (a: number[], b: number[], k: number) => a.map((c, i) => c + (b[i]! - c) * k)
740
741const mixRGB = (a: RGB, b: RGB, k: number): RGB => [mixN(a[0], b[0], k), mixN(a[1], b[1], k), mixN(a[2], b[2], k)]
742
743// Where `rotate` is at simulation time t: the pair it is on, the next one,
744// and how far (0..1) the blend into it has got. A pure function of t.
745export function rotationAt(t: number): { from: LavaPair; to: LavaPair; mix: number } {
746 const n = ROTATION.length
747 const p = t / ROTATE_PERIOD_S
748 const step = Math.floor(p)
749 const f = p - step
750 const at = ((step % n) + n) % n
751 return {
752 from: ROTATION[at]!,
753 to: ROTATION[(at + 1) % n]!,
754 mix: f < ROTATE_HOLD ? 0 : smooth((f - ROTATE_HOLD) / (1 - ROTATE_HOLD)),
755 }
756}
757
758// The colour scheme in force at time t: the fixed palette, or the rotating blend
759// of wax and liquid together, so the background always matches its wax. With
760// `rotate: 'clear'` only the wax rotates and the liquid stays clear.
761export function schemeAt(look: { palette: LavaPalette; rotate?: boolean | 'clear' }, t: number): Scheme {
762 if (look.rotate !== true && look.rotate !== 'clear') return SCHEMES[pairOf(look.palette)]
763 const { from, to, mix: k } = rotationAt(t)
764 const a = SCHEMES[from]
765 const b = SCHEMES[to]
766 return {
767 top: mixRGB(a.top, b.top, k),
768 bottom: mixRGB(a.bottom, b.bottom, k),
769 edge: mixRGB(a.edge, b.edge, k),
770 mid: mixRGB(a.mid, b.mid, k),
771 hot: mixRGB(a.hot, b.hot, k),
772 clear: look.rotate === 'clear',
773 }
774}
775
776export const METAL = hex([96, 92, 110])
777export const METAL_DARK = hex([58, 54, 70])
778// The glass's side edge, drawn only where a clear liquid would leave it invisible.
779export const GLASS_RIM = hex([88, 98, 126])
780
781// Colours are quantised (steps) so neighbouring cells share them and merge.
782// Wax is hottest (brightest) deep inside a blob and near the heated base.
783// Liquid in a clear pair is `undefined`: no colour, so the terminal shows.
784function lavaColor(f: number, v: number, s: Scheme): string | undefined {
785 const q = (x: number, steps: number) => Math.round(x * steps) / steps
786 if (f < THRESHOLD && s.clear) return undefined
787 const bg = mix(s.top, s.bottom, q(clamp(v, 0, 1), 4))
788 if (f < THRESHOLD * 0.55) return hex(bg)
789 if (f < THRESHOLD) return hex(mix(bg, s.edge, q((f - THRESHOLD * 0.55) / (THRESHOLD * 0.45), 3) * 0.6))
790 const heat = q(clamp((f - THRESHOLD) / 0.8 + (v - 0.55) * 0.4, 0, 1), 6)
791 return hex(heat < 0.5 ? mix(s.edge, s.mid, heat * 2) : mix(s.mid, s.hot, (heat - 0.5) * 2))
792}
793
794// ---------------------------------------------------------------- drawing
795
796type Shape = (x: number, y: number) => string | undefined
797
798export type Look = { palette: LavaPalette; lamp: boolean; rotate?: boolean | 'clear'; bubbles?: LavaBubbles }
799
800// The lamp silhouette over a w × h2 pixel grid (h2 = 2 × rows); a plain filled
801// body when the outline is off or there is no room for a cap and a base.
802function shape(w: number, h2: number, t: number, look: Look, sim: Sim): Shape {
803 const colours = schemeAt(look, t)
804 const hasLamp = look.lamp && w >= 14 && h2 >= 20
805 const capH = hasLamp ? Math.max(2, Math.round(h2 * 0.12)) : 0
806 const baseH = hasLamp ? Math.max(3, Math.round(h2 * 0.16)) : 0
807 const glassTop = capH
808 const glassH = Math.max(1, h2 - capH - baseH)
809 const cx = (w - 1) / 2
810 // Narrow at the top, widest two-thirds down, a little in at the bottom.
811 const halfPx = (v: number) => (hasLamp ? w * (v < 0.66 ? 0.25 + 0.21 * (v / 0.66) : 0.46 - 0.1 * ((v - 0.66) / 0.34)) : w / 2)
812 const glass: Glass = { half: v => halfPx(v) / glassH, widest: (hasLamp ? w * 0.46 : w / 2) / glassH }
813 const list = blobsOf(sim, glass)
814 const metal = (x: number, half: number) => (Math.abs(x - cx) < half * 0.4 ? METAL : METAL_DARK)
815 return (x, y) => {
816 if (!hasLamp) {
817 const v = y / glassH
818 return lavaColor(field(list, (x - cx) / glassH, v), v, colours)
819 }
820 if (y < glassTop) {
821 const half = w * (0.18 + 0.06 * (y / capH))
822 return Math.abs(x - cx) <= half ? metal(x, half) : undefined
823 }
824 if (y >= glassTop + glassH) {
825 const k = (y - glassTop - glassH) / baseH
826 const half = w * (k < 0.3 ? 0.36 - 0.06 * (k / 0.3) : 0.3 + 0.18 * ((k - 0.3) / 0.7))
827 return Math.abs(x - cx) <= half ? metal(x, half) : undefined
828 }
829 const v = (y - glassTop) / glassH
830 const dx = Math.abs(x - cx)
831 if (dx > halfPx(v)) return undefined
832 const c = lavaColor(field(list, (x - cx) / glassH, v), v, colours)
833 // A clear liquid leaves nothing to show the glass by, so edge it.
834 if (c === undefined && dx > halfPx(v) - 1.2) return GLASS_RIM
835 return c
836 }
837}
838
839type Cell = { ch: string; color?: string; backgroundColor?: string }
840
841// Top pixel over bottom pixel; `undefined` is no colour (outside the lamp, or a
842// clear liquid), which the half-block glyph leaves to the terminal.
843function cell(top: string | undefined, bottom: string | undefined): Cell {
844 if (top === undefined && bottom === undefined) return { ch: ' ' }
845 if (top === undefined) return { ch: '▄', color: bottom }
846 if (top === bottom) return { ch: ' ', backgroundColor: top }
847 return { ch: '▀', color: top, backgroundColor: bottom }
848}
849
850// A frame of exactly `rows` rows, each row's segments `columns` cells wide: the
851// wax where `sim` has it, the colours (and so `rotate`) at `t` seconds of lamp
852// time, which is the sim's own clock unless a caller says otherwise.
853export function frame(columns: number, rows: number, sim: Sim, look: Partial<Look> = {}, t: number = simTime(sim)): Segment[][] {
854 const full: Look = {
855 palette: look.palette ?? DEFAULT_OPTIONS.palette,
856 lamp: look.lamp ?? DEFAULT_OPTIONS.lamp,
857 rotate: look.rotate,
858 bubbles: look.bubbles,
859 }
860 const w = Math.max(0, Math.floor(columns))
861 const h = Math.max(0, Math.floor(rows))
862 const paint = shape(w, h * 2, t, full, sim)
863 const out: Segment[][] = []
864 for (let r = 0; r < h; r++) {
865 const row: Segment[] = []
866 let run: Segment | undefined
867 for (let x = 0; x < w; x++) {
868 const c = cell(paint(x, r * 2), paint(x, r * 2 + 1))
869 if (run !== undefined && run.color === c.color && run.backgroundColor === c.backgroundColor && run.text[0] === c.ch)
870 run.text += c.ch
871 else {
872 run = { text: c.ch, color: c.color, backgroundColor: c.backgroundColor }
873 row.push(run)
874 }
875 }
876 out.push(row)
877 }
878 return out
879}
880types/index.d.ts 39 lines1// How fast the wax moves: how many simulation steps run per drawn frame.
2export type LavaSpeed = 'veryslow' | 'slow' | 'normal' | 'fast'
3
4// A single colour word: shorthand for that colour's default wax-in-liquid pair.
5export type LavaColourWord = 'orange' | 'purple' | 'green' | 'blue' | 'pink' | 'yellow'
6
7// Colours that can be the wax of a lamp, and the liquid it floats in (the same
8// colours, and `clear`, which has no colour of its own).
9export type LavaWaxColour = 'orange' | 'yellow' | 'green' | 'purple' | 'blue' | 'pink' | 'red' | 'turquoise' | 'white' | 'black' | 'violet'
10export type LavaLiquidColour = LavaWaxColour | 'clear'
11
12// A wax in a liquid, named `<wax>-<liquid>`: any wax colour with any liquid. A
13// `-clear` liquid draws no background, so the terminal's own shows through.
14export type LavaPair = `${LavaWaxColour}-${LavaLiquidColour}`
15
16// What `palette` may hold: a colour word (kept as typed, so options saved by an
17// older version stay valid) or a named pair.
18export type LavaPalette = LavaColourWord | LavaPair
19
20// How many blobs of wax: few big ones, a medium handful, or many small ones.
21export type LavaBubbles = 'few' | 'medium' | 'many'
22
23// What `/lava-lamp <words>` sets; a bare `/lava-lamp` reopens with the last.
24// `rotate` cycles slowly through a set of pairs and, while set, overrides `palette` ('clear': wax only, clear liquid);
25// a colour word removes it. An absent `bubbles` means `medium`.
26export type LavaOptions = { speed: LavaSpeed; palette: LavaPalette; lamp: boolean; rotate?: boolean | 'clear'; bubbles?: LavaBubbles }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'lava-lamp': {
31 // Lamp time in milliseconds (25 ms per simulation step), advanced per frame by
32 // the speed's step count. It is what `rotate` reads and survives a reload; the
33 // wax itself is module state that restarts, already settled, after one.
34 clockMs: number
35 options: LavaOptions
36 }
37 }
38}
39