package client import ( "image" "sync" "galaxy/client/updater" "galaxy/client/widget/calculator" "galaxy/client/world" "galaxy/connector" mc "galaxy/model/client" "galaxy/storage" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) const version = "1.0.0" type client struct { s storage.Storage conn connector.Connector app fyne.App window fyne.Window state *mc.State stateMu sync.RWMutex reg *registry calculator *calculator.Calculator mapSplitter *container.Split accInfo *widget.AccordionItem accCalc *widget.AccordionItem // loadReportFunc func(uint) 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 updater *updater.Manager backgroundStop chan struct{} backgroundOnce sync.Once onConnectionFn func(bool) onConnectionErrFn func(error) onStorageErrFn func(error) onServiceErrFn func(error) } func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) { e := &client{ s: s, conn: conn, app: app, window: app.NewWindow("Galaxy Plus"), reg: newRegistry(), lastCanvasScale: 1.0, world: nil, hits: make([]world.Hit, 5), backgroundStop: make(chan struct{}), } e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass)) e.updater = updater.NewManager(e.s, e.conn) stateExists, err := e.s.StateExists() if err != nil { return nil, err } if stateExists { state, err := e.s.LoadState() if err != nil { return nil, err } e.state = &state } else { e.state = &mc.State{ ClientCurrentVersion: e.Version(), CameraZoom: 1.0, MapSplitterOffset: 0.5, AccordionInfoOpen: false, AccordionCalcOpen: false, } if err := e.s.SaveState(*e.state); err != nil { return nil, err } } if e.state.CameraZoom <= 0 { e.state.CameraZoom = 1.0 } if e.state.MapSplitterOffset <= 0 { e.state.MapSplitterOffset = 0.5 } e.wp = &world.RenderParams{ Options: &world.RenderOptions{DisableWrapScroll: false}, CameraZoom: e.state.CameraZoom, CameraXWorldFp: e.state.CameraXFp, CameraYWorldFp: e.state.CameraYFp, } 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) }, ) return e, nil } func (e *client) BuildUI(w fyne.Window) { mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) toolbar := widget.NewToolbar( widget.NewToolbarAction( theme.FolderIcon(), func() { e.initReportAsync("GAME_ID", 0) }), widget.NewToolbarSeparator(), widget.NewToolbarAction( theme.NavigateBackIcon(), func() {}), widget.NewToolbarAction( theme.NavigateNextIcon(), func() {}), ) e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack()) e.accInfo.Open = e.state.AccordionInfoOpen e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject) e.accCalc.Open = e.state.AccordionCalcOpen accordion := widget.NewAccordion() accordion.MultiOpen = true accordion.Append(e.accCalc) accordion.Append(e.accInfo) e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion)) e.mapSplitter.SetOffset(e.state.MapSplitterOffset) tabs := container.NewAppTabs( container.NewTabItemWithIcon( lang.L("title.map"), theme.GridIcon(), e.mapSplitter), container.NewTabItemWithIcon( "Calculator", theme.ComputerIcon(), container.NewStack(widget.NewButton("Calc", func() {})), ), ) th := tabs.Theme() icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo)) statusLeft := widget.NewTextGridFromString("Status") statusAd := widget.NewTextGridFromString("") statusBar := container.NewBorder( nil, // top nil, // bottom container.NewHBox(statusLeft, widget.NewSeparator()), // left container.NewHBox(widget.NewSeparator(), icon), // right statusAd, // center ) content := container.NewBorder( toolbar, // top statusBar, // bottom nil, // left nil, // right tabs, // center ) w.CenterOnScreen() w.SetContent(content) s := statusBar.Size() icon.SetMinSize(fyne.NewSize(s.Height, s.Height)) e.initLatestReport() } func (e *client) loadWorld(w *world.World) { if w == nil { return } w.SetCircleRadiusScaleFp(world.SCALE / 1000) 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.RequestRefresh() } func (e *client) Run() error { e.BuildUI(e.window) e.startBackground() e.RequestRefresh() e.window.SetMaster() e.window.Resize(fyne.NewSize(800, 600)) e.window.CenterOnScreen() e.window.SetOnClosed(e.Shutdown) e.window.ShowAndRun() return nil } func (e *client) Shutdown() { e.stopBackground() e.ensureStatePersist() e.window.Close() } // TODO: remove func? func (e *client) Version() string { return version } func (e *client) OnConnection(isGood bool) { if e.onConnectionFn != nil { e.onConnectionFn(isGood) } } func (e *client) OnConnectionError(err error) { if e.onConnectionErrFn != nil { e.onConnectionErrFn(err) } } func (e *client) OnStorageError(err error) { if e.onStorageErrFn != nil { e.onStorageErrFn(err) } } func (e *client) OnServiceError(err error) { if e.onServiceErrFn != nil { e.onServiceErrFn(err) } }