Files
galaxy-game/client/world/hit_primitives.go
T
2026-03-08 15:31:17 +02:00

248 lines
6.3 KiB
Go

package world
import (
"image/color"
"math/bits"
)
func effectiveHitSlopPx(hitSlopPx int, def int) int {
if hitSlopPx > 0 {
return hitSlopPx
}
return def
}
func alphaNonZero(c color.Color) bool {
if c == nil {
return false
}
_, _, _, a := c.RGBA()
return a != 0
}
func hitPoint(p Point, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(p.HitSlopPx, DefaultHitSlopPointPx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(p.X, cx, worldW)
dy = shortestTorusDelta(p.Y, cy, worldH)
} else {
dx = int64(cx - p.X)
dy = int64(cy - p.Y)
}
// Point is treated as a small disc: dist <= slop.
ds := distSqU128(dx, dy)
rs := sqU128Int64(int64(slopW))
if u128Cmp(ds, rs) <= 0 {
return Hit{
ID: p.Id,
Kind: KindPoint,
Priority: p.Priority,
StyleID: p.StyleID,
DistanceSq: ds,
X: p.X,
Y: p.Y,
}, true
}
return Hit{}, false
}
func hitCircle(c Circle, effRadiusFp int, style Style, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(c.HitSlopPx, DefaultHitSlopCirclePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
fillVisible := alphaNonZero(style.FillColor)
// Determine if circle is point-like at current zoom.
// IMPORTANT: point-like disc behavior applies only for filled circles.
rPx := worldSpanFixedToCanvasPx(effRadiusFp, zoomFp)
pointLike := fillVisible && rPx < CirclePointLikeMinRadiusPx
var dx, dy int64
if allowWrap {
dx = shortestTorusDelta(c.X, cx, worldW)
dy = shortestTorusDelta(c.Y, cy, worldH)
} else {
dx = int64(cx - c.X)
dy = int64(cy - c.Y)
}
ds := distSqU128(dx, dy)
// Filled + point-like: treat as a disc with minimum visible radius + slop.
if pointLike {
// Treat as a disc with minimum visible radius in px.
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
effR := minRW
if effRadiusFp > effR {
effR = effRadiusFp
}
r := effR + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
// Filled circle: hit-test by disc (surface).
if fillVisible {
r := effRadiusFp + slopW
if u128Cmp(ds, sqU128Int64(int64(r))) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
// Stroke-only circle: ring hit, but NEVER at exact center.
// For very small circles, expand the effective radius to a minimum visible size
// so that ring selection remains practical, while still excluding center.
effR := effRadiusFp
if rPx < CirclePointLikeMinRadiusPx {
minRW := PixelSpanToWorldFixed(CirclePointLikeMinRadiusPx, zoomFp)
if minRW > effR {
effR = minRW
}
}
low := effR - slopW
// IMPORTANT: center must not hit for stroke-only circles.
if low < 1 {
low = 1
}
high := effR + slopW
lowSq := sqU128Int64(int64(low))
highSq := sqU128Int64(int64(high))
if u128Cmp(ds, lowSq) >= 0 && u128Cmp(ds, highSq) <= 0 {
return Hit{
ID: c.Id,
Kind: KindCircle,
Priority: c.Priority,
StyleID: c.StyleID,
DistanceSq: ds,
X: c.X,
Y: c.Y,
Radius: effRadiusFp,
}, true
}
return Hit{}, false
}
func hitLine(l Line, cx, cy int, zoomFp int, allowWrap bool, worldW, worldH int) (Hit, bool) {
slopPx := effectiveHitSlopPx(l.HitSlopPx, DefaultHitSlopLinePx)
slopW := PixelSpanToWorldFixed(slopPx, zoomFp)
// For wrap: compare against torus-shortest representation (same as rendering).
// We test all segments produced by torusShortestLineSegments and take the best (min distance).
segs := []lineSeg{{x1: l.X1, y1: l.Y1, x2: l.X2, y2: l.Y2}}
if allowWrap {
segs = torusShortestLineSegments(l, worldW, worldH)
}
best := Hit{}
found := false
for _, s := range segs {
ds := distSqPointToSegmentU128(int64(cx), int64(cy), int64(s.x1), int64(s.y1), int64(s.x2), int64(s.y2))
// Check ds <= slopW^2
if u128Cmp(ds, sqU128Int64(int64(slopW))) <= 0 {
h := Hit{
ID: l.Id,
Kind: KindLine,
Priority: l.Priority,
StyleID: l.StyleID,
DistanceSq: ds,
X1: l.X1,
Y1: l.Y1,
X2: l.X2,
Y2: l.Y2,
}
if !found || hitLess(h, best) {
best = h
found = true
}
}
}
return best, found
}
// distSqPointToSegmentU128 computes squared distance from point P to segment AB using safe 128-bit comparisons.
func distSqPointToSegmentU128(px, py, ax, ay, bx, by int64) u128 {
abx := bx - ax
aby := by - ay
apx := px - ax
apy := py - ay
// Degenerate segment => distance to point A.
if abx == 0 && aby == 0 {
return distSqU128(apx, apy)
}
dot := apx*abx + apy*aby
if dot <= 0 {
return distSqU128(apx, apy)
}
abLen2 := abx*abx + aby*aby
if dot >= abLen2 {
bpx := px - bx
bpy := py - by
return distSqU128(bpx, bpy)
}
// Perpendicular distance: dist^2 = cross^2 / |AB|^2, compare in 128 if needed by callers.
// Here we actually return an exact rational? We return floor(cross^2 / abLen2) in integer domain
// would lose precision. Instead, for HitTest we only compare dist^2 <= slop^2, but we also use
// dist^2 for tie-breaking. We'll compute an approximate using integer division in 128/64.
//
// cross = AP x AB
cross := apx*aby - apy*abx
// cross^2 fits in u128, abLen2 fits in int64.
c2 := sqU128Int64(cross)
return u128DivByU64(c2, uint64(abLen2))
}
// u128DivByU64 returns floor(a / d) where d>0, producing u128 result.
// Here we only need it for tie-breaking (monotonic).
func u128DivByU64(a u128, d uint64) u128 {
if d == 0 {
panic("u128DivByU64: divide by zero")
}
// Simple long division for 128/64 -> 128 quotient (but high part will be small here).
// We compute using two-step: divide high then combine.
qHi := a.hi / d
rHi := a.hi % d
// Combine remainder with low as 128-bit number (rHi<<64 + lo) divided by d.
// Use bits.Div64 for (hi, lo)/d.
qLo, _ := bits.Div64(rHi, a.lo, d)
return u128{hi: qHi, lo: qLo}
}