Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

197 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"strings"
"time"
)
// flameDriver renders a fire effect using the classic doom-fire propagation:
// a heat field is fed at the bottom row from the spectrum, then each frame
// every cell inherits its neighbour-below's heat with a small lateral wind
// jitter and a random decay. The result is a continuous, lapping flame
// instead of a row of independent columns. Bass thickens the heat source so
// loud passages feed taller flames; quiet passages settle into a low,
// flickering bed of coals.
type flameDriver struct {
heat []float64
dotRows, dotCols int
rng uint64
frame uint64
}
func newFlameDriver() visModeDriver {
return &flameDriver{rng: 0xF1A3C0DE0BADCAFE}
}
func (*flameDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return spectrumAnalysisSpec(DefaultSpectrumBands)
}
func (d *flameDriver) ensure(rows, cols int) {
if d.dotRows == rows && d.dotCols == cols && len(d.heat) == rows*cols {
return
}
d.heat = make([]float64, rows*cols)
d.dotRows = rows
d.dotCols = cols
}
func (d *flameDriver) Tick(v *Visualizer, ctx VisTickContext) {
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
if ctx.OverlayActive {
return
}
dotRows := v.Rows * 4
dotCols := PanelWidth * 2
if dotRows < 4 || dotCols < 4 {
return
}
d.ensure(dotRows, dotCols)
d.frame = v.frame
bands := v.SmoothedBands()
bandCount := len(bands)
// Source (bottom) row: per-column heat seeded from a smooth spectrum sample
// plus a small per-column sparkle so the base shimmers even on quiet input.
if bandCount > 0 {
last := float64(bandCount - 1)
for x := 0; x < dotCols; x++ {
pos := float64(x) / float64(max(1, dotCols-1)) * last
src := sampleBandLinear(bands, pos)
d.rng = d.rng*6364136223846793005 + 1442695040888963407
sparkle := float64((d.rng>>33)%100) / 100.0 * 0.18
// Always keep the base mildly lit so a bed of embers is visible.
base := 0.30 + 0.70*src + sparkle
if base > 1.05 {
base = 1.05
}
d.heat[x] = base
}
} else {
for x := 0; x < dotCols; x++ {
d.rng = d.rng*6364136223846793005 + 1442695040888963407
d.heat[x] = 0.30 + float64((d.rng>>33)%100)/100.0*0.20
}
}
// Propagate heat upward. Process top→down so we always read row y-1 before
// any later iteration overwrites it. Each cell inherits from a horizontally
// jittered neighbour below (the "wind") and loses a randomised amount of
// heat — that randomness is what gives the flame its wispy texture.
for y := dotRows - 1; y >= 1; y-- {
// Heat decays faster toward the top so flames taper. Tuned light so
// flames can climb most of the panel on a strong source.
heightFrac := float64(y) / float64(max(1, dotRows-1))
decayBase := 0.010 + 0.028*heightFrac
for x := 0; x < dotCols; x++ {
d.rng = d.rng*6364136223846793005 + 1442695040888963407
r := d.rng >> 33
offset := int(r%3) - 1 // -1, 0, +1
r >>= 2
decayJitter := float64(r%100) / 100.0 * 0.018
sourceX := x + offset
if sourceX < 0 {
sourceX = 0
} else if sourceX >= dotCols {
sourceX = dotCols - 1
}
next := d.heat[(y-1)*dotCols+sourceX] - decayBase - decayJitter
if next < 0 {
next = 0
}
d.heat[y*dotCols+x] = next
}
}
}
func (*flameDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
return defaultDriverTickInterval(ctx)
}
func (d *flameDriver) OnEnter(v *Visualizer) {
if v == nil {
d.heat = nil
d.dotRows = 0
d.dotCols = 0
return
}
d.ensure(v.Rows*4, PanelWidth*2)
for i := range d.heat {
d.heat[i] = 0
}
}
func (*flameDriver) OnLeave(*Visualizer) {}
func (d *flameDriver) Render(v *Visualizer) string {
height := v.Rows
dotRows := height * 4
dotCols := PanelWidth * 2
if dotRows < 4 || dotCols < 4 {
return strings.Repeat("\n", max(0, height-1))
}
if d.dotRows != dotRows || d.dotCols != dotCols || len(d.heat) != dotRows*dotCols {
d.ensure(dotRows, dotCols)
}
lines := make([]string, height)
for row := 0; row < height; row++ {
var sb, run strings.Builder
tag := -1
for col := 0; col < PanelWidth; col++ {
var braille rune = ''
cellTag := -1
for dr := 0; dr < 4; dr++ {
for dc := 0; dc < 2; dc++ {
y := row*4 + dr
x := col*2 + dc
// Panel y=0 is top; heat buffer y=0 is bottom (the source).
heatY := dotRows - 1 - y
h := d.heat[heatY*dotCols+x]
// Wispy tips: at low heat, only stochastically light the dot
// so the upper edge of the flame has a soft, broken silhouette
// instead of a hard cutoff.
if h < 0.10 {
continue
}
if h < 0.25 && scatterHash(0, y, x, d.frame) > h*4 {
continue
}
// Tier mapping:
// hottest → yellow (mid spectrum tier)
// body → red (high tier)
// tips → red, stippled above
var t int
switch {
case h >= 0.55:
t = 1 // yellow core
default:
t = 2 // red body / tips
}
braille |= brailleBit[dr][dc]
if t > cellTag {
cellTag = t
}
}
}
if cellTag < 0 {
cellTag = 0
}
if cellTag != tag {
flushStyleRun(&sb, &run, tag)
tag = cellTag
}
run.WriteRune(braille)
}
flushStyleRun(&sb, &run, tag)
lines[row] = sb.String()
}
return strings.Join(lines, "\n")
}