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

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")
}