client io architecture

This commit is contained in:
Ilia Denisov
2026-03-12 18:45:46 +02:00
committed by GitHub
parent 2dafa69b93
commit 079b9facb0
36 changed files with 1810 additions and 460 deletions
+190 -11
View File
@@ -1,24 +1,203 @@
package client
import (
"image"
"sync"
"galaxy/client/world"
"galaxy/connector"
mc "galaxy/model/client"
"galaxy/model/report"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
type ui struct {
type client struct {
conn connector.UIConnector
app fyne.App
window fyne.Window
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 NewUI() *ui {
c := &ui{}
c.app = app.New()
c.window = c.app.NewWindow("Galaxy Plus")
client := NewClient()
client.BuildUI(c.window)
return c
func NewClient(conn connector.UIConnector, app fyne.App, settings mc.Settings) (mc.Client, error) {
e := &client{
conn: conn,
app: app,
window: app.NewWindow("Galaxy Plus"),
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)
},
)
return e, nil
}
func (c *ui) Run() {
c.window.ShowAndRun()
func (e *client) loadReport(t uint) {
e.conn.FetchReport("GAME_ID", t, func(r report.Report, err error) {
if err != nil {
e.handlerError(err)
} else {
e.setReport(r)
}
})
}
func (e *client) setReport(r report.Report) {
w := world.NewWorld(int(r.Width), int(r.Height))
for i := range r.LocalPlanet {
p := r.LocalPlanet[i]
w.AddCircle(p.X.F(), p.Y.F(), p.Size.F())
}
for i := range r.UnidentifiedPlanet {
p := r.UnidentifiedPlanet[i]
w.AddPoint(p.X.F(), p.Y.F())
}
e.loadWorld(w)
}
func (e *client) handlerError(err error) {
}
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) {
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)
// if e.world == nil {
// w.SetCircleRadiusScaleFp(world.SCALE / 4)
// e.world = w
// 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()
}
func (e *client) Run() error {
e.BuildUI(e.window)
e.window.ShowAndRun()
e.RequestRefresh()
return nil
}
func (e *client) Shutdown() {
e.window.Close()
}
func (e *client) OnConnection(bool) {}
+30 -3
View File
@@ -1,8 +1,35 @@
package main
import "galaxy/client"
import (
"errors"
"fmt"
"galaxy/client"
mc "galaxy/model/client"
"os"
"fyne.io/fyne/v2/app"
)
func main() {
c := client.NewUI()
c.Run()
var err error
defer func() {
if err == nil {
if r := recover(); r != nil {
err = errors.Join(err, fmt.Errorf("app panics: %v", r))
}
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}()
app := app.New()
settings := mc.Settings{
StoragePath: ".",
}
c, err := client.NewClient(nil, app, settings)
if err != nil {
return
}
err = c.Run()
}
-9
View File
@@ -1,9 +0,0 @@
package client
import (
"galaxy/model/report"
)
type Connector interface {
Turn(uint, func(report.Report, error)) error
}
-9
View File
@@ -1,9 +0,0 @@
package http
type httpConnector struct {
}
func NewHttpConnector() *httpConnector {
h := &httpConnector{}
return h
}
-289
View File
@@ -1,289 +0,0 @@
package client
import (
"fmt"
"image"
"math"
"sync"
"galaxy/client/world"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
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()
}
+1
View File
@@ -0,0 +1 @@
package model
+42
View File
@@ -0,0 +1,42 @@
package client
import (
model "galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
)
// Storage manages Client's data local storing and retrieval.
// It performs all I/O operations asynchronously to avoid UI main thread blocking.
type Storage interface {
// StateExists check if previously saved [model.State] exists on filesystem and returns result.
// I/O error may occur, it that case returned result will be false and error is non-nil.
StateExists() (bool, error)
// LoadState loads Client's [model.State] from filesystem data asynchronously.
// Passed callback func will accept non-nil error in case of I/O or decoding errors occuried,
// otherwise callback func accepts loaded [model.State].
LoadState(func(model.State, error))
// SaveState stores Client's state at the filesystem asynchronously.
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
SaveState(model.State, func(error))
// LoadReport loads a [report.Report] for a given [model.GameID] and turn number from filesystem asynchronously.
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
// otherwise callback func accepts loaded [report.Report].
LoadReport(model.GameID, uint, func(report.Report, error))
// PutReport stores given [report.Report] for a given [model.GameID] and turn number at the filesystem asynchronously.
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
PutReport(model.GameID, uint, report.Report, func(error))
// LoadOrder loads a [order.Order] for a given [model.GameID] and turn number from filesystem asynchronously.
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
// otherwise callback func accepts loaded [order.Order].
LoadOrder(model.GameID, uint, func(order.Order, error))
// PutOrder stores given [order.Order] for a given [model.GameID] and turn number at the filesystem asynchronously.
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
PutOrder(model.GameID, uint, order.Order, func(error))
}
+48
View File
@@ -0,0 +1,48 @@
package storage
import (
"errors"
"fmt"
"galaxy/util"
"path/filepath"
)
const (
// Name of the file under the storage's root where [model.State] is stored.
stateFileName = "state.dat"
// Suffix of a Game's file inder the storage's root where [model.GameData] is stored.
gameDataFileSuffix = ".dat"
)
type storage struct {
root string
}
// NewStorage returns implementation of the "galaxy/client.Storage" interface
// or nil with a non-nil error when filesystem storage initialisation failed.
func NewStorage(rootPath string) (*storage, error) {
ok, err := util.Writable(rootPath)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("user does not have write permissions to the storage root")
}
s := &storage{
root: rootPath,
}
return s, nil
}
// StateFilePath returns client's state file path relative to the root,
// file name and extension are pre-defined constant.
func StateFilePath(root string) string {
return filepath.Join(root, stateFileName)
}
// GameDataPath returns game's data file path relative to the root,
// data file name is GameID string representation and extension is a pre-defined constant.
func GameDataFilePath(root string, id fmt.Stringer) string {
return filepath.Join(root, id.String()) + gameDataFileSuffix
}
+141
View File
@@ -1,6 +1,7 @@
package client
import (
"fmt"
"image"
"math"
@@ -193,6 +194,146 @@ func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool)
return x, y, true
}
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()
}
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
// It avoids SubImage aliasing issues: dst becomes independent from src backing memory.
-38
View File
@@ -147,18 +147,11 @@ func (w *World) Theme() StyleTheme {
}
// SetTheme updates the world's current theme.
// Step 1 behavior:
// - Does NOT mutate built-in default styles (1/2/3).
// - Materializes three theme default styles as new StyleIDs in the style table.
// - New objects (and later, theme-relative ones) can use these IDs.
// - Forces next render to full redraw.
func (w *World) SetTheme(theme StyleTheme) {
if theme == nil {
theme = DefaultTheme{}
}
// fmt.Println("current theme:", w.theme.ID())
w.theme = theme
// fmt.Println("new theme:", w.theme.ID())
// Drop derived cache when theme changes to avoid unbounded growth.
for k := range w.derivedCache {
@@ -174,7 +167,6 @@ func (w *World) SetTheme(theme StyleTheme) {
// Full redraw to apply new background and base styles.
w.renderState.Reset()
// w.forceFullRedraw = true
w.ForceFullRedrawNext()
}
@@ -503,36 +495,6 @@ func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e
return id, nil
}
// func (g *World) resolvePointStyleID(o PointOptions) StyleID {
// if o.hasStyleID {
// return o.StyleID
// }
// if o.Override.IsZero() {
// return StyleIDDefaultPoint
// }
// return g.styles.AddDerived(StyleIDDefaultPoint, o.Override)
// }
// func (g *World) resolveCircleStyleID(o CircleOptions) StyleID {
// if o.hasStyleID {
// return o.StyleID
// }
// if o.Override.IsZero() {
// return StyleIDDefaultCircle
// }
// return g.styles.AddDerived(StyleIDDefaultCircle, o.Override)
// }
// func (g *World) resolveLineStyleID(o LineOptions) StyleID {
// if o.hasStyleID {
// return o.StyleID
// }
// if o.Override.IsZero() {
// return StyleIDDefaultLine
// }
// return g.styles.AddDerived(StyleIDDefaultLine, o.Override)
// }
// worldToCellX converts a fixed-point X coordinate to a grid column index.
func (w *World) worldToCellX(x int) int {
return worldToCell(x, w.W, w.cols, w.cellSize)