Files
radiantwave-legacy/internal/page/scrollerPage.go
T

649 lines
15 KiB
Go

package page
import (
"fmt"
"math"
"path/filepath"
"strings"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
const (
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 10
defaultProclamationPlayCount int = 1
)
type Line struct {
words []StringItem
width int32
}
// proclamationStream tracks one proclamation and how many full loops it should play.
type proclamationStream struct {
name string
words []string
repeat int
completed int
wordIndex int
}
// LineDeque is a double-ended queue for Line values backed by a circular buffer.
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
type LineDeque struct {
buf []Line
head, tail int
count int
}
func NewLineDeque(cap int) *LineDeque {
if cap < 4 {
cap = 4
}
return &LineDeque{buf: make([]Line, cap)}
}
func (d *LineDeque) Len() int { return d.count }
func (d *LineDeque) grow() {
newCap := len(d.buf) * 2
newBuf := make([]Line, newCap)
for i := 0; i < d.count; i++ {
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
}
d.buf = newBuf
d.head = 0
d.tail = d.count
}
func (d *LineDeque) PushBack(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.buf[d.tail] = l
d.tail = (d.tail + 1) % len(d.buf)
d.count++
}
func (d *LineDeque) PushFront(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
d.buf[d.head] = l
d.count++
}
func (d *LineDeque) PopFront() Line {
l := d.buf[d.head]
d.buf[d.head] = Line{} // clear for GC
d.head = (d.head + 1) % len(d.buf)
d.count--
return l
}
func (d *LineDeque) PopBack() Line {
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
l := d.buf[d.tail]
d.buf[d.tail] = Line{} // clear for GC
d.count--
return l
}
func (d *LineDeque) Front() Line { return d.buf[d.head] }
func (d *LineDeque) Back() Line {
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
}
func (d *LineDeque) Get(i int) *Line {
return &d.buf[(d.head+i)%len(d.buf)]
}
func (d *LineDeque) Set(i int, l Line) {
d.buf[(d.head+i)%len(d.buf)] = l
}
func (d *LineDeque) Clear() {
for i := 0; i < d.count; i++ {
d.buf[(d.head+i)%len(d.buf)] = Line{}
}
d.head, d.tail, d.count = 0, 0, 0
}
type ScrollerPage struct {
Base
streams []proclamationStream
streamIndex int
lines *LineDeque
lineHeight int32
shader *shaderManager.Shader
activePattern linePattern.LinePattern
font *fontManager.FontEntry
spaceWidth int32
lineCtx *linePattern.LineRenderContext
colorPattern *colorPattern.ColorPattern
scrollOffset float32
scrollY float32
fontScale float32
// Reuse one GL texture per unique word.
wordTextureCache map[string]StringItem
increment float32
velocity float32
// Time-based velocity control
velocityFloor float32
velocityCeiling float32
velocityStep float32
velocityDelay float32
velocityDirection int
velocityTimer float32
velocityState int
autoVelocity bool
hold float32
}
func (p *ScrollerPage) Init(ctx *PageCtx) error {
p.increment = 100.0
p.velocity = 800.0
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
sm := shaderManager.Get()
fm := fontManager.Get()
// Get font
font, ok := fm.GetFont("Scrambled")
if !ok {
return fmt.Errorf("unable to fetch font 'Scrambled'")
}
p.font = font
logger.Debug("Font name: %s", font.Name)
p.Base.Init(ctx)
// Get text shader
textShader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("text shader not found")
}
p.shader = textShader
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
p.wordTextureCache = make(map[string]StringItem, 256)
// Load proclamation streams from database
if err := p.loadProclamations(); err != nil {
logger.Error("Error loading proclamations: %v", err)
p.Context.NavigateTo(PROCLAMATIONS)
return nil
}
// Get line pattern from database
var err error
linePatternName := p.Context.CfgPtr.LinePattern
p.activePattern, err = linePattern.Get(linePatternName)
if err != nil {
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
}
// Initialize color pattern
baseColors := []sdl.Color{
colors.Tan,
colors.PeachPuff,
colors.LightCyan,
colors.LightSeaGreen,
colors.MediumTurquoise,
colors.DarkCyan,
colors.MediumPurple,
}
p.colorPattern = colorPattern.NewColorPattern(baseColors)
// Font setup
p.fontScale = float32(32) / float32(p.Context.CfgPtr.StandardFontSize)
nLines := int(float32(p.ScreenHeight) / 32.0)
p.lines = NewLineDeque(nLines * 2)
// Calculate space width using the scale ratio.
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
if !ok {
return fmt.Errorf("failed to get space glyph")
}
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
// Get line height using the same scale ratio.
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
if err != nil {
return fmt.Errorf("failed to create sample texture: %w", err)
}
p.lineHeight = int32(float32(H) * p.fontScale)
gl.DeleteTextures(1, &tmpStrID)
p.lineCtx = &linePattern.LineRenderContext{
ScreenWidth: p.ScreenWidth,
Font: font,
FontScale: p.fontScale,
SpaceWidth: p.spaceWidth,
}
// Create initial lines
for i := range nLines {
if err := p.createLine(i, true); err != nil {
return fmt.Errorf("failed to create initial line %d: %w", i, err)
}
}
assetsDir := p.Context.CfgPtr.AssetsDir
audioFile := filepath.Join(assetsDir, "audio/audio.wav")
logger.Info("Attempting to play %s", audioFile)
if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil {
logger.Error("Mixer returned an error: %v", err)
}
currentDevice := mixer.CurrentDevice()
if currentDevice == "" {
currentDevice = "default"
}
_ = currentDevice
return nil
}
// loadProclamations loads selected proclamations as stream units (not one giant word list).
func (p *ScrollerPage) loadProclamations() error {
p.streams = p.streams[:0]
var selected []db.Proclamation
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
return err
}
var defaultProclamation db.Proclamation
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
return err
}
defaultWords := strings.Fields(defaultProclamation.Content)
// Build interleaved stream:
// each selected proclamation repeats N times, then default repeats once.
for _, proclamation := range selected {
// Prevent default proclamation from being treated as a normal selected proclamation.
if proclamation.ID == defaultProclamationID {
continue
}
words := strings.Fields(proclamation.Content)
if len(words) == 0 {
continue
}
p.streams = append(p.streams, proclamationStream{
name: proclamation.Name,
words: words,
repeat: proclamationPlayCount, // 10
})
if len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1, // play once between selected proclamations
})
}
}
// Fallback: if no non-default selected proclamations, at least play default.
if len(p.streams) == 0 && len(defaultWords) > 0 {
p.streams = append(p.streams, proclamationStream{
name: defaultProclamation.Name,
words: defaultWords,
repeat: 1,
})
}
if len(p.streams) == 0 {
return fmt.Errorf("failed to load proclamations: no proclamation words available")
}
p.streamIndex = 0
return nil
}
// nextLineWords gets words for the current line and advances proclamation stream when needed.
func (p *ScrollerPage) nextLineWords() []string {
if len(p.streams) == 0 {
return nil
}
s := &p.streams[p.streamIndex]
if len(s.words) == 0 {
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
return p.nextLineWords()
}
prevIdx := s.wordIndex
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
// If word index wraps, one full proclamation pass completed.
if s.wordIndex < prevIdx {
s.completed++
targetRepeats := s.repeat
if targetRepeats <= 0 {
targetRepeats = 1
}
logger.Debug("Now playing proclamation: %s", s.name)
if s.completed >= targetRepeats {
s.completed = 0
s.wordIndex = 0
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
}
}
return words
}
// getCachedWord returns a reusable textured StringItem for a word.
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
if cached, ok := p.wordTextureCache[word]; ok {
return cached, nil
}
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
}
base.Scale(p.fontScale)
p.wordTextureCache[word] = base
return base, nil
}
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
line := Line{words: []StringItem{}}
for _, word := range words {
if word == "" {
continue
}
cached, err := p.getCachedWord(word)
if err != nil {
return Line{}, err
}
item := cached
item.Color = p.colorPattern.Color()
if len(line.words) > 0 {
line.width += p.spaceWidth
}
line.words = append(line.words, item)
line.width += item.W
}
return line, nil
}
// positionLine sets X/Y for all words in a line.
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
startX := p.ScreenCenterX - (line.width / 2)
for j := range line.words {
if j == 0 {
line.words[j].X = startX
} else {
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
}
line.words[j].Y = baseY
}
}
// createLine is a helper to generate a new line at a specific index.
// isInitial determines if this is during initialization (positions from top)
// or dynamic creation (positions relative to existing lines).
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
words := p.nextLineWords()
line, err := p.buildLineFromWords(words)
if err != nil {
return err
}
var baseY int32
if isInitial {
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
} else {
baseY = 0
}
p.positionLine(&line, baseY)
if isInitial {
p.lines.PushBack(line)
}
return nil
}
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
switch e := (*event).(type) {
case *sdl.KeyboardEvent:
if e.Type != sdl.KEYDOWN {
return nil
}
switch e.Keysym.Sym {
case sdl.K_UP:
if !p.autoVelocity {
p.velocity += p.increment
}
case sdl.K_DOWN:
if !p.autoVelocity {
p.velocity -= p.increment
}
}
return nil
}
return nil
}
func (p *ScrollerPage) Update(dt float32) error {
p.updateAutoVelocity(dt)
p.scrollOffset += dt * p.velocity
move := int32(p.scrollOffset)
if move == 0 {
return nil
}
p.scrollOffset -= float32(move)
// Move all lines vertically
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for j := range line.words {
line.words[j].Y += move
}
}
// Handle line replacement based on scroll direction
if p.velocity > 0 {
// Moving up: replace lines that go off top
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
if err := p.addLineAtBottom(); err != nil {
return err
}
}
} else if p.velocity < 0 {
// Moving down: replace lines that go off bottom
for p.lines.Len() > 0 {
back := p.lines.Back()
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
break
}
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
if err := p.addLineAtTop(); err != nil {
return err
}
}
}
return nil
}
// addLineAtBottom creates and adds a new line at the bottom of the display.
func (p *ScrollerPage) addLineAtBottom() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
} else {
baseY = -int32(p.lineHeight)
}
p.positionLine(&line, baseY)
p.lines.PushBack(line)
return nil
}
// addLineAtTop creates and adds a new line at the top of the display.
func (p *ScrollerPage) addLineAtTop() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
} else {
baseY = p.ScreenHeight
}
p.positionLine(&line, baseY)
p.lines.PushFront(line)
return nil
}
func (p *ScrollerPage) Render() error {
p.BeginTextureRendering(p.shader)
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for _, word := range line.words {
if word.ID == 0 {
continue
}
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
}
}
p.EndTextureRendering()
return nil
}
func (p *ScrollerPage) Destroy() error {
if p == nil {
return nil
}
mixer.Stop(1000, p.Context.CfgPtr)
// Delete each cached word texture exactly once.
for k, item := range p.wordTextureCache {
if item.ID != 0 {
id := item.ID
gl.DeleteTextures(1, &id)
}
delete(p.wordTextureCache, k)
}
gl.Flush()
// Drop references for GC safely
p.lines = nil
p.streams = nil
p.lineCtx = nil
p.font = nil
p.shader = nil
p.colorPattern = nil
p.wordTextureCache = nil
return nil
}
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
p.autoVelocity = enabled
p.velocityFloor = -float32(math.Abs(float64(floor)))
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
p.velocityStep = float32(math.Abs(float64(step)))
p.velocityDelay = delay
p.hold = hold
// Initialize state
p.velocityDirection = 1
p.velocityState = 0
p.velocityTimer = 0.0
p.velocity = p.velocityFloor
}
// updateAutoVelocity handles automatic velocity changes.
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
if !p.autoVelocity {
return
}
switch p.velocityState {
case 0: // Changing velocity
if p.velocityDirection == 1 {
p.velocity += p.velocityStep * dt
if p.velocity >= p.velocityCeiling {
p.velocity = p.velocityCeiling
p.velocityState = 1
p.velocityTimer = 0.0
}
} else {
p.velocity -= p.velocityStep * dt
if p.velocity <= p.velocityFloor {
p.velocity = p.velocityFloor
p.velocityState = 2
p.velocityTimer = 0.0
}
}
case 1: // Waiting at ceiling
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = -1
p.velocityState = 0
}
case 2: // Waiting at floor
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = 1
p.velocityState = 0
}
}
}