client refactor
This commit is contained in:
+145
-200
@@ -3,7 +3,6 @@ package client
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
@@ -15,36 +14,42 @@ import (
|
||||
"github.com/iliadenisov/galaxy/client/world"
|
||||
)
|
||||
|
||||
type Editor interface {
|
||||
BuildUI(fyne.Window)
|
||||
}
|
||||
type client struct {
|
||||
world *world.World
|
||||
drawer *world.GGDrawer
|
||||
raster *canvas.Raster
|
||||
co *RasterCoalescer[world.RenderParams]
|
||||
pan *PanController
|
||||
|
||||
type editor struct {
|
||||
world *world.World
|
||||
drawer *world.GGDrawer
|
||||
raster *canvas.Raster
|
||||
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
|
||||
// Viewport/margins are NOT stored here; they come from raster draw callback.
|
||||
mu sync.RWMutex
|
||||
wp *world.RenderParams
|
||||
canvasScale float32
|
||||
|
||||
win fyne.Window
|
||||
// Latest raster geometry metadata for correct event->pixel conversion:
|
||||
// - logical size: raster.Size() (Fyne units)
|
||||
// - pixel size: last (wPx,hPx) passed to draw callback
|
||||
metaMu sync.RWMutex
|
||||
lastRasterLogicW float32
|
||||
lastRasterLogicH float32
|
||||
lastRasterPxW int
|
||||
lastRasterPxH int
|
||||
lastCanvasScale float32 // optional, useful for debugging
|
||||
|
||||
// Coalescer for latest-wins refresh scheduling.
|
||||
co *RasterCoalescer[world.RenderParams]
|
||||
// Snapshot of params actually used for the last render (includes viewport/margins).
|
||||
// Used for HitTest and to keep UI interactions consistent with what the user sees.
|
||||
lastRenderedMu sync.RWMutex
|
||||
lastRenderedParams world.RenderParams
|
||||
|
||||
pan *PanController
|
||||
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
|
||||
lastIndexedViewportW int
|
||||
lastIndexedViewportH int
|
||||
lastIndexedZoomFp int
|
||||
|
||||
// Protected render params state. Stored as value to avoid aliasing issues.
|
||||
mu sync.RWMutex
|
||||
wp *world.RenderParams
|
||||
|
||||
// Last viewport size we indexed the world for.
|
||||
lastViewportW int
|
||||
lastViewportH int
|
||||
|
||||
// Optional: you can keep the last expanded canvas size to avoid reallocations.
|
||||
lastCanvasW int
|
||||
lastCanvasH int
|
||||
|
||||
// Reusable viewport buffer to avoid per-frame allocations.
|
||||
viewportImg *image.RGBA
|
||||
viewportW int
|
||||
viewportH int
|
||||
@@ -52,99 +57,160 @@ type editor struct {
|
||||
hits []world.Hit
|
||||
}
|
||||
|
||||
func (e *editor) CanvasScale() float32 { return e.canvasScale }
|
||||
func NewClient() *client {
|
||||
e := &client{
|
||||
world: nil,
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
lastCanvasScale: 1.0,
|
||||
hits: make([]world.Hit, 5),
|
||||
}
|
||||
|
||||
func (e *editor) ForceFullRedraw() {
|
||||
e.drawer = &world.GGDrawer{DC: nil}
|
||||
|
||||
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||
return e.draw(wPx, hPx)
|
||||
})
|
||||
|
||||
e.pan = NewPanController(e)
|
||||
|
||||
e.co = NewRasterCoalescer(
|
||||
FyneExecutor{},
|
||||
e.raster,
|
||||
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||
return e.renderRasterImage(wPx, hPx, p)
|
||||
},
|
||||
)
|
||||
|
||||
e.RequestRefresh()
|
||||
return e
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// здесь определяю, изменились ли границы raster, если да - обновляю размеры viewport, margin и корректирую zoom
|
||||
func (e *editor) updateSizes() {
|
||||
canvas := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
||||
if canvas == nil {
|
||||
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
|
||||
}
|
||||
|
||||
size := e.raster.Size()
|
||||
e.canvasScale = canvas.Scale()
|
||||
sz := e.raster.Size() // logical (Fyne units)
|
||||
scale := canvasObj.Scale()
|
||||
|
||||
width := int(size.Width * e.canvasScale)
|
||||
height := int(size.Height * e.canvasScale)
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterLogicW = sz.Width
|
||||
e.lastRasterLogicH = sz.Height
|
||||
e.lastCanvasScale = scale
|
||||
e.metaMu.Unlock()
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
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
|
||||
}
|
||||
|
||||
if width != e.wp.ViewportWidthPx || height != e.wp.ViewportHeightPx {
|
||||
e.wp.ViewportWidthPx = width
|
||||
e.wp.ViewportHeightPx = height
|
||||
e.wp.MarginXPx = e.wp.ViewportWidthPx / 4
|
||||
e.wp.MarginYPx = e.wp.ViewportHeightPx / 4
|
||||
defer func() { e.co.Request(*e.wp) }()
|
||||
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e.world != nil {
|
||||
e.wp.CameraZoom = e.world.CorrectCameraZoom(e.wp.CameraZoom, e.wp.ViewportWidthPx, e.wp.ViewportHeightPx)
|
||||
e.world.IndexOnViewportChange(e.wp.ViewportWidthPx, e.wp.ViewportHeightPx, e.wp.CameraZoom)
|
||||
e.world.ClampRenderParamsNoWrap(e.wp)
|
||||
|
||||
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 *editor) onScrolled(s *fyne.ScrollEvent) {
|
||||
vw := e.wp.ViewportWidthPx
|
||||
vh := e.wp.ViewportHeightPx
|
||||
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
|
||||
}
|
||||
|
||||
// Cursor position in viewport pixels (Fyne units -> px by multiplying).
|
||||
cxPx := int(float32(s.Position.X) * e.canvasScale)
|
||||
cyPx := int(float32(s.Position.Y) * e.canvasScale)
|
||||
|
||||
if cxPx < 0 {
|
||||
cxPx = 0
|
||||
} else if cxPx > vw {
|
||||
cxPx = vw
|
||||
}
|
||||
if cyPx < 0 {
|
||||
cyPx = 0
|
||||
} else if cyPx > vh {
|
||||
cyPx = vh
|
||||
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
oldZoom := e.wp.CameraZoom
|
||||
|
||||
// Exponential zoom:
|
||||
// - Each "notch" multiplies zoom by a fixed factor.
|
||||
// - Using DY directly as exponent gives smooth trackpad behavior too.
|
||||
//
|
||||
// Tune base:
|
||||
// base=1.10 => ~10% per wheel step (if DY≈1 per step)
|
||||
// base=1.05 => ~5% per step
|
||||
//
|
||||
// In user settings, better store on percents userZoomLevelPercent = (0,100]: base = 1 + (userZoomLevelPercent) / 10
|
||||
// Exponential zoom factor; tune later.
|
||||
const base = 1.005
|
||||
|
||||
// Negative DY => zoom out, positive DY => zoom in (depending on platform settings).
|
||||
// If you want inverted direction, negate float64(s.Scrolled.DY).
|
||||
delta := float64(s.Scrolled.DY)
|
||||
|
||||
newZoom := oldZoom * math.Pow(base, delta)
|
||||
|
||||
// Clamp/correct (min/max + prevent wrap if needed).
|
||||
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,
|
||||
@@ -155,56 +221,15 @@ func (e *editor) onScrolled(s *fyne.ScrollEvent) {
|
||||
e.wp.CameraZoom = newZoom
|
||||
e.wp.CameraXWorldFp = newCamX
|
||||
e.wp.CameraYWorldFp = newCamY
|
||||
e.mu.Unlock()
|
||||
|
||||
e.world.IndexOnViewportChange(vw, vh, e.wp.CameraZoom)
|
||||
|
||||
// No-wrap clamp to avoid "detaching" from borders.
|
||||
e.world.ClampRenderParamsNoWrap(e.wp)
|
||||
|
||||
// Zoom changes are best done as full redraw.
|
||||
// Any zoom change should rebuild index and force full redraw.
|
||||
e.world.ForceFullRedrawNext()
|
||||
|
||||
e.co.Request(*e.wp)
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *editor) onDragged(ev *fyne.DragEvent) {
|
||||
e.pan.Dragged(ev)
|
||||
}
|
||||
|
||||
func (e *editor) onDradEnd() {
|
||||
e.pan.DragEnd()
|
||||
}
|
||||
|
||||
func (e *editor) onTapped(ev *fyne.PointEvent) {
|
||||
if e.world == nil {
|
||||
return
|
||||
}
|
||||
hits, err := e.world.HitTest(e.hits, e.wp, int(ev.Position.X*e.canvasScale), int(ev.Position.Y*e.canvasScale))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m := func(v int) float64 {
|
||||
return float64(v) / float64(world.SCALE)
|
||||
}
|
||||
var coord string
|
||||
for _, hit := range hits {
|
||||
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 *editor) onMapLayout(s fyne.Size) {
|
||||
e.updateSizes()
|
||||
}
|
||||
|
||||
func (e *editor) BuildUI(w fyne.Window) {
|
||||
e.win = w
|
||||
|
||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
func (e *client) BuildUI(w fyne.Window) {
|
||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
|
||||
|
||||
toolbar := widget.NewToolbar(
|
||||
@@ -244,7 +269,7 @@ func (e *editor) BuildUI(w fyne.Window) {
|
||||
w.SetContent(content)
|
||||
}
|
||||
|
||||
func (e *editor) loadWorld(w *world.World) {
|
||||
func (e *client) loadWorld(w *world.World) {
|
||||
if e.world == nil {
|
||||
w.SetCircleRadiusScaleFp(world.SCALE / 4)
|
||||
e.world = w
|
||||
@@ -252,7 +277,6 @@ func (e *editor) loadWorld(w *world.World) {
|
||||
e.wp.CameraXWorldFp = w.W / 2
|
||||
e.wp.CameraYWorldFp = w.H / 2
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
e.updateSizes()
|
||||
} else {
|
||||
if e.world.Theme().ID() == "theme.light.v1" {
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
@@ -262,82 +286,3 @@ func (e *editor) loadWorld(w *world.World) {
|
||||
}
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func NewEditor() *editor {
|
||||
e := &editor{
|
||||
world: nil,
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
canvasScale: 1.0,
|
||||
hits: make([]world.Hit, 5),
|
||||
}
|
||||
|
||||
// Create a drawer with some initial context; real size will be adjusted on first draw.
|
||||
e.drawer = &world.GGDrawer{DC: nil}
|
||||
|
||||
// Create raster; its draw callback delegates to coalescer.
|
||||
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||
return e.draw(wPx, hPx)
|
||||
})
|
||||
|
||||
e.pan = NewPanController(e)
|
||||
|
||||
// Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call.
|
||||
exec := FyneExecutor{}
|
||||
e.co = NewRasterCoalescer(
|
||||
exec,
|
||||
e.raster, // Refresher
|
||||
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||
// This runs on UI thread (inside draw). It must return an image.
|
||||
return e.renderRasterImage(wPx, hPx, p)
|
||||
},
|
||||
)
|
||||
|
||||
// Kick initial draw.
|
||||
e.RequestRefresh()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func mockWorld() *world.World {
|
||||
w := world.NewWorld(300, 300)
|
||||
mockWorldInit(w)
|
||||
return w
|
||||
}
|
||||
|
||||
func mockWorldInit(w *world.World) {
|
||||
lineStyle := w.AddStyleLine(world.StyleOverride{
|
||||
StrokeColor: color.RGBA{R: 0, G: 255, B: 0, A: 255},
|
||||
StrokeWidthPx: new(3.0),
|
||||
StrokeDashes: new([]float64{10.}),
|
||||
})
|
||||
|
||||
if _, err := w.AddCircle(150, 150, 50); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddCircle(150, 299, 30); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := w.AddCircle(299, 150, 30); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddLine(100, 20, 200, 30, world.LineWithStyleID(lineStyle), world.LineWithPriority(500)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddLine(50, 50, 250, 100); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddPoint(10, 10); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddPoint(25, 255); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user