ui: basic map scroller

This commit is contained in:
Ilia Denisov
2026-03-06 23:29:06 +02:00
committed by GitHub
parent 29d188969b
commit 1de621c743
68 changed files with 9861 additions and 118 deletions
+250
View File
@@ -0,0 +1,250 @@
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
}