Files
galaxy-game/client/coalesce.go
2026-03-09 14:26:17 +03:00

134 lines
3.5 KiB
Go

package client
/*
Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image).
Key property:
- draw() renders at most once per invocation (never loops).
- if new requests arrived while drawing, we schedule exactly one extra Refresh.
*/
import (
"image"
"sync"
)
// UIExecutor posts a function to run on the UI/main thread.
type UIExecutor interface {
Post(fn func())
}
// Refresher is the minimal interface we need from fyne.CanvasObject / Raster.
type Refresher interface {
Refresh()
}
// RasterRenderer renders the latest params and returns an image.
// Must be called on the UI thread (inside draw callback).
type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image
// RasterCoalescer implements latest-wins coalescing for raster rendering.
// It is designed specifically for toolkits like fyne where the system calls draw(w,h)
// and expects a returned image.
type RasterCoalescer[P any] struct {
exec UIExecutor
refresher Refresher
renderer RasterRenderer[P]
mu sync.Mutex
// inDraw == true while Draw() is running on UI thread.
inDraw bool
// refreshQueued == true when we have already posted a Refresh() that has not yet
// resulted in a Draw() call (or is expected to call Draw soon).
refreshQueued bool
// pending == true when new params arrived while inDraw==true.
// Draw() will schedule exactly one follow-up Refresh after it returns.
pending bool
latest P
have bool
}
// NewRasterCoalescer creates a new coalescer.
// - exec.Post must run fn on UI thread.
// - refresher.Refresh will trigger the framework to call draw(w,h).
func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] {
if exec == nil {
panic("RasterCoalescer: nil executor")
}
if refresher == nil {
panic("RasterCoalescer: nil refresher")
}
if renderer == nil {
panic("RasterCoalescer: nil renderer")
}
return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer}
}
// Request stores the latest params and schedules exactly one refresh (latest-wins).
// Can be called from any goroutine.
func (c *RasterCoalescer[P]) Request(params P) {
c.mu.Lock()
c.latest = params
c.have = true
// If we are currently inside Draw(), don't schedule refresh immediately.
// Just mark pending; Draw() will schedule one follow-up refresh after it returns.
if c.inDraw {
c.pending = true
c.mu.Unlock()
return
}
// Not drawing. Schedule at most one refresh until the next Draw() happens.
if c.refreshQueued {
c.mu.Unlock()
return
}
c.refreshQueued = true
c.mu.Unlock()
c.exec.Post(c.refresher.Refresh)
}
// Draw must be called from the raster draw callback on the UI thread.
// It renders exactly once with the latest snapshot.
// If more requests arrived while drawing, it schedules exactly one extra refresh.
func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image {
c.mu.Lock()
// A Draw call corresponds to a previously scheduled refresh being serviced.
c.refreshQueued = false
if !c.have {
c.mu.Unlock()
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
}
c.inDraw = true
c.pending = false
params := c.latest
c.mu.Unlock()
img := c.renderer(wPx, hPx, params)
c.mu.Lock()
needAnother := c.pending
c.pending = false
c.inDraw = false
// If we need another frame, schedule exactly one refresh (if not already queued).
if needAnother && !c.refreshQueued {
c.refreshQueued = true
c.mu.Unlock()
c.exec.Post(c.refresher.Refresh)
return img
}
c.mu.Unlock()
return img
}