264 lines
6.2 KiB
Go
264 lines
6.2 KiB
Go
package client
|
|
|
|
import (
|
|
"image"
|
|
"math"
|
|
"sync"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/layout"
|
|
"github.com/iliadenisov/galaxy/client/world"
|
|
)
|
|
|
|
type Editor interface {
|
|
BuildUI(fyne.Window)
|
|
}
|
|
|
|
type editor struct {
|
|
world *world.World
|
|
drawer *world.GGDrawer
|
|
raster *canvas.Raster
|
|
canvasScale float32
|
|
|
|
canvas *interactiveRaster
|
|
win fyne.Window
|
|
|
|
// Coalescer for latest-wins refresh scheduling.
|
|
co *RasterCoalescer[world.RenderParams]
|
|
|
|
pan *PanController
|
|
|
|
// 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
|
|
}
|
|
|
|
func (e *editor) CanvasScale() float32 { return e.canvasScale }
|
|
|
|
func (e *editor) ForceFullRedraw() {
|
|
e.world.ForceFullRedrawNext()
|
|
}
|
|
|
|
func (e *editor) buildUI() fyne.CanvasObject {
|
|
return e.canvas
|
|
}
|
|
|
|
// здесь определяю, изменились ли границы raster, если да - обновляю размеры viewport, margin и корректирую zoom
|
|
func (e *editor) updateSizes() {
|
|
canvas := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
|
if canvas == nil {
|
|
return
|
|
}
|
|
|
|
size := e.raster.Size()
|
|
e.canvasScale = canvas.Scale()
|
|
|
|
width := int(size.Width * e.canvasScale)
|
|
height := int(size.Height * e.canvasScale)
|
|
|
|
if width > 0 && height > 0 && (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
|
|
|
|
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)
|
|
e.co.Request(*e.wp)
|
|
}
|
|
}
|
|
|
|
func (e *editor) onScrolled(s *fyne.ScrollEvent) {
|
|
vw := e.wp.ViewportWidthPx
|
|
vh := e.wp.ViewportHeightPx
|
|
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
|
|
}
|
|
|
|
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
|
|
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 {
|
|
return
|
|
}
|
|
|
|
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
|
|
if err != nil {
|
|
return
|
|
}
|
|
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
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.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.
|
|
e.world.ForceFullRedrawNext()
|
|
|
|
e.co.Request(*e.wp)
|
|
}
|
|
|
|
func (e *editor) onDragged(ev *fyne.DragEvent) {
|
|
e.pan.Dragged(ev)
|
|
}
|
|
|
|
func (e *editor) onDradEnd() {
|
|
e.pan.DragEnd()
|
|
}
|
|
|
|
func (e *editor) wheelZoom(stepDelta int) {}
|
|
|
|
func (e *editor) InitImage() {
|
|
s := fyne.NewSize(292, 292)
|
|
e.canvas.SetMinSize(s)
|
|
e.updateSizes()
|
|
}
|
|
|
|
func (e *editor) onMapLayout(s fyne.Size) {
|
|
e.updateSizes()
|
|
}
|
|
|
|
func (e *editor) BuildUI(w fyne.Window) {
|
|
e.win = w
|
|
content := container.New(layout.NewStackLayout(), e.buildUI())
|
|
w.CenterOnScreen()
|
|
w.SetContent(content)
|
|
}
|
|
|
|
func NewEditor() *editor {
|
|
w := world.NewWorld(300, 300)
|
|
testWorldInit(w)
|
|
e := &editor{
|
|
world: w,
|
|
wp: &world.RenderParams{
|
|
CameraZoom: 1.0,
|
|
CameraXWorldFp: 300 * world.SCALE,
|
|
CameraYWorldFp: 300 * world.SCALE,
|
|
// Viewport sizes and margins will be filled from draw(w,h).
|
|
Options: &world.RenderOptions{DisableWrapScroll: false},
|
|
},
|
|
canvasScale: 1.0,
|
|
}
|
|
|
|
// 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.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onScrolled, e.onDragged, e.onDradEnd)
|
|
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()
|
|
|
|
e.InitImage()
|
|
return e
|
|
}
|
|
|
|
func testWorldInit(w *world.World) {
|
|
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); 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)
|
|
}
|
|
}
|