251 lines
6.1 KiB
Go
251 lines
6.1 KiB
Go
package world
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
)
|
|
|
|
var (
|
|
errInvalidCameraZoom = errors.New("invalid camera zoom")
|
|
)
|
|
|
|
const (
|
|
// SCALE is the fixed-point multiplier used across the package.
|
|
// A real value of 1.0 is represented as SCALE.
|
|
SCALE = 1000
|
|
|
|
// MIN_ZOOM and MAX_ZOOM define the supported zoom range in fixed-point form.
|
|
// They are reserved for future validation/clamping logic.
|
|
MIN_ZOOM = int(SCALE / 4) // 0.25x
|
|
MAX_ZOOM = int(SCALE * 32) // 32x
|
|
|
|
// cellSizeMin and cellSizeMax bound the automatically selected grid cell size.
|
|
cellSizeMin = 16 * SCALE
|
|
cellSizeMax = 512 * SCALE
|
|
)
|
|
|
|
// Rect is a half-open rectangle in fixed-point world coordinates:
|
|
// [minX, maxX) x [minY, maxY).
|
|
type Rect struct {
|
|
minX, maxX int
|
|
minY, maxY int
|
|
}
|
|
|
|
// wrap maps value into the half-open interval [0, size).
|
|
// It supports negative input values and is used for torus coordinates.
|
|
func wrap(value, size int) int {
|
|
r := value % size
|
|
if r < 0 {
|
|
r += size
|
|
}
|
|
return r
|
|
}
|
|
|
|
// clamp limits value to the closed interval [minValue, maxValue].
|
|
func clamp(value, minValue, maxValue int) int {
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ceilDiv returns ceil(a / b) for positive integers.
|
|
func ceilDiv(a, b int) int {
|
|
return (a + b - 1) / b
|
|
}
|
|
|
|
// floorDiv returns floor(a / b) for b > 0 and supports negative a.
|
|
func floorDiv(a, b int) int {
|
|
if b <= 0 {
|
|
panic("floorDiv: non-positive divisor")
|
|
}
|
|
|
|
q := a / b
|
|
r := a % b
|
|
if r != 0 && a < 0 {
|
|
q--
|
|
}
|
|
return q
|
|
}
|
|
|
|
// fixedPoint converts a real value into the package fixed-point representation
|
|
// using nearest-integer rounding.
|
|
func fixedPoint(v float64) int {
|
|
return int(math.Round(v * SCALE))
|
|
}
|
|
|
|
// abs returns the absolute value of v.
|
|
func abs(v int) int {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
// viewportPxToWorldFixed converts a viewport size in pixels into the visible
|
|
// world size in fixed-point coordinates for the given fixed-point zoom.
|
|
func viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp int) (int, int) {
|
|
return PixelSpanToWorldFixed(viewportWidthPx, cameraZoomFp),
|
|
PixelSpanToWorldFixed(viewportHeightPx, cameraZoomFp)
|
|
}
|
|
|
|
// worldToCell maps a world coordinate to a grid cell index.
|
|
// The input coordinate is wrapped on the torus before the cell is computed.
|
|
// The function panics when the grid configuration is invalid.
|
|
func worldToCell(value, worldSize, cells, cellSize int) int {
|
|
if cells <= 0 || cellSize <= 0 {
|
|
panic(fmt.Sprintf("worldToCell: cells=%d cellSize=%d", cells, cellSize))
|
|
}
|
|
|
|
wrappedValue := wrap(value, worldSize)
|
|
cell := wrappedValue / cellSize
|
|
if cell >= cells {
|
|
cell = cells - 1
|
|
}
|
|
return cell
|
|
}
|
|
|
|
// splitByWrap splits a half-open rectangle by torus wrap into 1..4 rectangles
|
|
// fully contained inside [0, W) x [0, H).
|
|
func splitByWrap(W, H, minX, maxX, minY, maxY int) []Rect {
|
|
width := maxX - minX
|
|
height := maxY - minY
|
|
|
|
if width <= 0 || height <= 0 {
|
|
return nil
|
|
}
|
|
|
|
if width >= W {
|
|
minX = 0
|
|
maxX = W
|
|
}
|
|
if height >= H {
|
|
minY = 0
|
|
maxY = H
|
|
}
|
|
|
|
type xPart struct {
|
|
minX, maxX int
|
|
}
|
|
|
|
xParts := make([]xPart, 0, 2)
|
|
|
|
if minX >= 0 && maxX <= W {
|
|
xParts = append(xParts, xPart{minX: minX, maxX: maxX})
|
|
} else {
|
|
wrappedMinX := wrap(minX, W)
|
|
wrappedMaxX := wrap(maxX, W)
|
|
|
|
if wrappedMinX < wrappedMaxX {
|
|
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: wrappedMaxX})
|
|
} else {
|
|
xParts = append(xParts, xPart{minX: wrappedMinX, maxX: W})
|
|
if wrappedMaxX > 0 {
|
|
xParts = append(xParts, xPart{minX: 0, maxX: wrappedMaxX})
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]Rect, 0, 4)
|
|
|
|
for _, xp := range xParts {
|
|
if minY >= 0 && maxY <= H {
|
|
result = append(result, Rect{
|
|
minX: xp.minX, maxX: xp.maxX,
|
|
minY: minY, maxY: maxY,
|
|
})
|
|
continue
|
|
}
|
|
|
|
wrappedMinY := wrap(minY, H)
|
|
wrappedMaxY := wrap(maxY, H)
|
|
|
|
if wrappedMinY < wrappedMaxY {
|
|
result = append(result, Rect{
|
|
minX: xp.minX, maxX: xp.maxX,
|
|
minY: wrappedMinY, maxY: wrappedMaxY,
|
|
})
|
|
} else {
|
|
result = append(result, Rect{
|
|
minX: xp.minX, maxX: xp.maxX,
|
|
minY: wrappedMinY, maxY: H,
|
|
})
|
|
if wrappedMaxY > 0 {
|
|
result = append(result, Rect{
|
|
minX: xp.minX, maxX: xp.maxX,
|
|
minY: 0, maxY: wrappedMaxY,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// PixelSpanToWorldFixed converts a span in pixels into a span in fixed-point
|
|
// world coordinates for the given fixed-point zoom.
|
|
func PixelSpanToWorldFixed(spanPx int, zoomFp int) int {
|
|
return (spanPx * SCALE * SCALE) / zoomFp
|
|
}
|
|
|
|
// shortestWrappedDelta returns a canonical torus representation of the pair
|
|
// (from, to) along a single axis of length size.
|
|
//
|
|
// The resulting delta (b - a) is normalized into the half-open interval
|
|
// [-size/2, size/2). This makes the tie-case deterministic:
|
|
// when the points are exactly half a world apart, wrap is always applied in
|
|
// the direction that produces the negative delta.
|
|
func shortestWrappedDelta(from, to, size int) (a int, b int) {
|
|
a, b = from, to
|
|
|
|
delta := to - from
|
|
half := size / 2
|
|
|
|
if delta >= half {
|
|
a += size
|
|
return
|
|
}
|
|
if delta < -half {
|
|
b += size
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// CameraZoomToWorldFixed converts a UI-facing zoom multiplier into the package
|
|
// fixed-point representation used by world-space calculations.
|
|
//
|
|
// The input zoom is expected to be a finite positive real value where 1.0 means
|
|
// the neutral zoom level. The result is rounded to the nearest fixed-point value.
|
|
//
|
|
// An error is returned when the input is invalid or when rounding would produce
|
|
// a non-positive fixed-point zoom.
|
|
func CameraZoomToWorldFixed(cameraZoom float64) (int, error) {
|
|
if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) {
|
|
return 0, errInvalidCameraZoom
|
|
}
|
|
|
|
zoomFp := int(math.Round(cameraZoom * SCALE))
|
|
if zoomFp <= 0 {
|
|
return 0, errInvalidCameraZoom
|
|
}
|
|
|
|
return zoomFp, nil
|
|
}
|
|
|
|
// mustCameraZoomToWorldFixed is the panic-on-error variant of
|
|
// cameraZoomToWorldFixed. It is intended for internal code paths where invalid
|
|
// zoom is considered a programmer or integration error and must fail fast.
|
|
func mustCameraZoomToWorldFixed(cameraZoom float64) int {
|
|
zoomFp, err := CameraZoomToWorldFixed(cameraZoom)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return zoomFp
|
|
}
|