client io architecture
This commit is contained in:
+190
-11
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
type Connector interface {
|
||||
Turn(uint, func(report.Report, error)) error
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package http
|
||||
|
||||
type httpConnector struct {
|
||||
}
|
||||
|
||||
func NewHttpConnector() *httpConnector {
|
||||
h := &httpConnector{}
|
||||
return h
|
||||
}
|
||||
@@ -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, ¶ms, 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()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package model
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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, ¶ms, 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user