package pncdsl import "math" // pathEps is the tolerance in world units (≈ pixels) used when comparing // polygon vertices for adjacency. Authors typically snap walkbox corners // to integer or half-pixel coordinates, so 0.5 is generous. const pathEps = 0.5 // pathfind returns a sequence of waypoints from start to end that stays // within the union of the given walkboxes. The last entry is always the // effective destination (which may differ from end if end was outside // every walkbox — in that case the closest boundary point is used). // // When boxes is empty, walkbox routing is off and pathfind degenerates // to a straight line ([]Point{end}). The caller (walkCharacter) treats // this as the legacy single-target behaviour. func pathfind(start, end Point, boxes []Polygon) []Point { if len(boxes) == 0 { return []Point{end} } startIdx := containingPolygon(start, boxes) if startIdx < 0 { start, startIdx = nearestPolygonPoint(start, boxes) if startIdx < 0 { return []Point{end} } } endIdx := containingPolygon(end, boxes) if endIdx < 0 { end, endIdx = nearestPolygonPoint(end, boxes) if endIdx < 0 { return []Point{end} } } if startIdx == endIdx { return []Point{end} } adj := buildAdjacency(boxes) seq := bfsPolygons(startIdx, endIdx, adj, len(boxes)) if seq == nil { // Disconnected components — give up and walk straight; the // caller's tickCharacters will at least move toward end. return []Point{end} } out := make([]Point, 0, len(seq)) for i := 0; i < len(seq)-1; i++ { mid, ok := sharedEdgeMidpoint(boxes[seq[i]], boxes[seq[i+1]]) if !ok { continue } out = append(out, mid) } out = append(out, end) return out } func containingPolygon(p Point, boxes []Polygon) int { for i, b := range boxes { if b.Contains(p) { return i } } return -1 } // nearestPolygonPoint projects p onto the boundary of the nearest // walkbox and returns the projection plus its polygon index. func nearestPolygonPoint(p Point, boxes []Polygon) (Point, int) { bestIdx := -1 bestPt := p bestD := math.Inf(1) for i, b := range boxes { pt, d := closestOnPolygonBoundary(p, b) if d < bestD { bestD = d bestPt = pt bestIdx = i } } return bestPt, bestIdx } func closestOnPolygonBoundary(p Point, b Polygon) (Point, float64) { if len(b.Points) < 2 { return p, math.Inf(1) } bestPt := b.Points[0] bestD := p.Dist(bestPt) n := len(b.Points) for i := 0; i < n; i++ { a := b.Points[i] c := b.Points[(i+1)%n] q := closestOnSegment(p, a, c) d := p.Dist(q) if d < bestD { bestD = d bestPt = q } } return bestPt, bestD } func closestOnSegment(p, a, b Point) Point { dx, dy := b.X-a.X, b.Y-a.Y denom := dx*dx + dy*dy if denom == 0 { return a } t := ((p.X-a.X)*dx + (p.Y-a.Y)*dy) / denom if t < 0 { t = 0 } else if t > 1 { t = 1 } return Point{X: a.X + t*dx, Y: a.Y + t*dy} } // buildAdjacency maps each polygon index to the indices of polygons it // shares an edge with. O(n²·k²) where k is avg edge count — fine for // the handful of walkboxes per scene that adventure games use. func buildAdjacency(boxes []Polygon) map[int][]int { adj := make(map[int][]int, len(boxes)) for i := range boxes { for j := i + 1; j < len(boxes); j++ { if _, ok := sharedEdgeMidpoint(boxes[i], boxes[j]); ok { adj[i] = append(adj[i], j) adj[j] = append(adj[j], i) } } } return adj } // sharedEdgeMidpoint returns the midpoint of an edge shared between two // polygons, if any. Two edges are considered shared when their endpoints // match (within pathEps) in either orientation — the typical case where // neighbouring walkboxes are authored with a common boundary. func sharedEdgeMidpoint(a, b Polygon) (Point, bool) { na, nb := len(a.Points), len(b.Points) if na < 2 || nb < 2 { return Point{}, false } for i := 0; i < na; i++ { ai, ai1 := a.Points[i], a.Points[(i+1)%na] for j := 0; j < nb; j++ { bj, bj1 := b.Points[j], b.Points[(j+1)%nb] if (pointsEq(ai, bj1) && pointsEq(ai1, bj)) || (pointsEq(ai, bj) && pointsEq(ai1, bj1)) { return Point{X: (ai.X + ai1.X) / 2, Y: (ai.Y + ai1.Y) / 2}, true } } } return Point{}, false } func pointsEq(a, b Point) bool { dx := a.X - b.X dy := a.Y - b.Y if dx < 0 { dx = -dx } if dy < 0 { dy = -dy } return dx <= pathEps && dy <= pathEps } // bfsPolygons returns the shortest polygon-index sequence from src to // dst over the adjacency graph, inclusive on both ends. nil if no // path exists. func bfsPolygons(src, dst int, adj map[int][]int, n int) []int { if src == dst { return []int{src} } prev := make([]int, n) for i := range prev { prev[i] = -1 } prev[src] = src queue := []int{src} for len(queue) > 0 { cur := queue[0] queue = queue[1:] if cur == dst { break } for _, nxt := range adj[cur] { if prev[nxt] != -1 { continue } prev[nxt] = cur queue = append(queue, nxt) } } if prev[dst] == -1 { return nil } // Walk back from dst. out := []int{dst} for cur := dst; cur != src; cur = prev[cur] { out = append([]int{prev[cur]}, out...) } return out }