366 lines
9.8 KiB
Go
366 lines
9.8 KiB
Go
package client
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"math"
|
|
|
|
"galaxy/client/world"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"github.com/fogleman/gg"
|
|
)
|
|
|
|
/*
|
|
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))
|
|
}
|
|
|
|
// Keep the incoming zoom snapshot so we can safely sync corrected zoom back
|
|
// to base params only when no newer zoom was written concurrently.
|
|
inputZoom := p.CameraZoom
|
|
|
|
// 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.
|
|
correctedZoom := e.world.CorrectCameraZoom(inputZoom, viewportW, viewportH)
|
|
p.CameraZoom = correctedZoom
|
|
|
|
// Sync corrected zoom to the canonical UI-facing params snapshot.
|
|
// Guard prevents stale render snapshots from overwriting a newer zoom value
|
|
// that may have been set by another UI event.
|
|
e.mu.Lock()
|
|
if e.wp.CameraZoom == inputZoom {
|
|
e.wp.CameraZoom = correctedZoom
|
|
}
|
|
e.mu.Unlock()
|
|
|
|
// 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
|
|
}
|
|
|
|
func (e *client) CanvasScale() float32 {
|
|
e.metaMu.RLock()
|
|
defer e.metaMu.RUnlock()
|
|
if e.lastCanvasScale <= 0 {
|
|
return 1
|
|
}
|
|
return e.lastCanvasScale
|
|
}
|
|
|
|
func (e *client) ForceFullRedraw() {
|
|
if e.world == nil {
|
|
return
|
|
}
|
|
e.world.ForceFullRedrawNext()
|
|
}
|
|
|
|
func (e *client) onRasterWidgetLayout(fyne.Size) {
|
|
e.updateSizes()
|
|
}
|
|
|
|
// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw.
|
|
// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback).
|
|
func (e *client) updateSizes() {
|
|
canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
|
if canvasObj == nil {
|
|
return
|
|
}
|
|
|
|
sz := e.raster.Size() // logical (Fyne units)
|
|
scale := canvasObj.Scale()
|
|
|
|
e.metaMu.Lock()
|
|
e.lastRasterLogicW = sz.Width
|
|
e.lastRasterLogicH = sz.Height
|
|
e.lastCanvasScale = scale
|
|
e.metaMu.Unlock()
|
|
|
|
e.RequestRefresh()
|
|
}
|
|
|
|
func (e *client) onDragged(ev *fyne.DragEvent) {
|
|
e.pan.Dragged(ev)
|
|
}
|
|
|
|
func (e *client) onDradEnd() {
|
|
e.pan.DragEnd()
|
|
}
|
|
|
|
func (e *client) onTapped(ev *fyne.PointEvent) {
|
|
if e.world == nil || ev == nil {
|
|
return
|
|
}
|
|
|
|
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
params := e.getLastRenderedParams()
|
|
hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx)
|
|
if err != nil {
|
|
// In UI you probably don't want panic; keep your existing handling.
|
|
panic(err)
|
|
}
|
|
|
|
m := func(v int) float64 { return float64(v) / float64(world.SCALE) }
|
|
|
|
for _, hit := range hits {
|
|
var coord string
|
|
if hit.Kind == world.KindLine {
|
|
coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
|
|
} else {
|
|
coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
|
|
}
|
|
fmt.Println("hit:", hit.ID, "Coord:", coord)
|
|
}
|
|
}
|
|
|
|
func (e *client) onScrolled(s *fyne.ScrollEvent) {
|
|
if e.world == nil || s == nil {
|
|
return
|
|
}
|
|
|
|
// Use last rendered viewport sizes (pixel) for zoom logic.
|
|
e.metaMu.RLock()
|
|
vw := e.lastRasterPxW
|
|
vh := e.lastRasterPxH
|
|
e.metaMu.RUnlock()
|
|
if vw <= 0 || vh <= 0 {
|
|
return
|
|
}
|
|
|
|
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
e.mu.Lock()
|
|
oldZoom := e.wp.CameraZoom
|
|
|
|
// Exponential zoom factor; tune later.
|
|
const base = 1.005
|
|
delta := float64(s.Scrolled.DY)
|
|
newZoom := oldZoom * math.Pow(base, delta)
|
|
|
|
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
|
|
if newZoom == oldZoom {
|
|
e.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
|
|
if err != nil {
|
|
e.mu.Unlock()
|
|
return
|
|
}
|
|
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
|
|
if err != nil {
|
|
e.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Pivot zoom for no-wrap behavior.
|
|
newCamX, newCamY := world.PivotZoomCameraNoWrap(
|
|
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
|
|
vw, vh,
|
|
cxPx, cyPx,
|
|
oldZoomFp, newZoomFp,
|
|
)
|
|
|
|
e.wp.CameraZoom = newZoom
|
|
e.wp.CameraXWorldFp = newCamX
|
|
e.wp.CameraYWorldFp = newCamY
|
|
e.mu.Unlock()
|
|
|
|
// Any zoom change should rebuild index and force full redraw.
|
|
e.world.ForceFullRedrawNext()
|
|
e.RequestRefresh()
|
|
}
|
|
|
|
// 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)
|
|
}
|