ui: basic map scroller
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
errBadCoordinate = errors.New("invalid coordinates")
|
||||
errBadRadius = errors.New("invalid radius")
|
||||
)
|
||||
|
||||
// World stores torus world dimensions, all registered objects,
|
||||
// and the grid-based spatial index built for the current viewport settings.
|
||||
type World struct {
|
||||
W, H int // Fixed-point world size.
|
||||
grid [][][]MapItem
|
||||
cellSize int
|
||||
rows, cols int
|
||||
objects map[uuid.UUID]MapItem
|
||||
renderState rendererIncrementalState
|
||||
}
|
||||
|
||||
// NewWorld constructs a new world with the given real dimensions.
|
||||
// The dimensions are converted to fixed-point and must be positive.
|
||||
func NewWorld(width, height int) *World {
|
||||
if width <= 0 || height <= 0 {
|
||||
panic("invalid width or height")
|
||||
}
|
||||
return &World{
|
||||
W: width * SCALE,
|
||||
H: height * SCALE,
|
||||
cellSize: 1,
|
||||
objects: make(map[uuid.UUID]MapItem),
|
||||
}
|
||||
}
|
||||
|
||||
// checkCoordinate reports whether the fixed-point coordinate (xf, yf)
|
||||
// lies inside the world bounds: [0, W) x [0, H).
|
||||
func (g *World) checkCoordinate(xf, yf int) bool {
|
||||
if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AddPoint validates and stores a point primitive in the world.
|
||||
// The input coordinates are given in real world units and are converted
|
||||
// to fixed-point before validation.
|
||||
func (g *World) AddPoint(x, y float64) (uuid.UUID, error) {
|
||||
xf := fixedPoint(x)
|
||||
yf := fixedPoint(y)
|
||||
|
||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Point{Id: id, X: xf, Y: yf}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AddCircle validates and stores a circle primitive in the world.
|
||||
// The center and radius are given in real world units and are converted
|
||||
// to fixed-point before validation. A zero radius is allowed.
|
||||
func (g *World) AddCircle(x, y, r float64) (uuid.UUID, error) {
|
||||
xf := fixedPoint(x)
|
||||
yf := fixedPoint(y)
|
||||
rf := fixedPoint(r)
|
||||
|
||||
if ok := g.checkCoordinate(xf, yf); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
}
|
||||
if rf < 0 {
|
||||
return uuid.Nil, errBadRadius
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Circle{Id: id, X: xf, Y: yf, Radius: rf}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AddLine validates and stores a line primitive in the world.
|
||||
// The endpoints are given in real world units and are converted
|
||||
// to fixed-point before validation.
|
||||
func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) {
|
||||
x1f := fixedPoint(x1)
|
||||
y1f := fixedPoint(y1)
|
||||
x2f := fixedPoint(x2)
|
||||
y2f := fixedPoint(y2)
|
||||
|
||||
if ok := g.checkCoordinate(x1f, y1f); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
}
|
||||
if ok := g.checkCoordinate(x2f, y2f); !ok {
|
||||
return uuid.Nil, errBadCoordinate
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
g.objects[id] = Line{Id: id, X1: x1f, Y1: y1f, X2: x2f, Y2: y2f}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
||||
func (g *World) worldToCellX(x int) int {
|
||||
return worldToCell(x, g.W, g.cols, g.cellSize)
|
||||
}
|
||||
|
||||
// worldToCellY converts a fixed-point Y coordinate to a grid row index.
|
||||
func (g *World) worldToCellY(y int) int {
|
||||
return worldToCell(y, g.H, g.rows, g.cellSize)
|
||||
}
|
||||
|
||||
// resetGrid recreates the spatial grid with the given cell size
|
||||
// and clears all previous indexing state.
|
||||
func (g *World) resetGrid(cellSize int) {
|
||||
g.cellSize = cellSize
|
||||
g.cols = ceilDiv(g.W, g.cellSize)
|
||||
g.rows = ceilDiv(g.H, g.cellSize)
|
||||
|
||||
g.grid = make([][][]MapItem, g.rows)
|
||||
for row := range g.grid {
|
||||
g.grid[row] = make([][]MapItem, g.cols)
|
||||
}
|
||||
}
|
||||
|
||||
// indexObject inserts a single object into all grid cells touched by its
|
||||
// indexing representation. Points are inserted into one cell, while circles
|
||||
// and lines are inserted by their torus-aware bbox coverage.
|
||||
func (g *World) indexObject(o MapItem) {
|
||||
switch mapItem := o.(type) {
|
||||
case Point:
|
||||
col := g.worldToCellX(mapItem.X)
|
||||
row := g.worldToCellY(mapItem.Y)
|
||||
g.grid[row][col] = append(g.grid[row][col], mapItem)
|
||||
|
||||
case Line:
|
||||
x1 := mapItem.X1
|
||||
y1 := mapItem.Y1
|
||||
x2 := mapItem.X2
|
||||
y2 := mapItem.Y2
|
||||
|
||||
x1, x2 = shortestWrappedDelta(x1, x2, g.W)
|
||||
y1, y2 = shortestWrappedDelta(y1, y2, g.H)
|
||||
|
||||
minX := min(x1, x2)
|
||||
maxX := max(x1, x2)
|
||||
minY := min(y1, y2)
|
||||
maxY := max(y1, y2)
|
||||
|
||||
if minX == maxX {
|
||||
maxX++
|
||||
}
|
||||
if minY == maxY {
|
||||
maxY++
|
||||
}
|
||||
|
||||
g.indexBBox(mapItem, minX, maxX, minY, maxY)
|
||||
|
||||
case Circle:
|
||||
g.indexBBox(mapItem, mapItem.MinX(), mapItem.MaxX(), mapItem.MinY(), mapItem.MaxY())
|
||||
|
||||
default:
|
||||
panic(fmt.Sprintf("indexing: unknown element %T", mapItem))
|
||||
}
|
||||
}
|
||||
|
||||
// indexBBox indexes an object by a half-open fixed-point bbox that may cross
|
||||
// torus boundaries. The bbox is split into wrapped in-world rectangles first,
|
||||
// then all covered grid cells are populated.
|
||||
func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) {
|
||||
rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY)
|
||||
for _, r := range rects {
|
||||
colStart := g.worldToCellX(r.minX)
|
||||
colEnd := g.worldToCellX(r.maxX - 1)
|
||||
|
||||
rowStart := g.worldToCellY(r.minY)
|
||||
rowEnd := g.worldToCellY(r.maxY - 1)
|
||||
|
||||
for col := colStart; col <= colEnd; col++ {
|
||||
for row := rowStart; row <= rowEnd; row++ {
|
||||
g.grid[row][col] = append(g.grid[row][col], o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IndexOnViewportChange rebuilds the grid for a new viewport size and zoom.
|
||||
// The zoom is provided by the UI as a real multiplier and is converted
|
||||
// to fixed-point inside the function.
|
||||
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
|
||||
cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom)
|
||||
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp)
|
||||
|
||||
cellsAcrossMin := 8
|
||||
visibleMin := min(worldWidth, worldHeight)
|
||||
|
||||
cellSize := visibleMin / cellsAcrossMin
|
||||
cellSize = clamp(cellSize, cellSizeMin, cellSizeMax)
|
||||
|
||||
g.resetGrid(cellSize)
|
||||
|
||||
for _, o := range g.objects {
|
||||
g.indexObject(o)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user