2208 lines
58 KiB
Go
2208 lines
58 KiB
Go
package main
|
|
|
|
import (
|
|
"container/heap"
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"math/rand"
|
|
"sort"
|
|
)
|
|
|
|
// PointOfInterest represents a location where roads may start, end, or intersect.
|
|
type PointOfInterest struct {
|
|
X, Y int
|
|
Connections int
|
|
TargetDegree int
|
|
IsExit bool
|
|
ArterialWeight float64
|
|
}
|
|
|
|
// PathPoint represents a single point in a road's path with bridge flag.
|
|
type PathPoint struct {
|
|
Point image.Point
|
|
IsBridge bool
|
|
}
|
|
|
|
type RoadTier int
|
|
|
|
const (
|
|
RoadTierLocal RoadTier = iota
|
|
RoadTierCollector
|
|
RoadTierArterial
|
|
)
|
|
|
|
// Road represents a connection between two points of interest.
|
|
type Road struct {
|
|
Start, End *PointOfInterest
|
|
Width int
|
|
Points []PathPoint
|
|
Importance int
|
|
Tier RoadTier
|
|
}
|
|
|
|
const (
|
|
minRoadWidthPercent = 0.1
|
|
maxRoadWidthPercent = 5.0
|
|
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
|
|
}
|
|
if v > maxRoadWidthPercent {
|
|
return maxRoadWidthPercent
|
|
}
|
|
return v
|
|
}
|
|
|
|
func snapRoadWidthPercent(v float64) float64 {
|
|
v = clampRoadWidthPercent(v)
|
|
steps := math.Round((v - minRoadWidthPercent) / roadWidthPercentStep)
|
|
return clampRoadWidthPercent(minRoadWidthPercent + steps*roadWidthPercentStep)
|
|
}
|
|
|
|
func normalizeRoadWidthPercentRange(minPercent, maxPercent float64) (float64, float64) {
|
|
minPercent = snapRoadWidthPercent(minPercent)
|
|
maxPercent = snapRoadWidthPercent(maxPercent)
|
|
if minPercent > maxPercent {
|
|
minPercent, maxPercent = maxPercent, minPercent
|
|
}
|
|
return minPercent, maxPercent
|
|
}
|
|
|
|
func getRoadWidthRangePixels(settings *Settings, width, height int) (float64, float64) {
|
|
minPercent, maxPercent := normalizeRoadWidthPercentRange(settings.MinRoadWidth, settings.MaxRoadWidth)
|
|
avgDim := averageImageDimension(width, height)
|
|
if avgDim < 1 {
|
|
avgDim = 1
|
|
}
|
|
minPx := (minPercent / 100.0) * avgDim
|
|
maxPx := (maxPercent / 100.0) * avgDim
|
|
if minPx < 1 {
|
|
minPx = 1
|
|
}
|
|
if maxPx < 1 {
|
|
maxPx = 1
|
|
}
|
|
return minPx, maxPx
|
|
}
|
|
|
|
// GenerateRoads creates roads on the map.
|
|
func GenerateRoads(
|
|
img *image.RGBA,
|
|
width,
|
|
height int,
|
|
settings *Settings,
|
|
waterMask *PixelMask,
|
|
seed int64,
|
|
) (*PixelMask, *PixelMask, *PixelMask, []image.Point) {
|
|
roadMask, bridgeMask, exitRoadMask, roadAnchors, _ := GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed)
|
|
return roadMask, bridgeMask, exitRoadMask, roadAnchors
|
|
}
|
|
|
|
func PrepareRoadNodes(width, height int, settings *Settings, waterMask *PixelMask, seed int64) ([]*PointOfInterest, int, bool) {
|
|
randSrc := rand.New(rand.NewSource(seed))
|
|
|
|
if settings.NumBuildings == 0 {
|
|
internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100)))
|
|
exitRoads := max(0, settings.RoadExits)
|
|
if internalRoads == 0 && exitRoads > 0 && settings.RoadDistribution <= 0 {
|
|
return nil, 0, true
|
|
}
|
|
if internalRoads > 0 {
|
|
roadTarget := internalRoads
|
|
return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false
|
|
}
|
|
return nil, 0, false
|
|
}
|
|
|
|
roadTarget := estimateRoadTarget(settings)
|
|
return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false
|
|
}
|
|
|
|
func GenerateRoadsWithPOIs(
|
|
img *image.RGBA,
|
|
width,
|
|
height int,
|
|
settings *Settings,
|
|
waterMask *PixelMask,
|
|
wallLayout *FortificationLayout,
|
|
pois []*PointOfInterest,
|
|
roadTarget int,
|
|
edgeToEdgeOnly bool,
|
|
seed int64,
|
|
) (*PixelMask, *PixelMask, *PixelMask, []image.Point, []*Road) {
|
|
if img == nil {
|
|
img = image.NewRGBA(image.Rect(0, 0, width, height))
|
|
}
|
|
randSrc := rand.New(rand.NewSource(seed))
|
|
roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255}
|
|
bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255}
|
|
|
|
if len(pois) > 0 && wallLayout != nil && wallLayout.Mask != nil {
|
|
nudgePOIsOutsideWalls(pois, wallLayout.Mask, waterMask, settings, width, height, randSrc)
|
|
}
|
|
|
|
// Edge-case mode: no buildings.
|
|
if settings.NumBuildings == 0 && roadTarget == 0 && !edgeToEdgeOnly {
|
|
internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100)))
|
|
exitRoads := max(0, settings.RoadExits)
|
|
if internalRoads == 0 && exitRoads == 0 {
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
|
}
|
|
if internalRoads > 0 {
|
|
roadTarget = internalRoads
|
|
} else if settings.RoadDistribution <= 0 && exitRoads > 0 {
|
|
edgeToEdgeOnly = true
|
|
}
|
|
}
|
|
|
|
var roads []*Road
|
|
if edgeToEdgeOnly {
|
|
roads = generateEdgeToEdgeExitRoads(max(0, settings.RoadExits), width, height, settings, randSrc, waterMask, wallLayout)
|
|
} else {
|
|
if roadTarget <= 0 {
|
|
roadTarget = estimateRoadTarget(settings)
|
|
}
|
|
if pois == nil {
|
|
pois = generatePOIs(width, height, settings, waterMask, randSrc, roadTarget)
|
|
}
|
|
if len(pois) < 2 {
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
|
}
|
|
roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, wallLayout, roadTarget)
|
|
roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask, wallLayout)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
assignRoadWidths(roads, settings, randSrc, width, height, wallLayout)
|
|
|
|
roadMask := NewPixelMask(width, height)
|
|
bridgeMask := NewPixelMask(width, height)
|
|
exitRoadMask := NewPixelMask(width, height)
|
|
for _, road := range roads {
|
|
drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, roadMask, bridgeMask)
|
|
if road.Start != nil && road.End != nil && (road.Start.IsExit || road.End.IsExit) {
|
|
drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask)
|
|
}
|
|
}
|
|
|
|
roadAnchors := collectRoadAnchors(roads, settings, waterMask, width, height)
|
|
return roadMask, bridgeMask, exitRoadMask, roadAnchors, roads
|
|
}
|
|
|
|
func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMask, settings *Settings, width, height int, randSrc *rand.Rand) {
|
|
if len(pois) == 0 || wallMask == nil {
|
|
return
|
|
}
|
|
if waterMask == nil {
|
|
waterMask = NewPixelMask(width, height)
|
|
}
|
|
|
|
fakeLayout := &FortificationLayout{Mask: wallMask}
|
|
exclusion := buildWallExclusionMask(fakeLayout, settings, width, height)
|
|
centerX := float64(width-1) * 0.5
|
|
centerY := float64(height-1) * 0.5
|
|
|
|
for _, p := range pois {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if !exclusion.GetXY(p.X, p.Y) {
|
|
continue
|
|
}
|
|
|
|
vx := float64(p.X) - centerX
|
|
vy := float64(p.Y) - centerY
|
|
vlen := math.Hypot(vx, vy)
|
|
if vlen < 0.001 {
|
|
theta := randSrc.Float64() * 2 * math.Pi
|
|
vx = math.Cos(theta)
|
|
vy = math.Sin(theta)
|
|
vlen = 1
|
|
}
|
|
dx := vx / vlen
|
|
dy := vy / vlen
|
|
|
|
moved := false
|
|
maxSteps := exclusion.Width + exclusion.Height
|
|
for step := 1; step <= maxSteps; step++ {
|
|
nx := int(math.Round(float64(p.X) + float64(step)*dx))
|
|
ny := int(math.Round(float64(p.Y) + float64(step)*dy))
|
|
if nx < 0 || ny < 0 || nx >= width || ny >= height {
|
|
break
|
|
}
|
|
if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
|
|
continue
|
|
}
|
|
p.X = nx
|
|
p.Y = ny
|
|
moved = true
|
|
break
|
|
}
|
|
if moved {
|
|
continue
|
|
}
|
|
|
|
baseAngle := math.Atan2(dy, dx)
|
|
for a := -6; a <= 6; a++ {
|
|
ang := baseAngle + float64(a)*math.Pi/18.0
|
|
adx := math.Cos(ang)
|
|
ady := math.Sin(ang)
|
|
for step := 1; step <= exclusion.Width+exclusion.Height; step++ {
|
|
nx := int(math.Round(float64(p.X) + float64(step)*adx))
|
|
ny := int(math.Round(float64(p.Y) + float64(step)*ady))
|
|
if nx < 0 || ny < 0 || nx >= width || ny >= height {
|
|
break
|
|
}
|
|
if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
|
|
continue
|
|
}
|
|
p.X = nx
|
|
p.Y = ny
|
|
moved = true
|
|
break
|
|
}
|
|
if moved {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road {
|
|
if exitRoads <= 0 {
|
|
return nil
|
|
}
|
|
avgDim := float64(width+height) / 2.0
|
|
roads := make([]*Road, 0, exitRoads)
|
|
for i := 0; i < exitRoads; i++ {
|
|
start, end := sampleDifferentEdgePair(width, height, randSrc)
|
|
start.IsExit = true
|
|
end.IsExit = true
|
|
path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout, RoadTierArterial)
|
|
roads = append(roads, &Road{
|
|
Start: start,
|
|
End: end,
|
|
Points: path,
|
|
Importance: 1,
|
|
Tier: RoadTierArterial,
|
|
})
|
|
}
|
|
return roads
|
|
}
|
|
|
|
func sampleDifferentEdgePair(width, height int, randSrc *rand.Rand) (*PointOfInterest, *PointOfInterest) {
|
|
sideA := randSrc.Intn(4)
|
|
sideB := randSrc.Intn(3)
|
|
if sideB >= sideA {
|
|
sideB++
|
|
}
|
|
return sampleEdgePOIBySide(width, height, sideA, randSrc), sampleEdgePOIBySide(width, height, sideB, randSrc)
|
|
}
|
|
|
|
func sampleEdgePOIBySide(width, height, side int, randSrc *rand.Rand) *PointOfInterest {
|
|
switch side {
|
|
case 0:
|
|
return &PointOfInterest{X: randSrc.Intn(width), Y: 0}
|
|
case 1:
|
|
return &PointOfInterest{X: randSrc.Intn(width), Y: height - 1}
|
|
case 2:
|
|
return &PointOfInterest{X: 0, Y: randSrc.Intn(height)}
|
|
default:
|
|
return &PointOfInterest{X: width - 1, Y: randSrc.Intn(height)}
|
|
}
|
|
}
|
|
|
|
func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, randSrc *rand.Rand, roadTarget int) []*PointOfInterest {
|
|
distribution := clamp01(settings.RoadDistribution / 100.0)
|
|
targetCoverage := 0.10 + 0.90*distribution
|
|
minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
|
|
avgBuildingSize := (minBuildingSizePx + maxBuildingSizePx) / 2.0
|
|
if avgBuildingSize < 1 {
|
|
avgBuildingSize = 1
|
|
}
|
|
|
|
coreNodes := estimateCoreNodeCount(width, height, distribution, avgBuildingSize, settings.NumBuildings)
|
|
if coreNodes < 2 {
|
|
coreNodes = 2
|
|
}
|
|
maxTotalNodes := max(2, roadTarget+1)
|
|
if coreNodes > maxTotalNodes {
|
|
coreNodes = maxTotalNodes
|
|
}
|
|
|
|
// 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)
|
|
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 {
|
|
continue
|
|
}
|
|
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 {
|
|
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
|
|
}
|
|
|
|
func estimateCoreNodeCount(width, height int, distribution, avgBuildingSize float64, numBuildings int) int {
|
|
targetArea := float64(width*height) * (0.10 + 0.90*distribution)
|
|
spacing := avgBuildingSize * (1.30 - 0.35*distribution)
|
|
if spacing < 6 {
|
|
spacing = 6
|
|
}
|
|
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)/45000.0, 80, 550))
|
|
if nodes > maxNodes {
|
|
nodes = maxNodes
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func sampleCorePOI(width, height int, distribution, targetCoverage, warpPhaseA, warpPhaseB float64, randSrc *rand.Rand) (int, int, bool) {
|
|
if width <= 0 || height <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
if distribution >= 0.999 {
|
|
return randSrc.Intn(width), randSrc.Intn(height), true
|
|
}
|
|
|
|
coverageRadius := math.Sqrt(clamp(targetCoverage, 0.01, 1.0))
|
|
superellipsePower := 2.0 + 10.0*distribution
|
|
warpAmp := (1.0 - distribution) * 0.18
|
|
|
|
cx := float64(width-1) * 0.5
|
|
cy := float64(height-1) * 0.5
|
|
invHalfW := 1.0 / math.Max(float64(width-1)*0.5, 1.0)
|
|
invHalfH := 1.0 / math.Max(float64(height-1)*0.5, 1.0)
|
|
|
|
for i := 0; i < 120; i++ {
|
|
x := randSrc.Intn(width)
|
|
y := randSrc.Intn(height)
|
|
nx := (float64(x) - cx) * invHalfW
|
|
ny := (float64(y) - cy) * invHalfH
|
|
|
|
ax := math.Abs(nx)
|
|
ay := math.Abs(ny)
|
|
metric := math.Pow(ax, superellipsePower) + math.Pow(ay, superellipsePower)
|
|
theta := math.Atan2(ny, nx)
|
|
warp := 1.0 + warpAmp*(0.55*math.Sin(3.0*theta+warpPhaseA)+0.45*math.Sin(5.0*theta+warpPhaseB))
|
|
if warp < 0.7 {
|
|
warp = 0.7
|
|
}
|
|
threshold := math.Pow(coverageRadius*warp, superellipsePower)
|
|
if metric <= threshold {
|
|
return x, y, true
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
func isTooCloseToExisting(pois []*PointOfInterest, x, y int, minDist float64) bool {
|
|
minDist2 := minDist * minDist
|
|
for _, p := range pois {
|
|
dx := float64(p.X - x)
|
|
dy := float64(p.Y - y)
|
|
if dx*dx+dy*dy < minDist2 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sampleEdgePOI(width, height int, randSrc *rand.Rand) *PointOfInterest {
|
|
side := randSrc.Intn(4)
|
|
switch side {
|
|
case 0:
|
|
return &PointOfInterest{X: randSrc.Intn(width), Y: 0}
|
|
case 1:
|
|
return &PointOfInterest{X: randSrc.Intn(width), Y: height - 1}
|
|
case 2:
|
|
return &PointOfInterest{X: 0, Y: randSrc.Intn(height)}
|
|
default:
|
|
return &PointOfInterest{X: width - 1, Y: randSrc.Intn(height)}
|
|
}
|
|
}
|
|
|
|
func sampleTargetDegree(randSrc *rand.Rand) int {
|
|
r := randSrc.Float64()
|
|
switch {
|
|
case r < 0.12:
|
|
return 2
|
|
case r < 0.60:
|
|
return 3
|
|
case r < 0.92:
|
|
return 4
|
|
default:
|
|
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.35
|
|
if roadTarget < len(pois)-1 {
|
|
roadTarget = len(pois) - 1
|
|
}
|
|
collectorTarget := max(len(pois)-1, max(roadTarget, roadTarget+max(2, roadTarget/8)))
|
|
totalBudget := max(collectorTarget, roadTarget+max(3, roadTarget/4))
|
|
isSmallSettlement := settings.NumBuildings <= 120 || len(pois) <= 18
|
|
|
|
var wallMask, gateMask *PixelMask
|
|
if wallLayout != nil {
|
|
wallMask = wallLayout.Mask
|
|
gateMask = wallLayout.GateMask
|
|
}
|
|
|
|
type edgeCandidate struct {
|
|
a, b int
|
|
score float64
|
|
dist float64
|
|
arterialMean float64
|
|
}
|
|
|
|
candidates := make([]edgeCandidate, 0, len(pois)*6)
|
|
for i := 0; i < len(pois); i++ {
|
|
for j := i + 1; j < len(pois); j++ {
|
|
a := pois[i]
|
|
b := pois[j]
|
|
if a.IsExit && b.IsExit {
|
|
continue
|
|
}
|
|
dx := float64(a.X - b.X)
|
|
dy := float64(a.Y - b.Y)
|
|
d := math.Hypot(dx, dy)
|
|
if !a.IsExit && !b.IsExit && d > edgeDist {
|
|
continue
|
|
}
|
|
if (a.IsExit || b.IsExit) && d > edgeDist*1.6 {
|
|
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.4))
|
|
score := distanceBias*0.55 + arterialBias*0.35 + randSrc.Float64()*0.10
|
|
candidates = append(candidates, edgeCandidate{
|
|
a: i,
|
|
b: j,
|
|
score: score,
|
|
dist: d,
|
|
arterialMean: (a.ArterialWeight + b.ArterialWeight) * 0.5,
|
|
})
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
|
|
type selectedEdge struct {
|
|
edge edgeCandidate
|
|
tier RoadTier
|
|
}
|
|
|
|
selected := make(map[uint64]bool, totalBudget)
|
|
adjAngles := make([][]float64, len(pois))
|
|
selectedEdges := make([]selectedEdge, 0, totalBudget)
|
|
|
|
nodeCapacity := func(p *PointOfInterest, tier RoadTier) int {
|
|
base := max(1, p.TargetDegree)
|
|
switch tier {
|
|
case RoadTierArterial:
|
|
return max(base+1, 4)
|
|
case RoadTierCollector:
|
|
return base + 1
|
|
default:
|
|
return base
|
|
}
|
|
}
|
|
|
|
addEdge := func(pick edgeCandidate, tier RoadTier) {
|
|
key := edgeKey(pick.a, pick.b)
|
|
selected[key] = true
|
|
selectedEdges = append(selectedEdges, selectedEdge{edge: pick, tier: tier})
|
|
a := pois[pick.a]
|
|
b := pois[pick.b]
|
|
angAB := math.Atan2(float64(b.Y-a.Y), float64(b.X-a.X))
|
|
angBA := normalizeAngle(angAB + math.Pi)
|
|
a.Connections++
|
|
b.Connections++
|
|
adjAngles[pick.a] = append(adjAngles[pick.a], angAB)
|
|
adjAngles[pick.b] = append(adjAngles[pick.b], angBA)
|
|
}
|
|
|
|
canUseEdge := func(pick edgeCandidate, tier RoadTier) bool {
|
|
key := edgeKey(pick.a, pick.b)
|
|
if selected[key] {
|
|
return false
|
|
}
|
|
a := pois[pick.a]
|
|
b := pois[pick.b]
|
|
if a.Connections >= nodeCapacity(a, tier) || b.Connections >= nodeCapacity(b, tier) {
|
|
return false
|
|
}
|
|
angAB := math.Atan2(float64(b.Y-a.Y), float64(b.X-a.X))
|
|
angBA := normalizeAngle(angAB + math.Pi)
|
|
if !angleAllowed(adjAngles[pick.a], angAB, minAngle) || !angleAllowed(adjAngles[pick.b], angBA, minAngle) {
|
|
return false
|
|
}
|
|
return pick.score-degreePenalty(a, b) >= -0.4
|
|
}
|
|
|
|
arterialCount := max(2, min(len(pois), min(12, 2+roadTarget/14)))
|
|
arterialOrder := make([]int, len(pois))
|
|
for i := range arterialOrder {
|
|
arterialOrder[i] = i
|
|
}
|
|
sort.Slice(arterialOrder, func(i, j int) bool {
|
|
pi := pois[arterialOrder[i]]
|
|
pj := pois[arterialOrder[j]]
|
|
return pi.ArterialWeight > pj.ArterialWeight
|
|
})
|
|
arterialNodes := make(map[int]bool, arterialCount)
|
|
arterialMinSpacing := edgeDist * 0.50
|
|
arterialMinSpacing2 := arterialMinSpacing * arterialMinSpacing
|
|
for _, idx := range arterialOrder {
|
|
if len(arterialNodes) >= arterialCount {
|
|
break
|
|
}
|
|
keep := true
|
|
for chosen := range arterialNodes {
|
|
dx := float64(pois[chosen].X - pois[idx].X)
|
|
dy := float64(pois[chosen].Y - pois[idx].Y)
|
|
if dx*dx+dy*dy < arterialMinSpacing2 {
|
|
keep = false
|
|
break
|
|
}
|
|
}
|
|
if keep {
|
|
arterialNodes[idx] = true
|
|
}
|
|
}
|
|
for _, idx := range arterialOrder {
|
|
if len(arterialNodes) >= arterialCount {
|
|
break
|
|
}
|
|
arterialNodes[idx] = true
|
|
}
|
|
|
|
start := arterialOrder[0]
|
|
connected := make([]bool, len(pois))
|
|
connected[start] = true
|
|
connectedCount := 1
|
|
|
|
// 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
|
|
for idx, c := range candidates {
|
|
if !arterialNodes[c.a] || !arterialNodes[c.b] {
|
|
continue
|
|
}
|
|
if c.dist < edgeDist*0.25 {
|
|
continue
|
|
}
|
|
aConn := connected[c.a]
|
|
bConn := connected[c.b]
|
|
if aConn == bConn {
|
|
continue
|
|
}
|
|
if !canUseEdge(c, RoadTierArterial) {
|
|
continue
|
|
}
|
|
a := pois[c.a]
|
|
b := pois[c.b]
|
|
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
|
|
}
|
|
}
|
|
if bestIdx == -1 {
|
|
break
|
|
}
|
|
pick := candidates[bestIdx]
|
|
addEdge(pick, RoadTierArterial)
|
|
if !connected[pick.a] {
|
|
connected[pick.a] = true
|
|
connectedCount++
|
|
}
|
|
if !connected[pick.b] {
|
|
connected[pick.b] = true
|
|
connectedCount++
|
|
}
|
|
}
|
|
|
|
// Phase 2: connect remaining nodes with collector roads.
|
|
for connectedCount < len(pois) && len(selectedEdges) < collectorTarget {
|
|
bestIdx := -1
|
|
bestScore := -1.0
|
|
for idx, c := range candidates {
|
|
aConn := connected[c.a]
|
|
bConn := connected[c.b]
|
|
if aConn == bConn {
|
|
continue
|
|
}
|
|
if !canUseEdge(c, RoadTierCollector) {
|
|
continue
|
|
}
|
|
a := pois[c.a]
|
|
b := pois[c.b]
|
|
connectedBonus := 0.0
|
|
if arterialNodes[c.a] || arterialNodes[c.b] {
|
|
connectedBonus = 0.15
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if bestIdx == -1 {
|
|
break
|
|
}
|
|
pick := candidates[bestIdx]
|
|
addEdge(pick, RoadTierCollector)
|
|
if !connected[pick.a] {
|
|
connected[pick.a] = true
|
|
connectedCount++
|
|
}
|
|
if !connected[pick.b] {
|
|
connected[pick.b] = true
|
|
connectedCount++
|
|
}
|
|
}
|
|
|
|
// Phase 3: add shorter local links across all districts evenly.
|
|
for _, pick := range candidates {
|
|
if len(selectedEdges) >= totalBudget {
|
|
break
|
|
}
|
|
if pick.dist > edgeDist*0.65 {
|
|
continue
|
|
}
|
|
a := pois[pick.a]
|
|
b := pois[pick.b]
|
|
if !canUseEdge(pick, RoadTierLocal) {
|
|
continue
|
|
}
|
|
if a.Connections >= a.TargetDegree || b.Connections >= b.TargetDegree {
|
|
continue
|
|
}
|
|
if isSmallSettlement && (a.Connections > 1 || b.Connections > 1) {
|
|
continue
|
|
}
|
|
addEdge(pick, RoadTierLocal)
|
|
}
|
|
|
|
roads := make([]*Road, 0, len(selectedEdges))
|
|
avgDim := float64(width+height) / 2
|
|
for _, e := range selectedEdges {
|
|
a := pois[e.edge.a]
|
|
b := pois[e.edge.b]
|
|
path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout, e.tier)
|
|
imp := a.Connections + b.Connections + int(math.Round((a.ArterialWeight+b.ArterialWeight)*4))
|
|
roads = append(roads, &Road{Start: a, End: b, Points: path, Importance: imp, Tier: e.tier})
|
|
}
|
|
|
|
return roads
|
|
}
|
|
|
|
func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road {
|
|
if settings.RoadExits <= 0 || len(pois) == 0 {
|
|
return roads
|
|
}
|
|
|
|
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)
|
|
|
|
for i := 0; i < settings.RoadExits; i++ {
|
|
edgeNode, ok := sampleNonWaterEdgePOI(width, height, randSrc, waterMask, usedEdgePoints)
|
|
if !ok {
|
|
continue
|
|
}
|
|
edgePt := image.Point{X: edgeNode.X, Y: edgeNode.Y}
|
|
|
|
// 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
|
|
edgeNode.TargetDegree = 1
|
|
edgeNode.Connections = 1
|
|
importance := anchor.Connections + edgeNode.Connections + int(math.Round(anchor.ArterialWeight*3))
|
|
roads = append(roads, &Road{
|
|
Start: anchor,
|
|
End: edgeNode,
|
|
Points: path,
|
|
Importance: importance,
|
|
Tier: RoadTierArterial,
|
|
})
|
|
usedEdgePoints = append(usedEdgePoints, edgePt)
|
|
}
|
|
|
|
return roads
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *PixelMask, used []image.Point) (*PointOfInterest, bool) {
|
|
minSpacing := math.Min(float64(width), float64(height)) * 0.08
|
|
minSpacing2 := minSpacing * minSpacing
|
|
|
|
for tries := 0; tries < 120; tries++ {
|
|
p := sampleEdgePOI(width, height, randSrc)
|
|
pt := image.Point{X: p.X, Y: p.Y}
|
|
if waterMask != nil && waterMask.GetPoint(pt) {
|
|
continue
|
|
}
|
|
tooClose := false
|
|
for _, u := range used {
|
|
dx := float64(u.X - p.X)
|
|
dy := float64(u.Y - p.Y)
|
|
if dx*dx+dy*dy < minSpacing2 {
|
|
tooClose = true
|
|
break
|
|
}
|
|
}
|
|
if tooClose {
|
|
continue
|
|
}
|
|
return p, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func chooseExitAnchor(pois []*PointOfInterest, usedExits []image.Point, randSrc *rand.Rand) *PointOfInterest {
|
|
if len(pois) == 0 {
|
|
return nil
|
|
}
|
|
if len(usedExits) == 0 {
|
|
best := pois[0]
|
|
for i := 1; i < len(pois); i++ {
|
|
if pois[i].ArterialWeight > best.ArterialWeight {
|
|
best = pois[i]
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
target := usedExits[len(usedExits)-1]
|
|
best := pois[randSrc.Intn(len(pois))]
|
|
bestScore := -1.0
|
|
for _, p := range pois {
|
|
d := math.Hypot(float64(p.X-target.X), float64(p.Y-target.Y))
|
|
score := p.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1)
|
|
if score > bestScore {
|
|
bestScore = score
|
|
best = p
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func estimateRoadTarget(settings *Settings) int {
|
|
if settings.NumBuildings <= 0 {
|
|
return 0
|
|
}
|
|
if settings.NumBuildings < 10 {
|
|
return settings.NumBuildings
|
|
}
|
|
buildings := float64(max(settings.NumBuildings, 1))
|
|
roads := buildings / 5.0
|
|
if buildings > 80 {
|
|
roads += math.Pow(buildings-80.0, 0.70) * 0.30
|
|
}
|
|
if buildings > 500 {
|
|
roads += math.Pow((buildings-500.0)/2.2, 0.66) * 0.20
|
|
}
|
|
if buildings > 1800 {
|
|
roads *= 0.95
|
|
}
|
|
if buildings > 4000 {
|
|
roads *= 0.90
|
|
}
|
|
result := int(math.Round(roads))
|
|
if result < 1 {
|
|
result = 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
func edgeKey(a, b int) uint64 {
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
return (uint64(uint32(a)) << 32) | uint64(uint32(b))
|
|
}
|
|
|
|
func degreePenalty(a, b *PointOfInterest) float64 {
|
|
penalty := 0.0
|
|
if a.Connections >= a.TargetDegree {
|
|
penalty += 0.20 + float64(a.Connections-a.TargetDegree)*0.12
|
|
}
|
|
if b.Connections >= b.TargetDegree {
|
|
penalty += 0.20 + float64(b.Connections-b.TargetDegree)*0.12
|
|
}
|
|
return penalty
|
|
}
|
|
|
|
func angleAllowed(existing []float64, candidate, minAngle float64) bool {
|
|
if minAngle <= 0 || len(existing) == 0 {
|
|
return true
|
|
}
|
|
for _, ang := range existing {
|
|
d := math.Abs(normalizeAngle(candidate - ang))
|
|
if d > math.Pi {
|
|
d = 2*math.Pi - d
|
|
}
|
|
if d < minAngle {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizeAngle(a float64) float64 {
|
|
for a <= -math.Pi {
|
|
a += 2 * math.Pi
|
|
}
|
|
for a > math.Pi {
|
|
a -= 2 * math.Pi
|
|
}
|
|
return a
|
|
}
|
|
|
|
func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int, wallLayout *FortificationLayout) {
|
|
if len(roads) == 0 {
|
|
return
|
|
}
|
|
|
|
minWidth, maxWidth := getRoadWidthRangePixels(settings, width, height)
|
|
if maxWidth < minWidth {
|
|
minWidth, maxWidth = maxWidth, minWidth
|
|
}
|
|
|
|
maxImportance := 1
|
|
for _, road := range roads {
|
|
if road.Importance > maxImportance {
|
|
maxImportance = road.Importance
|
|
}
|
|
}
|
|
|
|
widths := make([]float64, len(roads))
|
|
startNode := make([]int, len(roads))
|
|
endNode := make([]int, len(roads))
|
|
nodeIndex := make(map[image.Point]int, len(roads)*2)
|
|
adj := make([][]int, 0, len(roads))
|
|
getNodeID := func(p *PointOfInterest) int {
|
|
pt := image.Point{X: p.X, Y: p.Y}
|
|
if id, ok := nodeIndex[pt]; ok {
|
|
return id
|
|
}
|
|
id := len(adj)
|
|
nodeIndex[pt] = id
|
|
adj = append(adj, nil)
|
|
return id
|
|
}
|
|
|
|
for i, r := range roads {
|
|
n := float64(r.Importance) / float64(maxImportance)
|
|
jitter := (randSrc.Float64() - 0.5) * 0.16
|
|
base := minWidth + (maxWidth-minWidth)*clamp01(n+jitter)
|
|
widths[i] = base
|
|
sid := getNodeID(r.Start)
|
|
eid := getNodeID(r.End)
|
|
startNode[i] = sid
|
|
endNode[i] = eid
|
|
adj[sid] = append(adj[sid], i)
|
|
adj[eid] = append(adj[eid], i)
|
|
}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
next := make([]float64, len(widths))
|
|
for ridx, w := range widths {
|
|
total := w
|
|
count := 1.0
|
|
for _, nid := range []int{startNode[ridx], endNode[ridx]} {
|
|
for _, nbr := range adj[nid] {
|
|
if nbr == ridx {
|
|
continue
|
|
}
|
|
total += widths[nbr]
|
|
count += 1
|
|
}
|
|
}
|
|
next[ridx] = w*0.55 + (total/count)*0.45
|
|
}
|
|
widths = next
|
|
}
|
|
|
|
for i, r := range roads {
|
|
w := clamp(widths[i], minWidth, maxWidth)
|
|
if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(r.Points, wallLayout)) > 0 {
|
|
minGateWidth := minWidth + 0.55*(maxWidth-minWidth)
|
|
if w < minGateWidth {
|
|
w = minGateWidth
|
|
}
|
|
}
|
|
r.Width = max(1, int(math.Round(w)))
|
|
}
|
|
}
|
|
|
|
// drawRoadToMasks draws a single road on the image including bridges.
|
|
func drawRoadToMasks(img *image.RGBA, points []PathPoint, roadColor, bridgeColor color.Color, width int, roadMask, bridgeMask *PixelMask) {
|
|
bridgeWidth := int(math.Ceil(float64(width) * 1.15))
|
|
if bridgeWidth < 1 {
|
|
bridgeWidth = 1
|
|
}
|
|
|
|
for i := 0; i < len(points)-1; {
|
|
p1 := points[i]
|
|
p2 := points[i+1]
|
|
isBridge := p1.IsBridge && p2.IsBridge
|
|
if !isBridge {
|
|
drawLineMasked(img, p1.Point.X, p1.Point.Y, p2.Point.X, p2.Point.Y, roadColor, width, roadMask)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
start := i
|
|
end := i + 1
|
|
for end < len(points)-1 && points[end].IsBridge && points[end+1].IsBridge {
|
|
end++
|
|
}
|
|
drawLineMasked(
|
|
img,
|
|
points[start].Point.X, points[start].Point.Y,
|
|
points[end].Point.X, points[end].Point.Y,
|
|
bridgeColor,
|
|
bridgeWidth,
|
|
bridgeMask,
|
|
)
|
|
i = end
|
|
}
|
|
}
|
|
|
|
func bresenhamRoad(path []image.Point) []image.Point {
|
|
if len(path) < 2 {
|
|
return path
|
|
}
|
|
|
|
fullPath := make([]image.Point, 0, len(path)*8)
|
|
for i := 0; i < len(path)-1; i++ {
|
|
p1, p2 := path[i], path[i+1]
|
|
dx, dy := p2.X-p1.X, p2.Y-p1.Y
|
|
absDx, absDy := int(math.Abs(float64(dx))), int(math.Abs(float64(dy)))
|
|
sx, sy := 1, 1
|
|
if dx < 0 {
|
|
sx = -1
|
|
}
|
|
if dy < 0 {
|
|
sy = -1
|
|
}
|
|
err := absDx - absDy
|
|
|
|
x, y := p1.X, p1.Y
|
|
for {
|
|
fullPath = append(fullPath, image.Point{X: x, Y: y})
|
|
if x == p2.X && y == p2.Y {
|
|
break
|
|
}
|
|
e2 := 2 * err
|
|
if e2 > -absDy {
|
|
err -= absDy
|
|
x += sx
|
|
}
|
|
if e2 < absDx {
|
|
err += absDx
|
|
y += sy
|
|
}
|
|
}
|
|
}
|
|
return fullPath
|
|
}
|
|
|
|
// 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}
|
|
isBridge := waterMask != nil && waterMask.GetPoint(p)
|
|
return []PathPoint{{Point: p, IsBridge: isBridge}}
|
|
}
|
|
|
|
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}})
|
|
return toPathPoints(points, waterMask)
|
|
}
|
|
|
|
perpX, perpY := -float64(dy)/dist, float64(dx)/dist
|
|
strength := math.Pow(curve, 1.1)
|
|
baseAmp := clamp(dist*(0.018+0.055*strength), 1.5, avgDim*0.06)
|
|
|
|
addControl := func(points []image.Point, t, lateral float64) []image.Point {
|
|
x := float64(start.X) + t*float64(dx)
|
|
y := float64(start.Y) + t*float64(dy)
|
|
x += lateral * perpX
|
|
y += lateral * perpY
|
|
return append(points, image.Point{X: int(math.Round(x)), Y: int(math.Round(y))})
|
|
}
|
|
|
|
polyline := []image.Point{{X: start.X, Y: start.Y}}
|
|
switch tier {
|
|
case RoadTierArterial:
|
|
lateral := baseAmp * (0.7 + randSrc.Float64()*0.35)
|
|
if randSrc.Float64() < 0.5 {
|
|
lateral = -lateral
|
|
}
|
|
polyline = addControl(polyline, 0.33, lateral*0.45)
|
|
polyline = addControl(polyline, 0.66, lateral)
|
|
case RoadTierCollector:
|
|
lateral := baseAmp * (0.9 + randSrc.Float64()*0.45)
|
|
if randSrc.Float64() < 0.5 {
|
|
lateral = -lateral
|
|
}
|
|
polyline = addControl(polyline, 0.35, lateral*0.65)
|
|
polyline = addControl(polyline, 0.72, lateral)
|
|
default:
|
|
lateralA := baseAmp * (0.65 + randSrc.Float64()*0.30)
|
|
lateralB := lateralA * (0.35 + randSrc.Float64()*0.25)
|
|
if randSrc.Float64() < 0.5 {
|
|
lateralA = -lateralA
|
|
}
|
|
if randSrc.Float64() < 0.8 {
|
|
lateralB = lateralA * (0.35 + randSrc.Float64()*0.20)
|
|
} else {
|
|
lateralB = -lateralB
|
|
}
|
|
polyline = addControl(polyline, 0.30, lateralA)
|
|
polyline = addControl(polyline, 0.68, lateralB)
|
|
}
|
|
polyline = append(polyline, image.Point{X: end.X, Y: end.Y})
|
|
|
|
points := bresenhamRoad(polyline)
|
|
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 {
|
|
pathPoints := make([]PathPoint, len(points))
|
|
for i, p := range points {
|
|
isBridge := false
|
|
if waterMask != nil {
|
|
isBridge = waterMask.GetPoint(p)
|
|
}
|
|
pathPoints[i] = PathPoint{Point: p, IsBridge: isBridge}
|
|
}
|
|
return pathPoints
|
|
}
|
|
|
|
func wallIDAtPoint(p image.Point, wallLayout *FortificationLayout) int {
|
|
if wallLayout == nil || wallLayout.Mask == nil {
|
|
return 0
|
|
}
|
|
if !wallLayout.Mask.InBounds(p.X, p.Y) {
|
|
return 0
|
|
}
|
|
if len(wallLayout.WallIDByPixel) != wallLayout.Mask.Width*wallLayout.Mask.Height {
|
|
return 0
|
|
}
|
|
return wallLayout.WallIDByPixel[p.Y*wallLayout.Mask.Width+p.X]
|
|
}
|
|
|
|
func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int {
|
|
if wallLayout == nil || wallLayout.Mask == nil || len(points) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int]bool)
|
|
out := make([]int, 0, 2)
|
|
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 && !seen[wid] {
|
|
seen[wid] = true
|
|
out = append(out, wid)
|
|
}
|
|
}
|
|
prevID = currID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, waterMask *PixelMask, randSrc *rand.Rand) []*Road {
|
|
if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil {
|
|
return roads
|
|
}
|
|
_ = waterMask
|
|
_ = randSrc
|
|
filtered := make([]*Road, 0, len(roads))
|
|
for _, road := range roads {
|
|
if pathRespectsWallPassages(road.Points, wallLayout.Mask, wallLayout.GateMask) {
|
|
filtered = append(filtered, road)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func pathRespectsWallPassages(points []PathPoint, exclusionMask, gateMask *PixelMask) bool {
|
|
if len(points) == 0 || exclusionMask == nil {
|
|
return true
|
|
}
|
|
for _, pp := range points {
|
|
x := pp.Point.X
|
|
y := pp.Point.Y
|
|
if !exclusionMask.InBounds(x, y) {
|
|
continue
|
|
}
|
|
if exclusionMask.GetXY(x, y) {
|
|
if gateMask != nil && gateMask.GetXY(x, y) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
start := image.Point{X: target.Start.X, Y: target.Start.Y}
|
|
end := image.Point{X: target.End.X, Y: target.End.Y}
|
|
|
|
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)
|
|
}
|
|
|
|
visited := make(map[image.Point]bool)
|
|
visited[start] = true
|
|
queue := []image.Point{start}
|
|
|
|
for len(queue) > 0 {
|
|
curr := queue[0]
|
|
queue = queue[1:]
|
|
if curr == end {
|
|
return false // End is still reachable without target road
|
|
}
|
|
for _, nbr := range adj[curr] {
|
|
if !visited[nbr] {
|
|
visited[nbr] = true
|
|
queue = append(queue, nbr)
|
|
}
|
|
}
|
|
}
|
|
|
|
return true // End is unreachable without target road -> essential bridge
|
|
}
|
|
|
|
func reduceRepeatedBridges(roads []*Road, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road {
|
|
if len(roads) == 0 || waterMask == nil {
|
|
return roads
|
|
}
|
|
|
|
regionByPixel := buildWaterRegionMap(waterMask)
|
|
if len(regionByPixel) == 0 {
|
|
return roads
|
|
}
|
|
|
|
const repeatBridgeFactor = 0.45
|
|
bodyBridgeCount := make(map[int]int)
|
|
filtered := make([]*Road, 0, len(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]
|
|
if c > 0 {
|
|
keepProb *= math.Pow(repeatBridgeFactor, float64(c))
|
|
}
|
|
}
|
|
if randSrc.Float64() <= keepProb {
|
|
filtered = append(filtered, road)
|
|
for _, body := range bridgedBodies {
|
|
bodyBridgeCount[body]++
|
|
}
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func buildWaterRegionMap(waterMask *PixelMask) []int {
|
|
if waterMask == nil || waterMask.Width <= 0 || waterMask.Height <= 0 {
|
|
return nil
|
|
}
|
|
total := waterMask.Width * waterMask.Height
|
|
region := make([]int, total)
|
|
nextRegionID := 1
|
|
|
|
queue := make([]int, 0, 1024)
|
|
for idx := 0; idx < total; idx++ {
|
|
if waterMask.Data[idx] == 0 || region[idx] != 0 {
|
|
continue
|
|
}
|
|
region[idx] = nextRegionID
|
|
queue = queue[:0]
|
|
queue = append(queue, idx)
|
|
|
|
for head := 0; head < len(queue); head++ {
|
|
cur := queue[head]
|
|
x := cur % waterMask.Width
|
|
y := cur / waterMask.Width
|
|
|
|
neighbors := [][2]int{
|
|
{x - 1, y}, {x + 1, y},
|
|
{x, y - 1}, {x, y + 1},
|
|
}
|
|
for _, n := range neighbors {
|
|
nx, ny := n[0], n[1]
|
|
if nx < 0 || ny < 0 || nx >= waterMask.Width || ny >= waterMask.Height {
|
|
continue
|
|
}
|
|
nidx := ny*waterMask.Width + nx
|
|
if waterMask.Data[nidx] == 0 || region[nidx] != 0 {
|
|
continue
|
|
}
|
|
region[nidx] = nextRegionID
|
|
queue = append(queue, nidx)
|
|
}
|
|
}
|
|
nextRegionID++
|
|
}
|
|
return region
|
|
}
|
|
|
|
func bridgedRegionIDs(points []PathPoint, regionByPixel []int, width, height int) []int {
|
|
if len(points) == 0 || len(regionByPixel) == 0 || width <= 0 || height <= 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int]bool)
|
|
out := make([]int, 0, 2)
|
|
for _, pp := range points {
|
|
if !pp.IsBridge {
|
|
continue
|
|
}
|
|
x, y := pp.Point.X, pp.Point.Y
|
|
if x < 0 || y < 0 || x >= width || y >= height {
|
|
continue
|
|
}
|
|
rid := regionByPixel[y*width+x]
|
|
if rid <= 0 || seen[rid] {
|
|
continue
|
|
}
|
|
seen[rid] = true
|
|
out = append(out, rid)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildWallExclusionMask(wallLayout *FortificationLayout, settings *Settings, width, height int) *PixelMask {
|
|
if wallLayout == nil || wallLayout.Mask == nil {
|
|
return NewPixelMask(width, height)
|
|
}
|
|
_, maxRoadPx := getRoadWidthRangePixels(settings, width, height)
|
|
margin := int(math.Ceil(maxRoadPx))
|
|
if margin < 1 {
|
|
margin = 1
|
|
}
|
|
out := NewPixelMask(width, height)
|
|
for y := 0; y < height; y++ {
|
|
for x := 0; x < width; x++ {
|
|
if !wallLayout.Mask.GetXY(x, y) {
|
|
continue
|
|
}
|
|
for dy := -margin; dy <= margin; dy++ {
|
|
for dx := -margin; dx <= margin; dx++ {
|
|
if dx*dx+dy*dy <= margin*margin {
|
|
out.SetXY(x+dx, y+dy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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))
|
|
if roadWidth < 1 {
|
|
roadWidth = 1
|
|
}
|
|
|
|
roads := make([]*Road, 0, len(wallLayout.Gates))
|
|
for _, gate := range wallLayout.Gates {
|
|
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
|
|
inner.Connections = 1
|
|
|
|
pts := bresenhamRoad([]image.Point{gate.OuterEnd, gate.InnerEnd})
|
|
path := toPathPoints(pts, waterMask)
|
|
|
|
roads = append(roads, &Road{
|
|
Start: outer,
|
|
End: inner,
|
|
Points: path,
|
|
Width: roadWidth,
|
|
Importance: 10,
|
|
Tier: RoadTierArterial,
|
|
})
|
|
}
|
|
return roads
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
poiSet := make(map[image.Point]*PointOfInterest)
|
|
for _, r := range allRoads {
|
|
if r.Start != nil {
|
|
poiSet[image.Point{X: r.Start.X, Y: r.Start.Y}] = r.Start
|
|
}
|
|
if r.End != nil {
|
|
poiSet[image.Point{X: r.End.X, Y: r.End.Y}] = r.End
|
|
}
|
|
}
|
|
for _, r := range gateRoads {
|
|
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 {
|
|
pois = append(pois, p)
|
|
}
|
|
|
|
connectors := make([]*Road, 0, len(gateRoads)*4)
|
|
_, maxRoadPx := getRoadWidthRangePixels(settings, width, height)
|
|
connW := int(math.Round(maxRoadPx))
|
|
if connW < 1 {
|
|
connW = 1
|
|
}
|
|
|
|
var wallMask, gateMask *PixelMask
|
|
if wallLayout != nil {
|
|
wallMask = wallLayout.Mask
|
|
gateMask = wallLayout.GateMask
|
|
}
|
|
|
|
for _, gr := range gateRoads {
|
|
for _, ep := range []*PointOfInterest{gr.Start, gr.End} {
|
|
if len(pois) == 0 {
|
|
break
|
|
}
|
|
epPt := image.Point{X: ep.X, Y: ep.Y}
|
|
|
|
type poiCandidate struct {
|
|
poi *PointOfInterest
|
|
dist float64
|
|
}
|
|
var candidates []poiCandidate
|
|
for _, p := range pois {
|
|
pPt := image.Point{X: p.X, Y: p.Y}
|
|
if !isSegmentWallSafe(epPt, pPt, wallMask, gateMask) {
|
|
continue
|
|
}
|
|
d := math.Hypot(float64(p.X-ep.X), float64(p.Y-ep.Y))
|
|
candidates = append(candidates, poiCandidate{poi: p, dist: d})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
|
|
spacing := int(math.Round(clamp((minBuildingSizePx+maxBuildingSizePx)*0.5, 8, 28)))
|
|
switch {
|
|
case settings.NumBuildings >= 2500:
|
|
spacing = int(math.Round(float64(spacing) * 0.72))
|
|
case settings.NumBuildings >= 1000:
|
|
spacing = int(math.Round(float64(spacing) * 0.80))
|
|
case settings.NumBuildings >= 300:
|
|
spacing = int(math.Round(float64(spacing) * 0.90))
|
|
}
|
|
if spacing < 6 {
|
|
spacing = 6
|
|
}
|
|
cellSize := max(4, spacing/2)
|
|
|
|
type anchorCell struct {
|
|
x int
|
|
y int
|
|
}
|
|
|
|
cells := make(map[anchorCell][]image.Point)
|
|
anchors := make([]image.Point, 0, len(roads)*4)
|
|
|
|
addAnchor := func(p image.Point) {
|
|
if p.X < 0 || p.Y < 0 || p.X >= width || p.Y >= height {
|
|
return
|
|
}
|
|
if waterMask != nil && waterMask.GetPoint(p) {
|
|
return
|
|
}
|
|
|
|
cx := p.X / cellSize
|
|
cy := p.Y / cellSize
|
|
for dy := -1; dy <= 1; dy++ {
|
|
for dx := -1; dx <= 1; dx++ {
|
|
key := anchorCell{x: cx + dx, y: cy + dy}
|
|
for _, existing := range cells[key] {
|
|
ddx := existing.X - p.X
|
|
ddy := existing.Y - p.Y
|
|
if ddx*ddx+ddy*ddy < spacing*spacing {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
key := anchorCell{x: cx, y: cy}
|
|
cells[key] = append(cells[key], p)
|
|
anchors = append(anchors, p)
|
|
}
|
|
|
|
nodeDegree := make(map[*PointOfInterest]int, len(roads)*2)
|
|
for _, road := range roads {
|
|
if road.Start != nil {
|
|
nodeDegree[road.Start]++
|
|
}
|
|
if road.End != nil {
|
|
nodeDegree[road.End]++
|
|
}
|
|
}
|
|
|
|
for _, road := range roads {
|
|
if road.Start != nil && (nodeDegree[road.Start] > 1 || !road.Start.IsExit) {
|
|
addAnchor(image.Point{X: road.Start.X, Y: road.Start.Y})
|
|
}
|
|
if road.End != nil && (nodeDegree[road.End] > 1 || !road.End.IsExit) {
|
|
addAnchor(image.Point{X: road.End.X, Y: road.End.Y})
|
|
}
|
|
|
|
step := spacing
|
|
if road.Tier == RoadTierArterial {
|
|
step = int(math.Round(float64(spacing) * 1.35))
|
|
}
|
|
if step < 6 {
|
|
step = 6
|
|
}
|
|
for i := step / 2; i < len(road.Points); i += step {
|
|
if road.Points[i].IsBridge {
|
|
continue
|
|
}
|
|
addAnchor(road.Points[i].Point)
|
|
}
|
|
}
|
|
|
|
return anchors
|
|
}
|