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

222 lines
5.5 KiB
Go

package world
import (
"sort"
)
// PrimitiveKind identifies primitive types in hit-test results.
type PrimitiveKind uint8
const (
KindLine PrimitiveKind = iota
KindCircle
KindPoint
)
// Hit describes one primitive that matches a hit-test query.
type Hit struct {
ID PrimitiveID
Kind PrimitiveKind
Priority int
StyleID StyleID
// DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort).
// Used for tie-breaking (smaller is better).
DistanceSq u128
// Primitive world coordinates:
// - Point: X,Y set
// - Circle: X,Y,Radius set
// - Line: X1,Y1,X2,Y2 set
X, Y int
Radius int
X1, Y1 int
X2, Y2 int
}
// Default hit slop (in pixels) per primitive type.
const (
DefaultHitSlopLinePx = 6
DefaultHitSlopCirclePx = 6
DefaultHitSlopPointPx = 8
// If a circle's screen radius is below this threshold, treat it as point-like for hit testing.
CirclePointLikeMinRadiusPx = 3
)
// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop.
// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations).
//
// If cap(out) is too small, it returns only the best hits by ranking:
//
// Priority desc, Distance asc, Kind asc, ID asc.
//
// Notes:
// - cursorXPx/cursorYPx are relative to viewport top-left.
// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll).
func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if w.grid == nil || w.rows == 0 || w.cols == 0 {
return nil, errGridNotBuilt
}
zoomFp, err := params.CameraZoomFp()
if err != nil {
return nil, err
}
allowWrap := true
if params.Options != nil && params.Options.DisableWrapScroll {
allowWrap = false
}
// Use clamped camera in no-wrap mode for consistency.
camX := params.CameraXWorldFp
camY := params.CameraYWorldFp
if !allowWrap {
camX, camY = ClampCameraNoWrapViewport(
camX, camY,
params.ViewportWidthPx, params.ViewportHeightPx,
zoomFp,
w.W, w.H,
)
}
// Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera).
worldPerPx := PixelSpanToWorldFixed(1, zoomFp)
offXPx := cursorXPx - params.ViewportWidthPx/2
offYPx := cursorYPx - params.ViewportHeightPx/2
cursorX := camX + offXPx*worldPerPx
cursorY := camY + offYPx*worldPerPx
if allowWrap {
cursorX = wrap(cursorX, w.W)
cursorY = wrap(cursorY, w.H)
} else {
// Clamp cursor into world bounds to avoid weird negative coords in margins.
cursorX = clamp(cursorX, 0, w.W-1)
cursorY = clamp(cursorY, 0, w.H-1)
}
// Compute a conservative search bbox around cursor using max possible slop (px->world).
// We use the maximum of default slops; per-object overrides are handled later.
maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx))
maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp)
minX := cursorX - maxSlopWorld
maxX := cursorX + maxSlopWorld + 1
minY := cursorY - maxSlopWorld
maxY := cursorY + maxSlopWorld + 1
var rects []Rect
if allowWrap {
rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
} else {
// Clamp to world.
minX = clamp(minX, 0, w.W)
maxX = clamp(maxX, 0, w.W)
minY = clamp(minY, 0, w.H)
maxY = clamp(maxY, 0, w.H)
if maxX <= minX || maxY <= minY {
return out[:0], nil
}
rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}}
}
// Gather candidates from grid cells, dedupe by ID.
cand := make(map[PrimitiveID]MapItem, 32)
for _, r := range rects {
colStart := w.worldToCellX(r.minX)
colEnd := w.worldToCellX(r.maxX - 1)
rowStart := w.worldToCellY(r.minY)
rowEnd := w.worldToCellY(r.maxY - 1)
for row := rowStart; row <= rowEnd; row++ {
for col := colStart; col <= colEnd; col++ {
cell := w.grid[row][col]
for _, it := range cell {
cand[it.ID()] = it
}
}
}
}
// Use caller buffer as backing store; keep only best cap(out) hits.
out = out[:0]
limit := cap(out)
for _, it := range cand {
h, ok := w.hitOne(it, cursorX, cursorY, zoomFp, allowWrap)
if !ok {
continue
}
if limit == 0 {
// Caller provided zero-cap buffer; cannot store anything.
continue
}
if len(out) < limit {
out = append(out, h)
continue
}
// Replace the worst hit if the new one is better.
worstIdx := 0
for i := 1; i < len(out); i++ {
if hitLess(out[worstIdx], out[i]) {
worstIdx = i // out[i] is worse than out[worstIdx]
}
}
if hitLess(h, out[worstIdx]) {
out[worstIdx] = h
}
}
// Sort final hits by best-first order.
sort.Slice(out, func(i, j int) bool {
return hitLess(out[i], out[j])
})
return out, nil
}
// hitLess orders hits by:
// Priority desc, DistanceSq asc, Kind asc, ID asc.
func hitLess(a, b Hit) bool {
if a.Priority != b.Priority {
return a.Priority > b.Priority
}
if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 {
return c < 0
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.ID < b.ID
}
func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) {
switch v := it.(type) {
case Point:
return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Circle:
style, ok := w.styles.Get(v.StyleID)
if !ok {
// Unknown style should not happen; treat as no-hit rather than panic.
return Hit{}, false
}
return hitCircle(v, style, cx, cy, zoomFp, allowWrap, w.W, w.H)
case Line:
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
default:
panic("HitTest: unknown map item type")
}
}