diff --git a/roads.go b/roads.go index 7a3bd23..a60fee0 100644 --- a/roads.go +++ b/roads.go @@ -1,6 +1,7 @@ package main import ( + "container/heap" "image" "image/color" "math" @@ -46,6 +47,16 @@ const ( roadWidthPercentStep = 0.1 ) +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + func clampRoadWidthPercent(v float64) float64 { if v < minRoadWidthPercent { return minRoadWidthPercent @@ -175,26 +186,28 @@ func GenerateRoadsWithPOIs( roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask, wallLayout) } - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil - } if wallLayout != nil && len(wallLayout.Gates) > 0 { gateRoads := generateGateRoads(wallLayout, settings, waterMask, width, height, randSrc) roads = append(roads, gateRoads...) roads = ensureGateRoadConnections(gateRoads, roads, wallLayout, settings, waterMask, width, height, randSrc) } + // Filter any initial paths that illegally cross walls without a gate. roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) + + // Reduce redundant bridges while strictly preserving road network connectivity. + roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) + + // Guarantee that the entire road network forms a single connected component with valid wall/water routing. + roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) + + // Final verification filter: guarantee zero wall violations under all conditions. + roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) + if len(roads) == 0 { return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } - roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil - } - roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) - roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) assignRoadWidths(roads, settings, randSrc, width, height, wallLayout) roadMask := NewPixelMask(width, height) @@ -202,7 +215,7 @@ func GenerateRoadsWithPOIs( exitRoadMask := NewPixelMask(width, height) for _, road := range roads { drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, roadMask, bridgeMask) - if road.Start.IsExit || road.End.IsExit { + if road.Start != nil && road.End != nil && (road.Start.IsExit || road.End.IsExit) { drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask) } } @@ -219,8 +232,6 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa waterMask = NewPixelMask(width, height) } - // Build exclusion zone: wall pixels dilated by one road width. - // POIs must be outside this zone so roads have room to run parallel to walls. fakeLayout := &FortificationLayout{Mask: wallMask} exclusion := buildWallExclusionMask(fakeLayout, settings, width, height) centerX := float64(width-1) * 0.5 @@ -349,47 +360,56 @@ func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, r if coreNodes < 2 { coreNodes = 2 } - // Keep node count compatible with the requested road segment budget so a connected graph is feasible. maxTotalNodes := max(2, roadTarget+1) if coreNodes > maxTotalNodes { coreNodes = maxTotalNodes } - centerX := width / 2 - centerY := height / 2 - effectiveRadius := math.Sqrt(targetCoverage) * (math.Min(float64(width), float64(height)) * 0.5) + // Uniform spacing across the entire settlement footprint so nodes are evenly spread. + uniformSpacing := avgBuildingSize * (1.15 - 0.20*distribution) + if uniformSpacing < 6 { + uniformSpacing = 6 + } + warpPhaseA := randSrc.Float64() * 2 * math.Pi warpPhaseB := randSrc.Float64() * 2 * math.Pi pois := make([]*PointOfInterest, 0, coreNodes) - for len(pois) < coreNodes { + maxTries := coreNodes * 80 + for tries := 0; len(pois) < coreNodes && tries < maxTries; tries++ { x, y, ok := sampleCorePOI(width, height, distribution, targetCoverage, warpPhaseA, warpPhaseB, randSrc) if !ok { - break - } - p := image.Point{X: x, Y: y} - centerDist := math.Hypot(float64(x-centerX), float64(y-centerY)) - centerFactor := 1.0 - clamp01(centerDist/(effectiveRadius+1)) - localSpacing := avgBuildingSize * (1.36 - 0.68*centerFactor) - if localSpacing < 4 { - localSpacing = 4 - } - // Keep larger spacing between intersections so buildings have room. - if waterMask.GetPoint(p) || isTooCloseToExisting(pois, x, y, localSpacing) { continue } - pois = append(pois, &PointOfInterest{X: x, Y: y, TargetDegree: sampleTargetDegree(randSrc, centerFactor)}) + p := image.Point{X: x, Y: y} + if waterMask != nil && waterMask.GetPoint(p) { + continue + } + if isTooCloseToExisting(pois, x, y, uniformSpacing) { + continue + } + pois = append(pois, &PointOfInterest{ + X: x, + Y: y, + TargetDegree: sampleTargetDegree(randSrc), + }) } if len(pois) == 0 { return nil } + // Assign arterial weights evenly across sectors of the city. + centerX := float64(width-1) * 0.5 + centerY := float64(height-1) * 0.5 for _, poi := range pois { - centerDist := math.Hypot(float64(poi.X-centerX), float64(poi.Y-centerY)) - centerFactor := 1.0 - clamp01(centerDist/(effectiveRadius+1)) - sizeFactor := clamp01((avgBuildingSize - 4.0) / 40.0) - poi.ArterialWeight = clamp01(0.72*centerFactor + 0.28*sizeFactor) + dx := float64(poi.X) - centerX + dy := float64(poi.Y) - centerY + dist := math.Hypot(dx, dy) + maxDist := math.Hypot(centerX, centerY) + normDist := clamp01(dist / (maxDist + 1)) + // Balanced weight based on spatial coverage and random variety + poi.ArterialWeight = clamp01(0.40*(1.0-0.5*normDist) + 0.35*randSrc.Float64() + 0.25*clamp01((avgBuildingSize-4.0)/40.0)) } return pois @@ -397,17 +417,17 @@ func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, r func estimateCoreNodeCount(width, height int, distribution, avgBuildingSize float64, numBuildings int) int { targetArea := float64(width*height) * (0.10 + 0.90*distribution) - spacing := avgBuildingSize * (1.4 - 0.5*distribution) + spacing := avgBuildingSize * (1.30 - 0.35*distribution) if spacing < 6 { spacing = 6 } - byArea := int((targetArea / (spacing * spacing)) * 0.20) - buildingPressure := int(math.Sqrt(float64(max(numBuildings, 1))) * (0.7 + distribution*0.9)) + byArea := int((targetArea / (spacing * spacing)) * 0.22) + buildingPressure := int(math.Sqrt(float64(max(numBuildings, 1))) * (0.7 + distribution*0.8)) nodes := byArea + buildingPressure if nodes < 8 { nodes = 8 } - maxNodes := int(clamp(float64(width*height)/50000.0, 80, 550)) + maxNodes := int(clamp(float64(width*height)/45000.0, 80, 550)) if nodes > maxNodes { nodes = maxNodes } @@ -418,13 +438,11 @@ func sampleCorePOI(width, height int, distribution, targetCoverage, warpPhaseA, if width <= 0 || height <= 0 { return 0, 0, false } - // At 100% distribution, allow POIs over the entire canvas. if distribution >= 0.999 { return randSrc.Intn(width), randSrc.Intn(height), true } coverageRadius := math.Sqrt(clamp(targetCoverage, 0.01, 1.0)) - // Morph from round to squarer footprint as distribution rises. superellipsePower := 2.0 + 10.0*distribution warpAmp := (1.0 - distribution) * 0.18 @@ -481,32 +499,43 @@ func sampleEdgePOI(width, height int, randSrc *rand.Rand) *PointOfInterest { } } -func sampleTargetDegree(randSrc *rand.Rand, centerFactor float64) int { - centerFactor = clamp01(centerFactor) +func sampleTargetDegree(randSrc *rand.Rand) int { r := randSrc.Float64() switch { - case r < 0.04-0.02*centerFactor: - return 1 - case r < 0.18-0.06*centerFactor: + case r < 0.12: return 2 - case r < 0.42-0.06*centerFactor: + case r < 0.60: return 3 - case r < 0.86-0.20*centerFactor: + case r < 0.92: return 4 - case r < 0.97-0.08*(1.0-centerFactor): - return 5 default: - return 6 + return 5 } } +// isSegmentWallSafe returns true if the bresenham line from a to b does not intersect wall exclusion pixels outside gates. +func isSegmentWallSafe(a, b image.Point, wallMask, gateMask *PixelMask) bool { + if wallMask == nil { + return true + } + pts := bresenhamRoad([]image.Point{a, b}) + for _, p := range pts { + if wallMask.InBounds(p.X, p.Y) && wallMask.GetXY(p.X, p.Y) { + if gateMask == nil || !gateMask.GetXY(p.X, p.Y) { + return false + } + } + } + return true +} + func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, roadTarget int) []*Road { minAngle := settings.MinRoadAngle * math.Pi / 180.0 if minAngle < 0 { minAngle = 0 } - edgeDist := math.Min(float64(width), float64(height)) * 0.30 + edgeDist := math.Min(float64(width), float64(height)) * 0.35 if roadTarget < len(pois)-1 { roadTarget = len(pois) - 1 } @@ -514,13 +543,10 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, totalBudget := max(collectorTarget, roadTarget+max(3, roadTarget/4)) isSmallSettlement := settings.NumBuildings <= 120 || len(pois) <= 18 - centerX := float64(width-1) * 0.5 - centerY := float64(height-1) * 0.5 - centerRadius := math.Max(math.Min(float64(width), float64(height))*0.28, 1) - - centerCloseness := func(p *PointOfInterest) float64 { - d := math.Hypot(float64(p.X)-centerX, float64(p.Y)-centerY) - return 1.0 - clamp01(d/centerRadius) + var wallMask, gateMask *PixelMask + if wallLayout != nil { + wallMask = wallLayout.Mask + gateMask = wallLayout.GateMask } type edgeCandidate struct { @@ -548,9 +574,14 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, continue } + // Do not add candidate if straight path cuts through a wall outside a gate + if !isSegmentWallSafe(image.Point{X: a.X, Y: a.Y}, image.Point{X: b.X, Y: b.Y}, wallMask, gateMask) { + continue + } + arterialBias := 1.0 - math.Abs(a.ArterialWeight-b.ArterialWeight) - distanceBias := 1.0 - clamp01(d/(edgeDist*1.6)) - score := arterialBias*0.65 + distanceBias*0.35 + randSrc.Float64()*0.08 + distanceBias := 1.0 - clamp01(d/(edgeDist*1.4)) + score := distanceBias*0.55 + arterialBias*0.35 + randSrc.Float64()*0.10 candidates = append(candidates, edgeCandidate{ a: i, b: j, @@ -581,7 +612,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, base := max(1, p.TargetDegree) switch tier { case RoadTierArterial: - return max(base+2, 4) + return max(base+1, 4) case RoadTierCollector: return base + 1 default: @@ -621,7 +652,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, return pick.score-degreePenalty(a, b) >= -0.4 } - arterialCount := max(2, min(len(pois), min(10, 2+roadTarget/16))) + arterialCount := max(2, min(len(pois), min(12, 2+roadTarget/14))) arterialOrder := make([]int, len(pois)) for i := range arterialOrder { arterialOrder[i] = i @@ -629,15 +660,10 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, sort.Slice(arterialOrder, func(i, j int) bool { pi := pois[arterialOrder[i]] pj := pois[arterialOrder[j]] - scoreI := pi.ArterialWeight - centerCloseness(pi)*0.22 - scoreJ := pj.ArterialWeight - centerCloseness(pj)*0.22 - if scoreI == scoreJ { - return centerCloseness(pi) < centerCloseness(pj) - } - return scoreI > scoreJ + return pi.ArterialWeight > pj.ArterialWeight }) arterialNodes := make(map[int]bool, arterialCount) - arterialMinSpacing := edgeDist * 0.55 + arterialMinSpacing := edgeDist * 0.50 arterialMinSpacing2 := arterialMinSpacing * arterialMinSpacing for _, idx := range arterialOrder { if len(arterialNodes) >= arterialCount { @@ -668,8 +694,8 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, connected[start] = true connectedCount := 1 - // Phase 1: connect the major arterial skeleton first. - arterialBudget := max(1, min(len(arterialNodes)-1, min(10, 2+roadTarget/20))) + // Phase 1: connect the major arterial skeleton spanning the city. + arterialBudget := max(1, min(len(arterialNodes)-1, min(12, 2+roadTarget/18))) for len(selectedEdges) < arterialBudget { bestIdx := -1 bestScore := -1.0 @@ -677,7 +703,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, if !arterialNodes[c.a] || !arterialNodes[c.b] { continue } - if c.dist < edgeDist*0.35 { + if c.dist < edgeDist*0.25 { continue } aConn := connected[c.a] @@ -690,10 +716,8 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, } a := pois[c.a] b := pois[c.b] - centerPenalty := centerCloseness(a) * centerCloseness(b) * 0.45 - degreePenalty := clamp01(float64(a.Connections+b.Connections) / 8.0) - coreBonus := (centerCloseness(a) + centerCloseness(b)) * 0.22 - score := c.arterialMean*0.58 + clamp01(c.dist/edgeDist)*0.27 + c.score*0.15 + coreBonus - centerPenalty - degreePenalty*0.18 + degPen := clamp01(float64(a.Connections+b.Connections) / 8.0) + score := c.arterialMean*0.50 + clamp01(c.dist/edgeDist)*0.30 + c.score*0.20 - degPen*0.15 if score > bestScore { bestScore = score bestIdx = idx @@ -729,22 +753,13 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, } a := pois[c.a] b := pois[c.b] - if !a.IsExit && !b.IsExit && c.dist > edgeDist*0.72 { - continue - } connectedBonus := 0.0 if arterialNodes[c.a] || arterialNodes[c.b] { - connectedBonus = 0.20 + connectedBonus = 0.15 } - distScore := 1.0 - clamp01(c.dist/(edgeDist*1.1)) - centerPenalty := centerCloseness(a) * centerCloseness(b) * 0.35 - degreePenalty := clamp01(float64(a.Connections+b.Connections) / 7.0) - longDiagonalPenalty := 0.0 - if !a.IsExit && !b.IsExit { - longDiagonalPenalty = clamp01((c.dist-edgeDist*0.45)/(edgeDist*0.35)) * 0.28 - } - coreBonus := (centerCloseness(a) + centerCloseness(b)) * 0.18 - score := c.score*0.28 + c.arterialMean*0.27 + distScore*0.35 + connectedBonus + coreBonus - centerPenalty - degreePenalty*0.14 - longDiagonalPenalty + distScore := 1.0 - clamp01(c.dist/(edgeDist*1.2)) + degPen := clamp01(float64(a.Connections+b.Connections) / 7.0) + score := c.score*0.35 + c.arterialMean*0.25 + distScore*0.40 + connectedBonus - degPen*0.12 if score > bestScore { bestScore = score bestIdx = idx @@ -765,12 +780,12 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, } } - // Phase 3: add shorter local links inside districts. + // Phase 3: add shorter local links across all districts evenly. for _, pick := range candidates { if len(selectedEdges) >= totalBudget { break } - if pick.dist > edgeDist*0.60 { + if pick.dist > edgeDist*0.65 { continue } a := pois[pick.a] @@ -784,12 +799,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, if isSmallSettlement && (a.Connections > 1 || b.Connections > 1) { continue } - if !a.IsExit && !b.IsExit && pick.dist > edgeDist*0.42 { - continue - } - if centerCloseness(a)+centerCloseness(b) < 0.35 && randSrc.Float64() < 0.55 { - continue - } addEdge(pick, RoadTierLocal) } @@ -811,7 +820,12 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, return roads } - exitRoadsAdded := 0 + var wallMask, gateMask *PixelMask + if wallLayout != nil { + wallMask = wallLayout.Mask + gateMask = wallLayout.GateMask + } + avgDim := float64(width+height) / 2 usedEdgePoints := make([]image.Point, 0, settings.RoadExits) @@ -820,13 +834,38 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, if !ok { continue } + edgePt := image.Point{X: edgeNode.X, Y: edgeNode.Y} - anchor := chooseExitAnchor(pois, usedEdgePoints, randSrc) + // Pick anchor reachable from the edge without illegally crossing walls + anchor := chooseWallSafeExitAnchor(pois, edgePt, usedEdgePoints, wallMask, gateMask, randSrc) + if anchor == nil && wallLayout != nil && len(wallLayout.Gates) > 0 { + // Connect to nearest gate OuterEnd + bestGateDist := math.MaxFloat64 + for _, g := range wallLayout.Gates { + if isSegmentWallSafe(g.OuterEnd, edgePt, wallMask, gateMask) { + d := math.Hypot(float64(g.OuterEnd.X-edgePt.X), float64(g.OuterEnd.Y-edgePt.Y)) + if d < bestGateDist { + bestGateDist = d + anchor = &PointOfInterest{X: g.OuterEnd.X, Y: g.OuterEnd.Y, ArterialWeight: 1.0} + } + } + } + } + if anchor == nil { + anchor = chooseExitAnchor(pois, usedEdgePoints, randSrc) + } if anchor == nil { continue } path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout, RoadTierArterial) + if !pathRespectsWallPassages(path, wallMask, gateMask) { + path = findWallSafePath(image.Point{X: anchor.X, Y: anchor.Y}, edgePt, wallMask, gateMask, waterMask, width, height) + } + + if !pathRespectsWallPassages(path, wallMask, gateMask) { + continue + } anchor.Connections++ edgeNode.IsExit = true @@ -840,112 +879,28 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, Importance: importance, Tier: RoadTierArterial, }) - usedEdgePoints = append(usedEdgePoints, image.Point{X: edgeNode.X, Y: edgeNode.Y}) - exitRoadsAdded++ + usedEdgePoints = append(usedEdgePoints, edgePt) } - _ = exitRoadsAdded return roads } -func forcePathThroughWallGate(start, end *PointOfInterest, wallLayout *FortificationLayout, waterMask *PixelMask) ([]PathPoint, bool) { - if start == nil || end == nil || wallLayout == nil || wallLayout.Mask == nil { - return nil, false - } - mid, ok := nearestWallPixelToSegment(image.Point{X: start.X, Y: start.Y}, image.Point{X: end.X, Y: end.Y}, wallLayout.Mask) - if !ok { - return nil, false - } - tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) - if !ok { - return nil, false - } - nx, ny := -ty, tx - rx := float64(end.X - start.X) - ry := float64(end.Y - start.Y) - if rx*nx+ry*ny < 0 { - nx, ny = -nx, -ny - } - left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) - right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) - if !lok || !rok || left == right { - return nil, false - } - - startPt := image.Point{X: start.X, Y: start.Y} - endPt := image.Point{X: end.X, Y: end.Y} - entry, exit := left, right - d1 := sqDist(startPt, left) + sqDist(endPt, right) - d2 := sqDist(startPt, right) + sqDist(endPt, left) - if d2 < d1 { - entry, exit = right, left - } - - seg1 := bresenhamRoad([]image.Point{startPt, entry}) - seg2 := bresenhamRoad([]image.Point{entry, exit}) - seg3 := bresenhamRoad([]image.Point{exit, endPt}) - out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) - appendDedup := func(seg []image.Point) { - for _, p := range seg { - if len(out) > 0 && out[len(out)-1] == p { - continue - } - out = append(out, p) +func chooseWallSafeExitAnchor(pois []*PointOfInterest, edgePt image.Point, usedExits []image.Point, wallMask, gateMask *PixelMask, randSrc *rand.Rand) *PointOfInterest { + var best *PointOfInterest + bestScore := -1.0 + for _, p := range pois { + pPt := image.Point{X: p.X, Y: p.Y} + if !isSegmentWallSafe(pPt, edgePt, wallMask, gateMask) { + continue + } + d := math.Hypot(float64(p.X-edgePt.X), float64(p.Y-edgePt.Y)) + score := p.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1) + if score > bestScore { + bestScore = score + best = p } } - appendDedup(seg1) - appendDedup(seg2) - appendDedup(seg3) - return toPathPoints(out, waterMask), true -} - -func nearestWallPixelToSegment(a, b image.Point, wallMask *PixelMask) (image.Point, bool) { - if wallMask == nil || wallMask.Width <= 0 || wallMask.Height <= 0 { - return image.Point{}, false - } - best := image.Point{} - bestD2 := math.MaxFloat64 - found := false - for y := 0; y < wallMask.Height; y++ { - row := y * wallMask.Width - for x := 0; x < wallMask.Width; x++ { - if wallMask.Data[row+x] == 0 { - continue - } - d2 := pointSegmentDistanceSquared(float64(x), float64(y), float64(a.X), float64(a.Y), float64(b.X), float64(b.Y)) - if d2 < bestD2 { - bestD2 = d2 - best = image.Point{X: x, Y: y} - found = true - } - } - } - return best, found -} - -func pointSegmentDistanceSquared(px, py, ax, ay, bx, by float64) float64 { - abx := bx - ax - aby := by - ay - apx := px - ax - apy := py - ay - den := abx*abx + aby*aby - if den <= 1e-9 { - dx := px - ax - dy := py - ay - return dx*dx + dy*dy - } - t := (apx*abx + apy*aby) / den - if t < 0 { - t = 0 - } - if t > 1 { - t = 1 - } - cx := ax + t*abx - cy := ay + t*aby - dx := px - cx - dy := py - cy - return dx*dx + dy*dy + return best } func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *PixelMask, used []image.Point) (*PointOfInterest, bool) { @@ -955,7 +910,7 @@ func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *Pix for tries := 0; tries < 120; tries++ { p := sampleEdgePOI(width, height, randSrc) pt := image.Point{X: p.X, Y: p.Y} - if waterMask.GetPoint(pt) { + if waterMask != nil && waterMask.GetPoint(pt) { continue } tooClose := false @@ -1007,17 +962,16 @@ func estimateRoadTarget(settings *Settings) int { if settings.NumBuildings <= 0 { return 0 } - // Keep tiny settlements proportional: 1 building -> 1 road, etc. if settings.NumBuildings < 10 { return settings.NumBuildings } buildings := float64(max(settings.NumBuildings, 1)) - roads := buildings / 5.25 + roads := buildings / 5.0 if buildings > 80 { - roads += math.Pow(buildings-80.0, 0.70) * 0.28 + roads += math.Pow(buildings-80.0, 0.70) * 0.30 } if buildings > 500 { - roads += math.Pow((buildings-500.0)/2.2, 0.66) * 0.18 + roads += math.Pow((buildings-500.0)/2.2, 0.66) * 0.20 } if buildings > 1800 { roads *= 0.95 @@ -1096,14 +1050,15 @@ func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, wid widths := make([]float64, len(roads)) startNode := make([]int, len(roads)) endNode := make([]int, len(roads)) - nodeIndex := make(map[*PointOfInterest]int, len(roads)*2) + nodeIndex := make(map[image.Point]int, len(roads)*2) adj := make([][]int, 0, len(roads)) getNodeID := func(p *PointOfInterest) int { - if id, ok := nodeIndex[p]; ok { + pt := image.Point{X: p.X, Y: p.Y} + if id, ok := nodeIndex[pt]; ok { return id } id := len(adj) - nodeIndex[p] = id + nodeIndex[pt] = id adj = append(adj, nil) return id } @@ -1143,7 +1098,6 @@ func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, wid for i, r := range roads { w := clamp(widths[i], minWidth, maxWidth) if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(r.Points, wallLayout)) > 0 { - // Wall-gate roads should be visibly substantial. minGateWidth := minWidth + 0.55*(maxWidth-minWidth) if w < minGateWidth { w = minGateWidth @@ -1170,7 +1124,6 @@ func drawRoadToMasks(img *image.RGBA, points []PathPoint, roadColor, bridgeColor continue } - // Draw each contiguous bridge run as one straight span. start := i end := i + 1 for end < len(points)-1 && points[end].IsBridge && points[end+1].IsBridge { @@ -1229,17 +1182,19 @@ func bresenhamRoad(path []image.Point) []image.Point { // calculateRoadPath computes the path for a road including curves and bridges. func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, tier RoadTier) []PathPoint { + if start == nil || end == nil { + return nil + } dx := end.X - start.X dy := end.Y - start.Y dist := math.Hypot(float64(dx), float64(dy)) if dist == 0 { p := image.Point{X: start.X, Y: start.Y} - return []PathPoint{{Point: p, IsBridge: waterMask.GetPoint(p)}} + isBridge := waterMask != nil && waterMask.GetPoint(p) + return []PathPoint{{Point: p, IsBridge: isBridge}} } - _ = wallLayout - curve := clamp(curvyness, 0, 1) if curve <= 0.01 || dist < 10 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) @@ -1291,7 +1246,15 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r polyline = append(polyline, image.Point{X: end.X, Y: end.Y}) points := bresenhamRoad(polyline) - return toPathPoints(points, waterMask) + path := toPathPoints(points, waterMask) + + // If the curved path accidentally intersects a wall where straight line doesn't, revert to straight line + if wallLayout != nil && wallLayout.Mask != nil && !pathRespectsWallPassages(path, wallLayout.Mask, wallLayout.GateMask) { + straight := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) + path = toPathPoints(straight, waterMask) + } + + return path } func toPathPoints(points []image.Point, waterMask *PixelMask) []PathPoint { @@ -1319,147 +1282,6 @@ func wallIDAtPoint(p image.Point, wallLayout *FortificationLayout) int { return wallLayout.WallIDByPixel[p.Y*wallLayout.Mask.Width+p.X] } -func straightenPathAcrossWalls(points []PathPoint, wallLayout *FortificationLayout, waterMask *PixelMask) []PathPoint { - if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { - return points - } - - straight := make([]image.Point, 0, len(points)) - i := 0 - for i < len(points) { - curr := points[i].Point - currWallID := wallIDAtPoint(curr, wallLayout) - if currWallID == 0 { - straight = append(straight, curr) - i++ - continue - } - - start := i - if start > 0 { - start-- - } - j := i - for j < len(points) && wallIDAtPoint(points[j].Point, wallLayout) != 0 { - j++ - } - end := j - if end >= len(points) { - end = len(points) - 1 - } - line := enforcePerpendicularWallCrossing(points, start, i, j, end, wallLayout) - for k, p := range line { - if len(straight) > 0 && k == 0 && straight[len(straight)-1] == p { - continue - } - straight = append(straight, p) - } - i = j - } - - return toPathPoints(straight, waterMask) -} - -func enforcePerpendicularWallCrossing(points []PathPoint, start, wallStart, wallEnd, end int, wallLayout *FortificationLayout) []image.Point { - startPt := points[start].Point - endPt := points[end].Point - baseLine := bresenhamRoad([]image.Point{startPt, endPt}) - if wallLayout == nil || wallLayout.Mask == nil { - return baseLine - } - if wallStart < 0 || wallEnd <= wallStart || wallEnd > len(points) { - return baseLine - } - - mid := points[wallStart+(wallEnd-wallStart)/2].Point - tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) - if !ok { - return baseLine - } - rx := float64(endPt.X - startPt.X) - ry := float64(endPt.Y - startPt.Y) - if crossingAngleToTangentDegrees(rx, ry, tx, ty) >= 75.0 { - return baseLine - } - - // Build a forced gate across the wall: one anchor just outside each side of the wall. - nx, ny := -ty, tx - vdot := rx*nx + ry*ny - if vdot < 0 { - nx, ny = -nx, -ny - } - left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) - right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) - if !lok || !rok || left == right { - return baseLine - } - - entry, exit := left, right - d1 := sqDist(startPt, left) + sqDist(endPt, right) - d2 := sqDist(startPt, right) + sqDist(endPt, left) - if d2 < d1 { - entry, exit = right, left - } - - seg1 := bresenhamRoad([]image.Point{startPt, entry}) - seg2 := bresenhamRoad([]image.Point{entry, exit}) - seg3 := bresenhamRoad([]image.Point{exit, endPt}) - out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) - appendDedup := func(seg []image.Point) { - for _, p := range seg { - if len(out) > 0 && out[len(out)-1] == p { - continue - } - out = append(out, p) - } - } - appendDedup(seg1) - appendDedup(seg2) - appendDedup(seg3) - return out -} - -func crossingAngleToTangentDegrees(rx, ry, tx, ty float64) float64 { - rn := math.Hypot(rx, ry) - tn := math.Hypot(tx, ty) - if rn < 0.001 || tn < 0.001 { - return 90 - } - dot := (rx*tx + ry*ty) / (rn * tn) - if dot < -1 { - dot = -1 - } - if dot > 1 { - dot = 1 - } - ang := math.Acos(math.Abs(dot)) * 180.0 / math.Pi - return ang -} - -func walkToOutsideWall(mid image.Point, dx, dy float64, wallMask *PixelMask) (image.Point, bool) { - if wallMask == nil { - return image.Point{}, false - } - maxSteps := max(8, (wallMask.Width+wallMask.Height)/12) - for s := 1; s <= maxSteps; s++ { - x := int(math.Round(float64(mid.X) + dx*float64(s))) - y := int(math.Round(float64(mid.Y) + dy*float64(s))) - if x < 0 || y < 0 || x >= wallMask.Width || y >= wallMask.Height { - return image.Point{}, false - } - if !wallMask.GetXY(x, y) { - return image.Point{X: x, Y: y}, true - } - } - return image.Point{}, false -} - -func sqDist(a, b image.Point) int { - dx := a.X - b.X - dy := a.Y - b.Y - return dx*dx + dy*dy -} - func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int { if wallLayout == nil || wallLayout.Mask == nil || len(points) == 0 { return nil @@ -1484,15 +1306,6 @@ func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int { return out } -func containsWallID(ids []int, wallID int) bool { - for _, id := range ids { - if id == wallID { - return true - } - } - return false -} - func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, waterMask *PixelMask, randSrc *rand.Rand) []*Road { if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil { return roads @@ -1518,185 +1331,55 @@ func pathRespectsWallPassages(points []PathPoint, exclusionMask, gateMask *Pixel if !exclusionMask.InBounds(x, y) { continue } - if !exclusionMask.GetXY(x, y) { - continue + if exclusionMask.GetXY(x, y) { + if gateMask != nil && gateMask.GetXY(x, y) { + continue + } + return false } - if gateMask != nil && gateMask.GetXY(x, y) { - continue - } - return false } return true } -func crossesSameWallMultipleTimes(points []PathPoint, wallLayout *FortificationLayout) bool { - if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { +// isRoadEssentialForConnectivity returns true if removing roads[skipIdx] disconnects road.Start from road.End in the graph. +func isRoadEssentialForConnectivity(roads []*Road, skipIdx int) bool { + target := roads[skipIdx] + if target.Start == nil || target.End == nil { return false } - transitionCount := make(map[int]int) - prevID := wallIDAtPoint(points[0].Point, wallLayout) - for i := 1; i < len(points); i++ { - currID := wallIDAtPoint(points[i].Point, wallLayout) - if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { - wid := currID - if wid == 0 { - wid = prevID - } - if wid > 0 { - transitionCount[wid]++ - // More than two transitions means re-crossing the same wall. - if transitionCount[wid] > 2 { - return true - } - } - } - prevID = currID - } - return false -} + start := image.Point{X: target.Start.X, Y: target.Start.Y} + end := image.Point{X: target.End.X, Y: target.End.Y} -func ensureRoadNetworkConnected(roads []*Road, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, width, height int) []*Road { - if len(roads) <= 1 { - return roads + adj := make(map[image.Point][]image.Point) + for i, r := range roads { + if i == skipIdx || r.Start == nil || r.End == nil { + continue + } + pA := image.Point{X: r.Start.X, Y: r.Start.Y} + pB := image.Point{X: r.End.X, Y: r.End.Y} + adj[pA] = append(adj[pA], pB) + adj[pB] = append(adj[pB], pA) } - avgDim := float64(width+height) / 2.0 - const maxConnectorAttempts = 32 + visited := make(map[image.Point]bool) + visited[start] = true + queue := []image.Point{start} - for attempts := 0; attempts < maxConnectorAttempts; attempts++ { - nodeIndex := make(map[*PointOfInterest]int) - nodes := make([]*PointOfInterest, 0, len(roads)*2) - getNodeID := func(p *PointOfInterest) int { - if id, ok := nodeIndex[p]; ok { - return id - } - id := len(nodes) - nodeIndex[p] = id - nodes = append(nodes, p) - return id + for len(queue) > 0 { + curr := queue[0] + queue = queue[1:] + if curr == end { + return false // End is still reachable without target road } - adj := make([][]int, 0, len(roads)*2) - ensureAdj := func(n int) { - for len(adj) <= n { - adj = append(adj, nil) + for _, nbr := range adj[curr] { + if !visited[nbr] { + visited[nbr] = true + queue = append(queue, nbr) } } - for _, r := range roads { - a := getNodeID(r.Start) - b := getNodeID(r.End) - ensureAdj(a) - ensureAdj(b) - adj[a] = append(adj[a], b) - adj[b] = append(adj[b], a) - } - - compID := make([]int, len(nodes)) - for i := range compID { - compID[i] = -1 - } - compCount := 0 - queue := make([]int, 0, len(nodes)) - for i := 0; i < len(nodes); i++ { - if compID[i] != -1 { - continue - } - compID[i] = compCount - queue = queue[:0] - queue = append(queue, i) - for h := 0; h < len(queue); h++ { - cur := queue[h] - for _, nb := range adj[cur] { - if compID[nb] != -1 { - continue - } - compID[nb] = compCount - queue = append(queue, nb) - } - } - compCount++ - } - if compCount <= 1 { - return roads - } - - bestA, bestB := -1, -1 - bestDist2 := math.MaxFloat64 - for i := 0; i < len(nodes); i++ { - for j := i + 1; j < len(nodes); j++ { - if compID[i] == compID[j] { - continue - } - dx := float64(nodes[i].X - nodes[j].X) - dy := float64(nodes[i].Y - nodes[j].Y) - d2 := dx*dx + dy*dy - if d2 < bestDist2 { - bestDist2 = d2 - bestA, bestB = i, j - } - } - } - if bestA == -1 || bestB == -1 { - return roads - } - - a := nodes[bestA] - b := nodes[bestB] - a.Connections++ - b.Connections++ - path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout, RoadTierCollector) - roads = append(roads, &Road{ - Start: a, - End: b, - Points: path, - Importance: a.Connections + b.Connections + 2, - Tier: RoadTierCollector, - }) } - return roads -} - -// drawLineMasked draws a line with specified width on the image and mask. -func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int, mask *PixelMask) { - dx := abs(x1 - x0) - dy := -abs(y1 - y0) - sx := -1 - if x0 < x1 { - sx = 1 - } - sy := -1 - if y0 < y1 { - sy = 1 - } - err := dx + dy - - for { - for i := -width / 2; i <= width/2; i++ { - for j := -width / 2; j <= width/2; j++ { - px := x0 + i - py := y0 + j - if img.Bounds().Min.X <= px && px < img.Bounds().Max.X && img.Bounds().Min.Y <= py && py < img.Bounds().Max.Y { - img.Set(px, py, col) - if mask != nil { - mask.SetXY(px, py) - } - } - } - } - - if x0 == x1 && y0 == y1 { - break - } - e2 := 2 * err - if e2 >= dy { - err += dy - x0 += sx - } - if e2 <= dx { - err += dx - y0 += sy - } - } + return true // End is unreachable without target road -> essential bridge } func reduceRepeatedBridges(roads []*Road, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road { @@ -1709,18 +1392,26 @@ func reduceRepeatedBridges(roads []*Road, waterMask *PixelMask, width, height in return roads } - // After first bridge on a water body, each additional bridge is progressively less likely. const repeatBridgeFactor = 0.45 bodyBridgeCount := make(map[int]int) filtered := make([]*Road, 0, len(roads)) - for _, road := range roads { + for i, road := range roads { bridgedBodies := bridgedRegionIDs(road.Points, regionByPixel, width, height) if len(bridgedBodies) == 0 { filtered = append(filtered, road) continue } + // Never delete a bridge if it disconnects the road network + if isRoadEssentialForConnectivity(roads, i) { + filtered = append(filtered, road) + for _, body := range bridgedBodies { + bodyBridgeCount[body]++ + } + continue + } + keepProb := 1.0 for _, body := range bridgedBodies { c := bodyBridgeCount[body] @@ -1807,8 +1498,6 @@ func bridgedRegionIDs(points []PathPoint, regionByPixel []int, width, height int return out } -// buildWallExclusionMask creates a mask of wall pixels dilated by one road width. -// Roads will avoid pixels set in this mask (except at gates). func buildWallExclusionMask(wallLayout *FortificationLayout, settings *Settings, width, height int) *PixelMask { if wallLayout == nil || wallLayout.Mask == nil { return NewPixelMask(width, height) @@ -1836,23 +1525,18 @@ func buildWallExclusionMask(wallLayout *FortificationLayout, settings *Settings, return out } -// generateGateRoads creates one straight perpendicular road per gate. -// Each road runs from the outer end to the inner end of the gate, crossing the wall gap. -// It also creates POIs at inner/outer ends so the road network can connect to them. func generateGateRoads(wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road { if wallLayout == nil || len(wallLayout.Gates) == 0 { return nil } _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) - roadWidth := int(math.Round(maxRoadPx + 0.5*(maxRoadPx))) + roadWidth := int(math.Round(maxRoadPx + 0.5*maxRoadPx)) if roadWidth < 1 { roadWidth = 1 } roads := make([]*Road, 0, len(wallLayout.Gates)) for _, gate := range wallLayout.Gates { - // Straight line from outerEnd to innerEnd — do NOT route through gateCenter - // (which is a wall boundary pixel and causes a kink in the road). outer := &PointOfInterest{X: gate.OuterEnd.X, Y: gate.OuterEnd.Y, IsExit: false} inner := &PointOfInterest{X: gate.InnerEnd.X, Y: gate.InnerEnd.Y, IsExit: false} outer.Connections = 1 @@ -1866,79 +1550,47 @@ func generateGateRoads(wallLayout *FortificationLayout, settings *Settings, wate End: inner, Points: path, Width: roadWidth, - Importance: 10, // high importance so gate roads get wide treatment + Importance: 10, Tier: RoadTierArterial, }) } return roads } -// ensureGateRoadConnections adds short connector roads from each gate's inner/outer -// endpoints to the nearest existing road POI, so the gate road is part of the network. func ensureGateRoadConnections(gateRoads []*Road, allRoads []*Road, wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road { if len(gateRoads) == 0 || wallLayout == nil { return allRoads } - // Collect non-gate POIs. - poiSet := make(map[*PointOfInterest]bool) + poiSet := make(map[image.Point]*PointOfInterest) for _, r := range allRoads { if r.Start != nil { - poiSet[r.Start] = true + poiSet[image.Point{X: r.Start.X, Y: r.Start.Y}] = r.Start } if r.End != nil { - poiSet[r.End] = true + poiSet[image.Point{X: r.End.X, Y: r.End.Y}] = r.End } } - // Remove gate road endpoints from the non-gate set. for _, r := range gateRoads { - delete(poiSet, r.Start) - delete(poiSet, r.End) + delete(poiSet, image.Point{X: r.Start.X, Y: r.Start.Y}) + delete(poiSet, image.Point{X: r.End.X, Y: r.End.Y}) } pois := make([]*PointOfInterest, 0, len(poiSet)) - for p := range poiSet { + for _, p := range poiSet { pois = append(pois, p) } - connectors := make([]*Road, 0, len(gateRoads)*2) + connectors := make([]*Road, 0, len(gateRoads)*4) _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) connW := int(math.Round(maxRoadPx)) if connW < 1 { connW = 1 } - // pathCrossesWall returns true if a straight Bresenham line from a to b touches any wall pixel. - pathCrossesWall := func(a, b image.Point) bool { - dx := abs(b.X - a.X) - dy := abs(b.Y - a.Y) - sx := -1 - if a.X < b.X { - sx = 1 - } - sy := -1 - if a.Y < b.Y { - sy = 1 - } - err := dx - dy - x, y := a.X, a.Y - for { - if wallLayout.Mask.GetXY(x, y) { - return true - } - if x == b.X && y == b.Y { - break - } - e2 := 2 * err - if e2 > -dy { - err -= dy - x += sx - } - if e2 < dx { - err += dx - y += sy - } - } - return false + var wallMask, gateMask *PixelMask + if wallLayout != nil { + wallMask = wallLayout.Mask + gateMask = wallLayout.GateMask } for _, gr := range gateRoads { @@ -1948,60 +1600,520 @@ func ensureGateRoadConnections(gateRoads []*Road, allRoads []*Road, wallLayout * } epPt := image.Point{X: ep.X, Y: ep.Y} - // Find nearest POI reachable without crossing any wall. - var best *PointOfInterest - bestD2 := math.MaxFloat64 + type poiCandidate struct { + poi *PointOfInterest + dist float64 + } + var candidates []poiCandidate for _, p := range pois { - if wallLayout.Mask.GetXY(p.X, p.Y) { - continue - } pPt := image.Point{X: p.X, Y: p.Y} - if pathCrossesWall(epPt, pPt) { + if !isSegmentWallSafe(epPt, pPt, wallMask, gateMask) { continue } - dx := float64(p.X - ep.X) - dy := float64(p.Y - ep.Y) - d2 := dx*dx + dy*dy - if d2 < bestD2 { - bestD2 = d2 - best = p - } + d := math.Hypot(float64(p.X-ep.X), float64(p.Y-ep.Y)) + candidates = append(candidates, poiCandidate{poi: p, dist: d}) } - // Fallback: if no wall-safe POI found, take the nearest regardless. - if best == nil { - for _, p := range pois { - if wallLayout.Mask.GetXY(p.X, p.Y) { - continue - } - dx := float64(p.X - ep.X) - dy := float64(p.Y - ep.Y) - d2 := dx*dx + dy*dy - if d2 < bestD2 { - bestD2 = d2 - best = p - } - } - } - if best == nil { - continue - } - pts := bresenhamRoad([]image.Point{epPt, {X: best.X, Y: best.Y}}) - path := toPathPoints(pts, waterMask) - ep.Connections++ - best.Connections++ - connectors = append(connectors, &Road{ - Start: ep, - End: best, - Points: path, - Width: connW, - Importance: 6, - Tier: RoadTierCollector, + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].dist < candidates[j].dist }) + + // Connect to up to 2 nearest wall-safe POIs on that side + connectCount := min(2, len(candidates)) + for cIdx := 0; cIdx < connectCount; cIdx++ { + best := candidates[cIdx].poi + pts := bresenhamRoad([]image.Point{epPt, {X: best.X, Y: best.Y}}) + path := toPathPoints(pts, waterMask) + ep.Connections++ + best.Connections++ + connectors = append(connectors, &Road{ + Start: ep, + End: best, + Points: path, + Width: connW, + Importance: 6, + Tier: RoadTierCollector, + }) + } } } return append(allRoads, connectors...) } +// gridNode represents a node in A* grid pathfinding +type gridNode struct { + x, y int + gCost float64 + fCost float64 + index int + parentIdx int +} + +type gridPriorityQueue []*gridNode + +func (pq gridPriorityQueue) Len() int { return len(pq) } +func (pq gridPriorityQueue) Less(i, j int) bool { return pq[i].fCost < pq[j].fCost } +func (pq gridPriorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].index = i + pq[j].index = j +} +func (pq *gridPriorityQueue) Push(x interface{}) { + n := len(*pq) + item := x.(*gridNode) + item.index = n + *pq = append(*pq, item) +} +func (pq *gridPriorityQueue) Pop() interface{} { + old := *pq + n := len(old) + item := old[n-1] + old[n-1] = nil + item.index = -1 + *pq = old[0 : n-1] + return item +} + +// findWallSafePath generates a path of points between start and end that avoids walls (or passes through gates). +func findWallSafePath(start, end image.Point, wallMask, gateMask, waterMask *PixelMask, width, height int) []PathPoint { + if isSegmentWallSafe(start, end, wallMask, gateMask) { + pts := bresenhamRoad([]image.Point{start, end}) + return toPathPoints(pts, waterMask) + } + + if wallMask == nil { + pts := bresenhamRoad([]image.Point{start, end}) + return toPathPoints(pts, waterMask) + } + + // Downsampled grid A* for obstacle avoidance + step := 6 + gw := (width + step - 1) / step + gh := (height + step - 1) / step + + sx, sy := clampInt(start.X/step, 0, gw-1), clampInt(start.Y/step, 0, gh-1) + ex, ey := clampInt(end.X/step, 0, gw-1), clampInt(end.Y/step, 0, gh-1) + + isBlocked := func(gx, gy int) bool { + if (gx == sx && gy == sy) || (gx == ex && gy == ey) { + return false + } + if gateMask != nil { + for dy := 0; dy < step; dy++ { + for dx := 0; dx < step; dx++ { + if gateMask.GetXY(gx*step+dx, gy*step+dy) { + return false + } + } + } + } + px := gx*step + step/2 + py := gy*step + step/2 + if !wallMask.InBounds(px, py) { + return false + } + return wallMask.GetXY(px, py) + } + + cellKey := func(x, y int) int { return y*gw + x } + + pq := make(gridPriorityQueue, 0, 256) + heap.Init(&pq) + + allNodes := make([]*gridNode, 0, gw*gh) + nodeMap := make(map[int]int, gw*gh) + + hCost := func(x, y int) float64 { + return math.Hypot(float64(x-ex), float64(y-ey)) + } + + startNode := &gridNode{x: sx, y: sy, gCost: 0, fCost: hCost(sx, sy), parentIdx: -1} + allNodes = append(allNodes, startNode) + nodeMap[cellKey(sx, sy)] = 0 + heap.Push(&pq, startNode) + + closed := make(map[int]bool, gw*gh) + targetIdx := -1 + + dxs := []int{1, -1, 0, 0, 1, -1, 1, -1} + dys := []int{0, 0, 1, -1, 1, 1, -1, -1} + dcosts := []float64{1.0, 1.0, 1.0, 1.0, 1.414, 1.414, 1.414, 1.414} + + maxIterations := gw * gh * 2 + for pq.Len() > 0 && maxIterations > 0 { + maxIterations-- + curr := heap.Pop(&pq).(*gridNode) + currKey := cellKey(curr.x, curr.y) + if closed[currKey] { + continue + } + closed[currKey] = true + + if curr.x == ex && curr.y == ey { + targetIdx = nodeMap[currKey] + break + } + + for i := 0; i < 8; i++ { + nx, ny := curr.x+dxs[i], curr.y+dys[i] + if nx < 0 || ny < 0 || nx >= gw || ny >= gh { + continue + } + nKey := cellKey(nx, ny) + if closed[nKey] { + continue + } + if isBlocked(nx, ny) { + continue + } + + newG := curr.gCost + dcosts[i] + if existingIdx, exists := nodeMap[nKey]; exists { + nbrNode := allNodes[existingIdx] + if newG < nbrNode.gCost { + nbrNode.gCost = newG + nbrNode.fCost = newG + hCost(nx, ny) + nbrNode.parentIdx = nodeMap[currKey] + heap.Fix(&pq, nbrNode.index) + } + } else { + nbrNode := &gridNode{ + x: nx, + y: ny, + gCost: newG, + fCost: newG + hCost(nx, ny), + parentIdx: nodeMap[currKey], + } + idx := len(allNodes) + allNodes = append(allNodes, nbrNode) + nodeMap[nKey] = idx + heap.Push(&pq, nbrNode) + } + } + } + + if targetIdx == -1 { + return nil + } + + // Reconstruct waypoint path + var waypoints []image.Point + currIdx := targetIdx + for currIdx != -1 { + gn := allNodes[currIdx] + waypoints = append(waypoints, image.Point{X: gn.x*step + step/2, Y: gn.y*step + step/2}) + currIdx = gn.parentIdx + } + // Reverse waypoints + for i, j := 0, len(waypoints)-1; i < j; i, j = i+1, j-1 { + waypoints[i], waypoints[j] = waypoints[j], waypoints[i] + } + waypoints[0] = start + waypoints[len(waypoints)-1] = end + + pts := bresenhamRoad(waypoints) + path := toPathPoints(pts, waterMask) + if !pathRespectsWallPassages(path, wallMask, gateMask) { + return nil + } + return path +} + +func ensureRoadNetworkConnected(roads []*Road, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, width, height int) []*Road { + if len(roads) <= 1 { + return roads + } + + avgDim := float64(width+height) / 2.0 + const maxConnectorAttempts = 48 + + var wallMask, gateMask *PixelMask + if wallLayout != nil { + wallMask = wallLayout.Mask + gateMask = wallLayout.GateMask + } + + for attempts := 0; attempts < maxConnectorAttempts; attempts++ { + nodeIndex := make(map[image.Point]int) + nodes := make([]*PointOfInterest, 0, len(roads)*2) + getNodeID := func(p *PointOfInterest) int { + pt := image.Point{X: p.X, Y: p.Y} + if id, ok := nodeIndex[pt]; ok { + return id + } + id := len(nodes) + nodeIndex[pt] = id + nodes = append(nodes, p) + return id + } + adj := make([][]int, 0, len(roads)*2) + ensureAdj := func(n int) { + for len(adj) <= n { + adj = append(adj, nil) + } + } + for _, r := range roads { + if r.Start == nil || r.End == nil { + continue + } + a := getNodeID(r.Start) + b := getNodeID(r.End) + ensureAdj(a) + ensureAdj(b) + adj[a] = append(adj[a], b) + adj[b] = append(adj[b], a) + } + + if len(nodes) == 0 { + return roads + } + + compID := make([]int, len(nodes)) + for i := range compID { + compID[i] = -1 + } + compCount := 0 + queue := make([]int, 0, len(nodes)) + for i := 0; i < len(nodes); i++ { + if compID[i] != -1 { + continue + } + compID[i] = compCount + queue = queue[:0] + queue = append(queue, i) + for h := 0; h < len(queue); h++ { + cur := queue[h] + for _, nb := range adj[cur] { + if compID[nb] != -1 { + continue + } + compID[nb] = compCount + queue = append(queue, nb) + } + } + compCount++ + } + if compCount <= 1 { + return roads + } + + // Priority 1: Shortest direct wall-safe connection between different components + bestA, bestB := -1, -1 + bestDist2 := math.MaxFloat64 + for i := 0; i < len(nodes); i++ { + for j := i + 1; j < len(nodes); j++ { + if compID[i] == compID[j] { + continue + } + ptA := image.Point{X: nodes[i].X, Y: nodes[i].Y} + ptB := image.Point{X: nodes[j].X, Y: nodes[j].Y} + if !isSegmentWallSafe(ptA, ptB, wallMask, gateMask) { + continue + } + dx := float64(nodes[i].X - nodes[j].X) + dy := float64(nodes[i].Y - nodes[j].Y) + d2 := dx*dx + dy*dy + if d2 < bestDist2 { + bestDist2 = d2 + bestA, bestB = i, j + } + } + } + + if bestA != -1 && bestB != -1 { + a := nodes[bestA] + b := nodes[bestB] + path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout, RoadTierCollector) + if !pathRespectsWallPassages(path, wallMask, gateMask) { + path = findWallSafePath(image.Point{X: a.X, Y: a.Y}, image.Point{X: b.X, Y: b.Y}, wallMask, gateMask, waterMask, width, height) + } + if path != nil && pathRespectsWallPassages(path, wallMask, gateMask) { + a.Connections++ + b.Connections++ + roads = append(roads, &Road{ + Start: a, + End: b, + Points: path, + Importance: a.Connections + b.Connections + 2, + Tier: RoadTierCollector, + }) + continue + } + } + + // Priority 2: Connect components across walls through the closest gate + if wallLayout != nil && len(wallLayout.Gates) > 0 { + gateConnected := false + for _, gate := range wallLayout.Gates { + innerPt := gate.InnerEnd + outerPt := gate.OuterEnd + innerPOI := &PointOfInterest{X: innerPt.X, Y: innerPt.Y} + outerPOI := &PointOfInterest{X: outerPt.X, Y: outerPt.Y} + + for i := 0; i < len(nodes); i++ { + ptA := image.Point{X: nodes[i].X, Y: nodes[i].Y} + for j := 0; j < len(nodes); j++ { + if compID[i] == compID[j] { + continue + } + ptB := image.Point{X: nodes[j].X, Y: nodes[j].Y} + + // Try path ptA -> outerPt, and ptB -> innerPt + pathA := findWallSafePath(ptA, outerPt, wallMask, gateMask, waterMask, width, height) + pathB := findWallSafePath(ptB, innerPt, wallMask, gateMask, waterMask, width, height) + if pathA != nil && pathB != nil { + gateRoadPts := bresenhamRoad([]image.Point{outerPt, innerPt}) + roads = append(roads, &Road{ + Start: outerPOI, + End: innerPOI, + Points: toPathPoints(gateRoadPts, waterMask), + Importance: 8, + Tier: RoadTierArterial, + }) + roads = append(roads, &Road{ + Start: nodes[i], + End: outerPOI, + Points: pathA, + Importance: 5, + Tier: RoadTierCollector, + }) + roads = append(roads, &Road{ + Start: nodes[j], + End: innerPOI, + Points: pathB, + Importance: 5, + Tier: RoadTierCollector, + }) + nodes[i].Connections++ + nodes[j].Connections++ + gateConnected = true + break + } + + // Try reverse: ptA -> innerPt, and ptB -> outerPt + pathA = findWallSafePath(ptA, innerPt, wallMask, gateMask, waterMask, width, height) + pathB = findWallSafePath(ptB, outerPt, wallMask, gateMask, waterMask, width, height) + if pathA != nil && pathB != nil { + gateRoadPts := bresenhamRoad([]image.Point{outerPt, innerPt}) + roads = append(roads, &Road{ + Start: outerPOI, + End: innerPOI, + Points: toPathPoints(gateRoadPts, waterMask), + Importance: 8, + Tier: RoadTierArterial, + }) + roads = append(roads, &Road{ + Start: nodes[i], + End: innerPOI, + Points: pathA, + Importance: 5, + Tier: RoadTierCollector, + }) + roads = append(roads, &Road{ + Start: nodes[j], + End: outerPOI, + Points: pathB, + Importance: 5, + Tier: RoadTierCollector, + }) + nodes[i].Connections++ + nodes[j].Connections++ + gateConnected = true + break + } + } + if gateConnected { + break + } + } + if gateConnected { + break + } + } + if gateConnected { + continue + } + } + + // Priority 3: Pathfinding around walls via A* + foundPath := false + for i := 0; i < len(nodes) && !foundPath; i++ { + for j := i + 1; j < len(nodes) && !foundPath; j++ { + if compID[i] == compID[j] { + continue + } + a := nodes[i] + b := nodes[j] + ptA := image.Point{X: a.X, Y: a.Y} + ptB := image.Point{X: b.X, Y: b.Y} + path := findWallSafePath(ptA, ptB, wallMask, gateMask, waterMask, width, height) + if path != nil && pathRespectsWallPassages(path, wallMask, gateMask) { + a.Connections++ + b.Connections++ + roads = append(roads, &Road{ + Start: a, + End: b, + Points: path, + Importance: a.Connections + b.Connections + 2, + Tier: RoadTierCollector, + }) + foundPath = true + break + } + } + } + + if !foundPath { + // No safe path could be found this iteration + break + } + } + + return roads +} + +func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int, mask *PixelMask) { + dx := abs(x1 - x0) + dy := -abs(y1 - y0) + sx := -1 + if x0 < x1 { + sx = 1 + } + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx + dy + + for { + for i := -width / 2; i <= width/2; i++ { + for j := -width / 2; j <= width/2; j++ { + px := x0 + i + py := y0 + j + if img.Bounds().Min.X <= px && px < img.Bounds().Max.X && img.Bounds().Min.Y <= py && py < img.Bounds().Max.Y { + img.Set(px, py, col) + if mask != nil { + mask.SetXY(px, py) + } + } + } + } + + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + func collectRoadAnchors(roads []*Road, settings *Settings, waterMask *PixelMask, width, height int) []image.Point { if len(roads) == 0 { return nil