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

210 lines
6.4 KiB
Go

package client
import (
"image"
"math"
"fyne.io/fyne/v2"
"github.com/fogleman/gg"
"github.com/iliadenisov/galaxy/client/world"
)
/*
Fyne integration notes:
- canvas.NewRaster calls draw(w,h) on the UI thread.
- We MUST keep draw() cheap and never loop re-rendering inside it.
- Coalescing must therefore schedule refreshes and render at most once per draw call.
- The world renderer expects:
- RenderParams.ViewportWidthPx/HeightPx: the size of the visible viewport.
- RenderParams.MarginXPx/MarginYPx: margins around viewport.
- RenderParams.CameraXWorldFp/YWorldFp: camera center in world-fixed units.
- RenderParams.CameraZoom: float zoom (converted inside world).
- world.Render draws on the full expanded canvas (viewport + 2*margins on each axis).
This adapter enforces:
- viewport sizes come from draw(w,h)
- margins are computed from viewport sizes (w/4 and h/4)
- gg context backing image is resized to the expanded canvas size
- IndexOnViewportChange is called when viewport sizes changed (you can also include zoom if desired)
*/
var (
blankImage image.Image = image.NewRGBA(image.Rect(0, 0, 0, 0))
)
// FyneExecutor posts functions onto the Fyne UI thread.
type FyneExecutor struct{}
func (FyneExecutor) Post(fn func()) {
fyne.Do(fn)
}
// GetParams returns a copy of current render params for external reads.
func (e *client) GetParams() world.RenderParams {
e.mu.RLock()
defer e.mu.RUnlock()
return *e.wp
}
// UpdateParams applies a modification function to render params and schedules a refresh.
// This is a safe way to mutate camera/zoom from event handlers.
func (e *client) UpdateParams(fn func(p *world.RenderParams)) {
e.mu.Lock()
fn(e.wp)
p := *e.wp
e.mu.Unlock()
e.co.Request(p)
}
// RequestRefresh schedules a refresh with the current params snapshot.
// Useful if you changed world objects and want to redraw.
func (e *client) RequestRefresh() {
e.mu.RLock()
p := *e.wp
e.mu.RUnlock()
e.co.Request(p)
}
// draw is the raster callback. It must be cheap and must not block on multiple re-renders.
// It delegates coalescing + rendering decision to RasterCoalescer.
func (e *client) draw(wPx, hPx int) image.Image {
return e.co.Draw(wPx, hPx)
}
// renderRasterImage renders the expanded canvas into the GGDrawer backing image,
// then copies only the viewport ROI into a reusable viewport buffer and returns it.
func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image {
if e.world == nil {
return image.NewRGBA(image.Rect(0, 0, 0, 0))
}
// Record current raster pixel size (used for event coordinate conversion).
e.metaMu.Lock()
e.lastRasterPxW = viewportW
e.lastRasterPxH = viewportH
e.metaMu.Unlock()
// Fill viewport/margins derived from draw callback.
p.ViewportWidthPx = viewportW
p.ViewportHeightPx = viewportH
p.MarginXPx = viewportW / 4
p.MarginYPx = viewportH / 4
// Correct zoom for viewport/world constraints, and clamp camera for no-wrap.
p.CameraZoom = e.world.CorrectCameraZoom(p.CameraZoom, viewportW, viewportH)
// Ensure indexing is up-to-date when viewport size or zoom changes.
zoomFp, err := p.CameraZoomFp()
if err == nil {
if viewportW != e.lastIndexedViewportW || viewportH != e.lastIndexedViewportH || zoomFp != e.lastIndexedZoomFp {
e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom)
e.lastIndexedViewportW = viewportW
e.lastIndexedViewportH = viewportH
e.lastIndexedZoomFp = zoomFp
}
}
e.world.ClampRenderParamsNoWrap(&p)
// Ensure backing expanded canvas (gg context) is sized properly.
canvasW := p.CanvasWidthPx()
canvasH := p.CanvasHeightPx()
e.ensureDrawerCanvas(canvasW, canvasH)
// Render into expanded canvas backing.
_ = e.world.Render(e.drawer, p) // TODO: handle error
// Save snapshot of params actually used for this render (for HitTest consistency).
e.lastRenderedMu.Lock()
e.lastRenderedParams = p
e.lastRenderedMu.Unlock()
// Copy viewport ROI into reusable viewport buffer and return it.
e.ensureViewportBuffer(viewportW, viewportH)
src, ok := e.drawer.DC.Image().(*image.RGBA)
if !ok || src == nil {
return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH))
}
copyViewportRGBA(e.viewportImg, src, p.MarginXPx, p.MarginYPx, viewportW, viewportH)
return e.viewportImg
}
// ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH.
func (e *client) ensureDrawerCanvas(canvasW, canvasH int) {
if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH {
return
}
// world.NewGGContextRGBA should return *gg.Context backed by *image.RGBA (gg.NewContext does).
e.drawer.DC = NewGGContextRGBA(canvasW, canvasH)
e.lastCanvasW = canvasW
e.lastCanvasH = canvasH
}
func (e *client) ensureViewportBuffer(w, h int) {
if e.viewportImg != nil && e.viewportW == w && e.viewportH == h {
return
}
e.viewportImg = image.NewRGBA(image.Rect(0, 0, w, h))
e.viewportW = w
e.viewportH = h
}
func (e *client) getLastRenderedParams() world.RenderParams {
e.lastRenderedMu.RLock()
defer e.lastRenderedMu.RUnlock()
return e.lastRenderedParams
}
// eventPosToPixel converts event logical coordinates (Fyne units) into pixel coordinates,
// using the last known raster logical size and the last draw callback pixel size.
//
// pixelX = floor(eventX * rasterPixelWidth / rasterLogicalWidth)
func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool) {
e.metaMu.RLock()
logW := e.lastRasterLogicW
logH := e.lastRasterLogicH
pxW := e.lastRasterPxW
pxH := e.lastRasterPxH
e.metaMu.RUnlock()
if logW <= 0 || logH <= 0 || pxW <= 0 || pxH <= 0 {
return 0, 0, false
}
x := int(math.Floor(float64(eventX) * float64(pxW) / float64(logW)))
y := int(math.Floor(float64(eventY) * float64(pxH) / float64(logH)))
// Clamp to viewport bounds.
if x < 0 {
x = 0
} else if x > pxW {
x = pxW
}
if y < 0 {
y = 0
} else if y > pxH {
y = pxH
}
return x, y, true
}
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
// It avoids SubImage aliasing issues: dst becomes independent from src backing memory.
func copyViewportRGBA(dst, src *image.RGBA, marginX, marginY, vw, vh int) {
for y := 0; y < vh; y++ {
srcOff := (marginY+y)*src.Stride + marginX*4
dstOff := y * dst.Stride
n := vw * 4
copy(dst.Pix[dstOff:dstOff+n], src.Pix[srcOff:srcOff+n])
}
}
func NewGGContextRGBA(w, h int) *gg.Context {
return gg.NewContext(w, h)
}