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

289 lines
6.4 KiB
Go

package client
import (
"fmt"
"image"
"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 client struct {
world *world.World
drawer *world.GGDrawer
raster *canvas.Raster
co *RasterCoalescer[world.RenderParams]
pan *PanController
// 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
// 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
// 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
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
lastIndexedViewportW int
lastIndexedViewportH int
lastIndexedZoomFp int
lastCanvasW int
lastCanvasH int
viewportImg *image.RGBA
viewportW int
viewportH int
hits []world.Hit
}
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),
}
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()
}
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, &params, 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()
}
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(
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 *client) 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)
} else {
if e.world.Theme().ID() == "theme.light.v1" {
e.world.SetTheme(world.ThemeDark)
} else {
e.world.SetTheme(world.ThemeLight)
}
}
e.RequestRefresh()
}