ui: basic map scroller
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user