125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
// renderBubbles draws rising air bubbles using Braille dots. Each bubble is a
|
|
// hollow ring with a tiny specular highlight that drifts upward and sways
|
|
// laterally. Audio energy modulates the sway and highlight intensity; the
|
|
// bubble count is fixed so bubbles never pop in or out of existence mid-air.
|
|
// Bubbles fade stochastically as they approach the surface so they appear to
|
|
// "pop."
|
|
func (v *Visualizer) renderBubbles(bands []float64) string {
|
|
height := v.Rows
|
|
dotRows := height * 4
|
|
dotCols := PanelWidth * 2
|
|
if dotRows < 4 || dotCols < 4 {
|
|
return strings.Repeat("\n", max(0, height-1))
|
|
}
|
|
|
|
grid := make([]bool, dotRows*dotCols)
|
|
|
|
var totalEnergy float64
|
|
for _, e := range bands {
|
|
totalEnergy += e
|
|
}
|
|
avgEnergy := totalEnergy / float64(len(bands))
|
|
|
|
// Fixed count — changing this per frame makes bubbles spawn/vanish mid-air.
|
|
const numBubbles = 18
|
|
|
|
for i := range numBubbles {
|
|
seed := uint64(i)*104729 + 7919
|
|
|
|
// Stable per-bubble radius (1.5 to 4.0 dots). Must not depend on
|
|
// per-frame audio, otherwise trajectory parameters derived from it
|
|
// (speedDiv, wrapH, baseY) jitter every frame and the bubble flashes
|
|
// around the screen instead of rising smoothly.
|
|
radius := 1.5 + float64(seed%100)/100.0*2.5
|
|
|
|
// Bigger bubbles rise slower (buoyancy feels floaty). Lower divisor =
|
|
// faster rise: at 20 FPS a divisor of ~4 means one dot every ~200ms,
|
|
// crossing the panel in roughly 4 seconds.
|
|
speedDiv := 3 + int(radius)
|
|
|
|
// Continuous upward scroll with off-screen buffer for smooth entry/exit.
|
|
wrapH := dotRows + int(radius*2) + 8
|
|
baseY := int((seed * 3037) % uint64(wrapH))
|
|
y := wrapH - 1 - ((baseY + int(v.frame)/speedDiv) % wrapH) - int(radius) - 2
|
|
|
|
// Horizontal position with gentle sinusoidal sway. Amplitude scales
|
|
// with overall energy — quiet passages drift calmly, loud passages
|
|
// wobble a bit more. This only shifts x, so it can't destabilize the
|
|
// trajectory.
|
|
baseX := int(seed % uint64(dotCols))
|
|
swayPhase := float64(seed%1000) / 1000.0 * 2 * math.Pi
|
|
swayAmp := 1.5 + avgEnergy*2.5
|
|
sway := math.Sin(float64(v.frame)*0.03+swayPhase) * swayAmp
|
|
x := baseX + int(sway)
|
|
|
|
// Pop fade — the last few rows thin the ring stochastically.
|
|
popZone := int(radius) + 3
|
|
popFade := 1.0
|
|
if y < popZone {
|
|
popFade = math.Max(0, float64(y)/float64(popZone))
|
|
}
|
|
|
|
// Draw hollow ring.
|
|
rInner := radius - 0.9
|
|
bbox := int(radius) + 1
|
|
for dy := -bbox; dy <= bbox; dy++ {
|
|
for dx := -bbox; dx <= bbox; dx++ {
|
|
dist := math.Sqrt(float64(dx*dx + dy*dy))
|
|
if dist > radius || dist < rInner {
|
|
continue
|
|
}
|
|
// Stable per-bubble pop pattern (no frame dependency) so the
|
|
// ring doesn't strobe as it fades near the top.
|
|
if popFade < 1.0 && scatterHash(i, dy, dx, 0) > popFade {
|
|
continue
|
|
}
|
|
gy := y + dy
|
|
gx := x + dx
|
|
if gy >= 0 && gy < dotRows && gx >= 0 && gx < dotCols {
|
|
grid[gy*dotCols+gx] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Specular highlight — small bright cluster in the upper-left quadrant.
|
|
if radius >= 2.0 && popFade > 0.5 {
|
|
hx := x - int(radius*0.45)
|
|
hy := y - int(radius*0.45)
|
|
for _, d := range [][2]int{{0, 0}, {0, 1}, {1, 0}} {
|
|
gy := hy + d[0]
|
|
gx := hx + d[1]
|
|
if gy >= 0 && gy < dotRows && gx >= 0 && gx < dotCols {
|
|
grid[gy*dotCols+gx] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
lines := make([]string, height)
|
|
for row := range height {
|
|
var content strings.Builder
|
|
for ch := range PanelWidth {
|
|
var braille rune = '\u2800'
|
|
for dr := range 4 {
|
|
for dc := range 2 {
|
|
if grid[(row*4+dr)*dotCols+ch*2+dc] {
|
|
braille |= brailleBit[dr][dc]
|
|
}
|
|
}
|
|
}
|
|
content.WriteRune(braille)
|
|
}
|
|
// Top rows warm (light through surface), bottom rows cool (depth).
|
|
lines[row] = specWrap(float64(height-1-row)/float64(height), content.String())
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|