Files
galaxy-game/client/world/world.go
T
2026-03-07 19:28:22 +02:00

421 lines
11 KiB
Go

package world
import (
"errors"
"fmt"
)
var (
errBadCoordinate = errors.New("invalid coordinates")
errBadRadius = errors.New("invalid radius")
errNoSuchObject = errors.New("no such object")
errIDExhausted = errors.New("primitive id exhausted")
)
type indexState struct {
initialized bool
viewportW int
viewportH int
zoomFp int
}
// 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[PrimitiveID]MapItem
styles *StyleTable
// PrimitiveID allocator state.
nextID PrimitiveID
freeIDs []PrimitiveID
// Index dirty flag for add/remove updates.
indexDirty bool
index indexState
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[PrimitiveID]MapItem),
styles: NewStyleTable(),
nextID: 1, // 0 is reserved as "invalid"
}
}
// allocID allocates a new PrimitiveID using a free-list (reusable IDs) and a monotonic counter.
// It returns an error if the ID space is exhausted.
func (g *World) allocID() (PrimitiveID, error) {
if n := len(g.freeIDs); n > 0 {
id := g.freeIDs[n-1]
g.freeIDs = g.freeIDs[:n-1]
return id, nil
}
if g.nextID == PrimitiveID(^uint32(0)) {
return 0, errIDExhausted
}
id := g.nextID
g.nextID++
return id, nil
}
// freeID returns an id back to the pool. It is safe to call only after the object is removed.
func (g *World) freeID(id PrimitiveID) {
if id == 0 {
return
}
g.freeIDs = append(g.freeIDs, id)
}
// 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
}
// AddStyleLine creates a new line style derived from the default line style.
func (g *World) AddStyleLine(override StyleOverride) StyleID {
return g.styles.AddDerived(StyleIDDefaultLine, override)
}
// AddStyleCircle creates a new circle style derived from the default circle style.
func (g *World) AddStyleCircle(override StyleOverride) StyleID {
return g.styles.AddDerived(StyleIDDefaultCircle, override)
}
// AddStylePoint creates a new point style derived from the default point style.
func (g *World) AddStylePoint(override StyleOverride) StyleID {
return g.styles.AddDerived(StyleIDDefaultPoint, override)
}
// Remove deletes an object by id. It returns errNoSuchObject if the id is unknown.
// It marks the spatial index dirty and triggers an autonomous rebuild if possible.
func (g *World) Remove(id PrimitiveID) error {
if _, ok := g.objects[id]; !ok {
return errNoSuchObject
}
delete(g.objects, id)
g.freeID(id)
g.indexDirty = true
g.rebuildIndexFromLastState()
return nil
}
// Reindex forces rebuilding the spatial index (grid) if the renderer has enough last-state
// information to choose a grid cell size. If not enough info exists yet, it keeps indexDirty=true.
func (g *World) Reindex() {
g.indexDirty = true
g.rebuildIndexFromLastState()
}
// rebuildIndexFromLastState rebuilds the index using last known viewport sizes and zoomFp
// from renderer state. If that state is not initialized, it does nothing.
func (g *World) rebuildIndexFromLastState() {
if !g.indexDirty {
return
}
if !g.index.initialized {
return
}
if g.index.viewportW <= 0 || g.index.viewportH <= 0 || g.index.zoomFp <= 0 {
return
}
g.indexOnViewportChangeZoomFp(g.index.viewportW, g.index.viewportH, g.index.zoomFp)
g.indexDirty = false
}
// 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, opts ...PointOpt) (PrimitiveID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return 0, errBadCoordinate
}
o := defaultPointOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolvePointStyleID(o)
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Point{
Id: id,
X: xf,
Y: yf,
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
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, opts ...CircleOpt) (PrimitiveID, error) {
xf := fixedPoint(x)
yf := fixedPoint(y)
if ok := g.checkCoordinate(xf, yf); !ok {
return 0, errBadCoordinate
}
if r < 0 {
return 0, errBadRadius
}
o := defaultCircleOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolveCircleStyleID(o)
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Circle{
Id: id,
X: xf,
Y: yf,
Radius: fixedPoint(r),
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
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, opts ...LineOpt) (PrimitiveID, error) {
x1f := fixedPoint(x1)
y1f := fixedPoint(y1)
x2f := fixedPoint(x2)
y2f := fixedPoint(y2)
if ok := g.checkCoordinate(x1f, y1f); !ok {
return 0, errBadCoordinate
}
if ok := g.checkCoordinate(x2f, y2f); !ok {
return 0, errBadCoordinate
}
o := defaultLineOptions()
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
styleID := g.resolveLineStyleID(o)
id, err := g.allocID()
if err != nil {
return 0, err
}
g.objects[id] = Line{
Id: id,
X1: x1f,
Y1: y1f,
X2: x2f,
Y2: y2f,
Priority: o.Priority,
StyleID: styleID,
HitSlopPx: o.HitSlopPx,
}
g.indexDirty = true
g.rebuildIndexFromLastState()
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 (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) {
if cellSize <= 0 {
panic("resetGrid: invalid cell size")
}
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 is called when UI window sizes are changed.
// cameraZoom is float64, converted inside world to fixed-point.
func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) {
zoomFp := mustCameraZoomToWorldFixed(cameraZoom) // must-version is ok here, matches your existing code
// Remember params for autonomous reindex after Add/Remove.
g.index.initialized = true
g.index.viewportW = viewportWidthPx
g.index.viewportH = viewportHeightPx
g.index.zoomFp = zoomFp
g.indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx, zoomFp)
g.indexDirty = false
}
// indexOnViewportChangeZoomFp performs indexing logic using fixed-point zoom.
func (g *World) indexOnViewportChangeZoomFp(viewportWidthPx, viewportHeightPx int, zoomFp int) {
worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, zoomFp)
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)
}
}