Files
galaxy-game/client/editor.go
T
2026-03-08 15:31:17 +02:00

344 lines
7.9 KiB
Go

package client
import (
"fmt"
"image"
"image/color"
"math"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"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
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
hits []world.Hit
}
func (e *editor) CanvasScale() float32 { return e.canvasScale }
func (e *editor) ForceFullRedraw() {
e.world.ForceFullRedrawNext()
}
// здесь определяю, изменились ли границы 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 {
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) }()
}
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)
}
}
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) 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)
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
toolbar := widget.NewToolbar(
widget.NewToolbarAction(
theme.FolderIcon(),
func() { e.loadWorld(mockWorld()) }),
widget.NewToolbarSeparator(),
widget.NewToolbarAction(
theme.NavigateBackIcon(),
func() {}),
widget.NewToolbarAction(
theme.NavigateNextIcon(),
func() {}),
)
tabs := container.NewAppTabs(
container.NewTabItemWithIcon(
"Map",
theme.GridIcon(),
mapCanvas),
container.NewTabItemWithIcon(
"Calculator",
theme.ComputerIcon(),
container.NewStack(widget.NewButton("Calc", func() {})),
),
)
content := container.NewBorder(
toolbar, // top
nil, // bottom
nil, // left
nil, // right
tabs, // center
)
w.CenterOnScreen()
w.SetContent(content)
}
func (e *editor) loadWorld(w *world.World) {
if e.world == nil {
w.SetCircleRadiusScaleFp(world.SCALE / 4)
e.world = w
// TODO: store camera position in user settings
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)
} else {
e.world.SetTheme(world.ThemeLight)
}
}
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)
}
}