2026-02-04 14:19:18 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"math"
|
|
|
|
|
"math/rand"
|
|
|
|
|
"sort"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// PointOfInterest represents a location where roads may start, end, or intersect.
|
2026-02-04 14:19:18 -06:00
|
|
|
type PointOfInterest struct {
|
2026-02-26 11:05:38 -06:00
|
|
|
X, Y int
|
|
|
|
|
Connections int
|
|
|
|
|
TargetDegree int
|
|
|
|
|
IsExit bool
|
|
|
|
|
ArterialWeight float64
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// PathPoint represents a single point in a road's path with bridge flag.
|
2026-02-04 16:03:55 -06:00
|
|
|
type PathPoint struct {
|
|
|
|
|
Point image.Point
|
|
|
|
|
IsBridge bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// Road represents a connection between two points of interest.
|
2026-02-04 14:19:18 -06:00
|
|
|
type Road struct {
|
2026-02-04 16:03:55 -06:00
|
|
|
Start, End *PointOfInterest
|
|
|
|
|
Width int
|
|
|
|
|
Points []PathPoint
|
|
|
|
|
Importance int
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 12:49:01 -06:00
|
|
|
const (
|
|
|
|
|
minRoadWidthPercent = 0.1
|
|
|
|
|
maxRoadWidthPercent = 5.0
|
|
|
|
|
roadWidthPercentStep = 0.1
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// GenerateRoads creates roads on the map.
|
2026-02-26 13:03:48 -06:00
|
|
|
func GenerateRoads(
|
|
|
|
|
img *image.RGBA,
|
|
|
|
|
width,
|
|
|
|
|
height int,
|
|
|
|
|
settings *Settings,
|
|
|
|
|
waterMask *PixelMask,
|
|
|
|
|
seed int64,
|
2026-03-02 10:40:14 -06:00
|
|
|
) (*PixelMask, *PixelMask, *PixelMask, []image.Point) {
|
2026-03-02 12:50:57 -06:00
|
|
|
roadMask, bridgeMask, exitRoadMask, roadAnchors, _ := GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed)
|
|
|
|
|
return roadMask, bridgeMask, exitRoadMask, roadAnchors
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-02 12:50:57 -06:00
|
|
|
) (*PixelMask, *PixelMask, *PixelMask, []image.Point, []*Road) {
|
2026-02-26 13:03:48 -06:00
|
|
|
if img == nil {
|
|
|
|
|
img = image.NewRGBA(image.Rect(0, 0, width, height))
|
|
|
|
|
}
|
2026-02-04 15:15:47 -06:00
|
|
|
randSrc := rand.New(rand.NewSource(seed))
|
2026-02-04 14:19:18 -06:00
|
|
|
roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255}
|
2026-02-04 16:03:55 -06:00
|
|
|
bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255}
|
2026-02-04 14:19:18 -06:00
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
if len(pois) > 0 && wallLayout != nil && wallLayout.Mask != nil {
|
|
|
|
|
nudgePOIsOutsideWalls(pois, wallLayout.Mask, waterMask, settings, width, height, randSrc)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 09:31:42 -06:00
|
|
|
// Edge-case mode: no buildings.
|
2026-03-02 10:40:14 -06:00
|
|
|
if settings.NumBuildings == 0 && roadTarget == 0 && !edgeToEdgeOnly {
|
2026-02-27 09:31:42 -06:00
|
|
|
internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100)))
|
|
|
|
|
exitRoads := max(0, settings.RoadExits)
|
|
|
|
|
if internalRoads == 0 && exitRoads == 0 {
|
2026-03-02 12:50:57 -06:00
|
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
2026-02-27 09:31:42 -06:00
|
|
|
}
|
|
|
|
|
if internalRoads > 0 {
|
2026-03-02 10:40:14 -06:00
|
|
|
roadTarget = internalRoads
|
2026-02-27 09:31:42 -06:00
|
|
|
} else if settings.RoadDistribution <= 0 && exitRoads > 0 {
|
2026-03-02 10:40:14 -06:00
|
|
|
edgeToEdgeOnly = true
|
2026-02-27 09:31:42 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
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 {
|
2026-03-02 12:50:57 -06:00
|
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, wallLayout, roadTarget)
|
|
|
|
|
roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask, wallLayout)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(roads) == 0 {
|
2026-03-02 12:50:57 -06:00
|
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
2026-03-02 10:40:14 -06:00
|
|
|
roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc)
|
2026-02-26 11:05:38 -06:00
|
|
|
if len(roads) == 0 {
|
2026-03-02 12:50:57 -06:00
|
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
|
|
|
|
|
// Add gate roads after wall-crossing rules (so they are never filtered out).
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 09:31:42 -06:00
|
|
|
roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc)
|
|
|
|
|
if len(roads) == 0 {
|
2026-03-02 12:50:57 -06:00
|
|
|
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
|
2026-02-27 09:31:42 -06:00
|
|
|
}
|
2026-03-02 11:20:57 -06:00
|
|
|
roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height)
|
|
|
|
|
assignRoadWidths(roads, settings, randSrc, width, height, wallLayout)
|
2026-02-04 14:19:18 -06:00
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
roadMask := NewPixelMask(width, height)
|
|
|
|
|
bridgeMask := NewPixelMask(width, height)
|
|
|
|
|
exitRoadMask := NewPixelMask(width, height)
|
2026-02-04 14:19:18 -06:00
|
|
|
for _, road := range roads {
|
2026-02-26 13:03:48 -06:00
|
|
|
drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, roadMask, bridgeMask)
|
2026-02-26 11:16:39 -06:00
|
|
|
if road.Start.IsExit || road.End.IsExit {
|
2026-02-26 13:03:48 -06:00
|
|
|
drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask)
|
2026-02-26 11:16:39 -06:00
|
|
|
}
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
roadAnchors := roadMask.ToPoints()
|
2026-03-02 12:50:57 -06:00
|
|
|
return roadMask, bridgeMask, exitRoadMask, roadAnchors, roads
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-02 11:20:57 -06:00
|
|
|
centerX := float64(width-1) * 0.5
|
|
|
|
|
centerY := float64(height-1) * 0.5
|
|
|
|
|
|
|
|
|
|
for _, p := range pois {
|
2026-03-11 12:43:41 -05:00
|
|
|
if p == nil {
|
2026-03-02 11:20:57 -06:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
if !exclusion.GetXY(p.X, p.Y) {
|
|
|
|
|
continue
|
2026-03-02 11:20:57 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-11 12:43:41 -05:00
|
|
|
maxSteps := exclusion.Width + exclusion.Height
|
|
|
|
|
for step := 1; step <= maxSteps; step++ {
|
2026-03-02 11:20:57 -06:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
|
2026-03-02 11:20:57 -06:00
|
|
|
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)
|
2026-03-11 12:43:41 -05:00
|
|
|
for step := 1; step <= exclusion.Width+exclusion.Height; step++ {
|
2026-03-02 11:20:57 -06:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
|
2026-03-02 11:20:57 -06:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
p.X = nx
|
|
|
|
|
p.Y = ny
|
|
|
|
|
moved = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if moved {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road {
|
2026-02-27 09:31:42 -06:00
|
|
|
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
|
2026-03-02 10:40:14 -06:00
|
|
|
path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout)
|
2026-02-27 09:31:42 -06:00
|
|
|
roads = append(roads, &Road{
|
|
|
|
|
Start: start,
|
|
|
|
|
End: end,
|
|
|
|
|
Points: path,
|
|
|
|
|
Importance: 1,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
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)}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, randSrc *rand.Rand, roadTarget int) []*PointOfInterest {
|
2026-02-26 11:05:38 -06:00
|
|
|
distribution := clamp01(settings.RoadDistribution / 100.0)
|
2026-02-27 09:31:42 -06:00
|
|
|
targetCoverage := 0.10 + 0.90*distribution
|
2026-02-26 12:49:01 -06:00
|
|
|
minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
|
|
|
|
|
avgBuildingSize := (minBuildingSizePx + maxBuildingSizePx) / 2.0
|
2026-02-26 11:05:38 -06:00
|
|
|
if avgBuildingSize < 1 {
|
|
|
|
|
avgBuildingSize = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
coreNodes := estimateCoreNodeCount(width, height, distribution, avgBuildingSize, settings.NumBuildings)
|
|
|
|
|
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
|
2026-02-27 09:31:42 -06:00
|
|
|
effectiveRadius := math.Sqrt(targetCoverage) * (math.Min(float64(width), float64(height)) * 0.5)
|
|
|
|
|
warpPhaseA := randSrc.Float64() * 2 * math.Pi
|
|
|
|
|
warpPhaseB := randSrc.Float64() * 2 * math.Pi
|
2026-02-26 11:05:38 -06:00
|
|
|
|
|
|
|
|
pois := make([]*PointOfInterest, 0, coreNodes)
|
|
|
|
|
for len(pois) < coreNodes {
|
2026-02-27 09:31:42 -06:00
|
|
|
x, y, ok := sampleCorePOI(width, height, distribution, targetCoverage, warpPhaseA, warpPhaseB, randSrc)
|
2026-02-26 11:05:38 -06:00
|
|
|
if !ok {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
p := image.Point{X: x, Y: y}
|
|
|
|
|
// Keep larger spacing between intersections so buildings have room.
|
2026-02-26 13:03:48 -06:00
|
|
|
if waterMask.GetPoint(p) || isTooCloseToExisting(pois, x, y, avgBuildingSize*1.1) {
|
2026-02-26 11:05:38 -06:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
pois = append(pois, &PointOfInterest{X: x, Y: y, TargetDegree: sampleTargetDegree(randSrc)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(pois) == 0 {
|
2026-02-04 14:19:18 -06:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
for _, poi := range pois {
|
|
|
|
|
centerDist := math.Hypot(float64(poi.X-centerX), float64(poi.Y-centerY))
|
2026-02-27 09:31:42 -06:00
|
|
|
centerFactor := 1.0 - clamp01(centerDist/(effectiveRadius+1))
|
2026-02-26 11:05:38 -06:00
|
|
|
sizeFactor := clamp01((avgBuildingSize - 4.0) / 40.0)
|
|
|
|
|
poi.ArterialWeight = clamp01(0.60*centerFactor + 0.40*sizeFactor)
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pois
|
|
|
|
|
}
|
2026-02-05 17:50:55 -06:00
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
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)
|
|
|
|
|
if spacing < 6 {
|
|
|
|
|
spacing = 6
|
|
|
|
|
}
|
|
|
|
|
byArea := int((targetArea / (spacing * spacing)) * 0.18)
|
|
|
|
|
buildingPressure := int(math.Sqrt(float64(max(numBuildings, 1))) * (0.7 + distribution*0.9))
|
|
|
|
|
nodes := byArea + buildingPressure
|
|
|
|
|
if nodes < 8 {
|
|
|
|
|
nodes = 8
|
|
|
|
|
}
|
|
|
|
|
maxNodes := int(clamp(float64(width*height)/50000.0, 80, 550))
|
|
|
|
|
if nodes > maxNodes {
|
|
|
|
|
nodes = maxNodes
|
|
|
|
|
}
|
|
|
|
|
return nodes
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 09:31:42 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-02-26 11:05:38 -06:00
|
|
|
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.03:
|
|
|
|
|
return 1
|
|
|
|
|
case r < 0.17:
|
|
|
|
|
return 2
|
|
|
|
|
case r < 0.40:
|
|
|
|
|
return 3
|
|
|
|
|
case r < 0.85:
|
|
|
|
|
return 4
|
|
|
|
|
default:
|
|
|
|
|
return 5
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, roadTarget int) []*Road {
|
2026-02-26 11:05:38 -06:00
|
|
|
minAngle := settings.MinRoadAngle * math.Pi / 180.0
|
|
|
|
|
if minAngle < 0 {
|
|
|
|
|
minAngle = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
edgeDist := math.Min(float64(width), float64(height)) * 0.30
|
|
|
|
|
if roadTarget < len(pois)-1 {
|
|
|
|
|
roadTarget = len(pois) - 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type edgeCandidate struct {
|
|
|
|
|
a, b int
|
|
|
|
|
score 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
candidates = append(candidates, edgeCandidate{a: i, b: j, score: score})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(candidates) == 0 {
|
2026-02-04 14:19:18 -06:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
|
|
|
return candidates[i].score > candidates[j].score
|
|
|
|
|
})
|
2026-02-04 16:20:31 -06:00
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
selected := make(map[uint64]bool, roadTarget)
|
|
|
|
|
adjAngles := make([][]float64, len(pois))
|
|
|
|
|
selectedEdges := make([]edgeCandidate, 0, roadTarget)
|
2026-02-04 14:19:18 -06:00
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
addEdge := func(pick edgeCandidate) {
|
|
|
|
|
key := edgeKey(pick.a, pick.b)
|
|
|
|
|
selected[key] = true
|
|
|
|
|
selectedEdges = append(selectedEdges, pick)
|
|
|
|
|
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)
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
canUseEdge := func(pick edgeCandidate) bool {
|
|
|
|
|
key := edgeKey(pick.a, pick.b)
|
|
|
|
|
if selected[key] {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
a := pois[pick.a]
|
|
|
|
|
b := pois[pick.b]
|
|
|
|
|
if a.Connections >= max(1, a.TargetDegree+1) || b.Connections >= max(1, b.TargetDegree+1) {
|
|
|
|
|
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
|
2026-02-04 14:50:36 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// Phase 1: enforce one connected backbone.
|
|
|
|
|
start := 0
|
|
|
|
|
bestWeight := pois[0].ArterialWeight
|
|
|
|
|
for i := 1; i < len(pois); i++ {
|
|
|
|
|
if pois[i].ArterialWeight > bestWeight {
|
|
|
|
|
start = i
|
|
|
|
|
bestWeight = pois[i].ArterialWeight
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
connected := make([]bool, len(pois))
|
|
|
|
|
connected[start] = true
|
|
|
|
|
connectedCount := 1
|
2026-02-04 14:19:18 -06:00
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
for connectedCount < len(pois) && len(selectedEdges) < roadTarget {
|
|
|
|
|
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) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if c.score > bestScore {
|
|
|
|
|
bestScore = c.score
|
|
|
|
|
bestIdx = idx
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 11:05:38 -06:00
|
|
|
if bestIdx == -1 {
|
2026-02-04 14:50:36 -06:00
|
|
|
break
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
2026-02-26 11:05:38 -06:00
|
|
|
pick := candidates[bestIdx]
|
|
|
|
|
addEdge(pick)
|
|
|
|
|
if !connected[pick.a] {
|
|
|
|
|
connected[pick.a] = true
|
|
|
|
|
connectedCount++
|
|
|
|
|
}
|
|
|
|
|
if !connected[pick.b] {
|
|
|
|
|
connected[pick.b] = true
|
|
|
|
|
connectedCount++
|
|
|
|
|
}
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// Phase 2: add extra links up to the target.
|
|
|
|
|
for _, pick := range candidates {
|
|
|
|
|
if len(selectedEdges) >= roadTarget {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if !canUseEdge(pick) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
addEdge(pick)
|
2026-02-04 16:20:31 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
roads := make([]*Road, 0, len(selectedEdges))
|
|
|
|
|
avgDim := float64(width+height) / 2
|
|
|
|
|
for _, e := range selectedEdges {
|
|
|
|
|
a := pois[e.a]
|
|
|
|
|
b := pois[e.b]
|
2026-03-02 10:40:14 -06:00
|
|
|
path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout)
|
2026-02-26 11:05:38 -06:00
|
|
|
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})
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road {
|
2026-02-26 11:05:38 -06:00
|
|
|
if settings.RoadExits <= 0 || len(pois) == 0 {
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exitRoadsAdded := 0
|
|
|
|
|
avgDim := float64(width+height) / 2
|
|
|
|
|
usedEdgePoints := make([]image.Point, 0, settings.RoadExits)
|
|
|
|
|
|
|
|
|
|
for i := 0; i < settings.RoadExits; i++ {
|
2026-02-26 13:03:48 -06:00
|
|
|
edgeNode, ok := sampleNonWaterEdgePOI(width, height, randSrc, waterMask, usedEdgePoints)
|
2026-02-26 11:05:38 -06:00
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
anchor := chooseExitAnchor(pois, usedEdgePoints, randSrc)
|
|
|
|
|
if anchor == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout)
|
|
|
|
|
if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(path, wallLayout)) == 0 {
|
|
|
|
|
bestScore := -1.0
|
|
|
|
|
bestAnchor := anchor
|
|
|
|
|
bestPath := path
|
|
|
|
|
for _, cand := range pois {
|
|
|
|
|
testPath := calculateRoadPath(cand, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout)
|
|
|
|
|
if len(crossedWallIDs(testPath, wallLayout)) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
d := math.Hypot(float64(cand.X-edgeNode.X), float64(cand.Y-edgeNode.Y))
|
|
|
|
|
score := cand.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1)
|
|
|
|
|
if score > bestScore {
|
|
|
|
|
bestScore = score
|
|
|
|
|
bestAnchor = cand
|
|
|
|
|
bestPath = testPath
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
anchor = bestAnchor
|
|
|
|
|
path = bestPath
|
|
|
|
|
}
|
2026-03-02 11:20:57 -06:00
|
|
|
if wallLayout != nil && wallLayout.Mask != nil && len(wallLayout.Coverages) > 0 {
|
|
|
|
|
// Exit roads always use the gate-cheat when walls exist so they are always placeable.
|
|
|
|
|
if forced, ok := forcePathThroughWallGate(anchor, edgeNode, wallLayout, waterMask); ok {
|
|
|
|
|
path = forced
|
|
|
|
|
}
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
usedEdgePoints = append(usedEdgePoints, image.Point{X: edgeNode.X, Y: edgeNode.Y})
|
|
|
|
|
exitRoadsAdded++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = exitRoadsAdded
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *PixelMask, used []image.Point) (*PointOfInterest, bool) {
|
2026-02-26 11:05:38 -06:00
|
|
|
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}
|
2026-02-26 13:03:48 -06:00
|
|
|
if waterMask.GetPoint(pt) {
|
2026-02-26 11:05:38 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 09:31:42 -06:00
|
|
|
func estimateRoadTarget(settings *Settings) int {
|
|
|
|
|
if settings.NumBuildings <= 0 {
|
|
|
|
|
return 0
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
2026-02-27 09:31:42 -06:00
|
|
|
// Keep tiny settlements proportional: 1 building -> 1 road, etc.
|
|
|
|
|
if settings.NumBuildings < 10 {
|
|
|
|
|
return settings.NumBuildings
|
|
|
|
|
}
|
|
|
|
|
divisor := float64(max(settings.BuildingsPerRoad, 1))
|
|
|
|
|
roads := int(math.Round(float64(max(settings.NumBuildings, 1)) / divisor))
|
|
|
|
|
if roads < 1 {
|
|
|
|
|
roads = 1
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int, wallLayout *FortificationLayout) {
|
2026-02-04 14:19:18 -06:00
|
|
|
if len(roads) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 12:49:01 -06:00
|
|
|
minWidth, maxWidth := getRoadWidthRangePixels(settings, width, height)
|
2026-02-26 11:05:38 -06:00
|
|
|
if maxWidth < minWidth {
|
|
|
|
|
minWidth, maxWidth = maxWidth, minWidth
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
maxImportance := 1
|
|
|
|
|
for _, road := range roads {
|
|
|
|
|
if road.Importance > maxImportance {
|
|
|
|
|
maxImportance = road.Importance
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:39:39 -06:00
|
|
|
widths := make([]float64, len(roads))
|
|
|
|
|
startNode := make([]int, len(roads))
|
|
|
|
|
endNode := make([]int, len(roads))
|
|
|
|
|
nodeIndex := make(map[*PointOfInterest]int, len(roads)*2)
|
|
|
|
|
adj := make([][]int, 0, len(roads))
|
|
|
|
|
getNodeID := func(p *PointOfInterest) int {
|
|
|
|
|
if id, ok := nodeIndex[p]; ok {
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
id := len(adj)
|
|
|
|
|
nodeIndex[p] = id
|
|
|
|
|
adj = append(adj, nil)
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i, r := range roads {
|
2026-02-26 11:05:38 -06:00
|
|
|
n := float64(r.Importance) / float64(maxImportance)
|
|
|
|
|
jitter := (randSrc.Float64() - 0.5) * 0.16
|
|
|
|
|
base := minWidth + (maxWidth-minWidth)*clamp01(n+jitter)
|
2026-02-26 13:39:39 -06:00
|
|
|
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)
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < 2; i++ {
|
2026-02-26 13:39:39 -06:00
|
|
|
next := make([]float64, len(widths))
|
|
|
|
|
for ridx, w := range widths {
|
2026-02-26 11:05:38 -06:00
|
|
|
total := w
|
|
|
|
|
count := 1.0
|
2026-02-26 13:39:39 -06:00
|
|
|
for _, nid := range []int{startNode[ridx], endNode[ridx]} {
|
|
|
|
|
for _, nbr := range adj[nid] {
|
|
|
|
|
if nbr == ridx {
|
2026-02-26 11:05:38 -06:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
total += widths[nbr]
|
|
|
|
|
count += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 13:39:39 -06:00
|
|
|
next[ridx] = w*0.55 + (total/count)*0.45
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
|
|
|
|
widths = next
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:39:39 -06:00
|
|
|
for i, r := range roads {
|
|
|
|
|
w := clamp(widths[i], minWidth, maxWidth)
|
2026-03-02 11:20:57 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 11:05:38 -06:00
|
|
|
r.Width = max(1, int(math.Round(w)))
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
// 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) {
|
2026-02-26 11:05:38 -06:00
|
|
|
bridgeWidth := int(math.Ceil(float64(width) * 1.15))
|
|
|
|
|
if bridgeWidth < 1 {
|
|
|
|
|
bridgeWidth = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < len(points)-1; {
|
2026-02-04 16:03:55 -06:00
|
|
|
p1 := points[i]
|
|
|
|
|
p2 := points[i+1]
|
2026-02-04 16:37:50 -06:00
|
|
|
isBridge := p1.IsBridge && p2.IsBridge
|
2026-02-26 11:05:38 -06:00
|
|
|
if !isBridge {
|
2026-02-26 13:03:48 -06:00
|
|
|
drawLineMasked(img, p1.Point.X, p1.Point.Y, p2.Point.X, p2.Point.Y, roadColor, width, roadMask)
|
2026-02-26 11:05:38 -06:00
|
|
|
i++
|
|
|
|
|
continue
|
2026-02-04 16:37:50 -06:00
|
|
|
}
|
2026-02-26 11:05:38 -06:00
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
end++
|
|
|
|
|
}
|
2026-02-26 13:03:48 -06:00
|
|
|
drawLineMasked(
|
2026-02-26 11:05:38 -06:00
|
|
|
img,
|
|
|
|
|
points[start].Point.X, points[start].Point.Y,
|
|
|
|
|
points[end].Point.X, points[end].Point.Y,
|
|
|
|
|
bridgeColor,
|
|
|
|
|
bridgeWidth,
|
2026-02-26 13:03:48 -06:00
|
|
|
bridgeMask,
|
2026-02-26 11:05:38 -06:00
|
|
|
)
|
|
|
|
|
i = end
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
2026-02-04 15:39:09 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func bresenhamRoad(path []image.Point) []image.Point {
|
|
|
|
|
if len(path) < 2 {
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
fullPath := make([]image.Point, 0, len(path)*8)
|
2026-02-04 15:39:09 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// calculateRoadPath computes the path for a road including curves and bridges.
|
2026-03-02 10:40:14 -06:00
|
|
|
func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []PathPoint {
|
2026-02-04 15:39:09 -06:00
|
|
|
dx := end.X - start.X
|
|
|
|
|
dy := end.Y - start.Y
|
2026-02-26 11:05:38 -06:00
|
|
|
dist := math.Hypot(float64(dx), float64(dy))
|
2026-02-04 16:03:55 -06:00
|
|
|
|
2026-02-04 15:39:09 -06:00
|
|
|
if dist == 0 {
|
2026-02-26 11:05:38 -06:00
|
|
|
p := image.Point{X: start.X, Y: start.Y}
|
2026-02-26 13:03:48 -06:00
|
|
|
return []PathPoint{{Point: p, IsBridge: waterMask.GetPoint(p)}}
|
2026-02-04 15:39:09 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
curve := clamp(curvyness, 0, 1)
|
|
|
|
|
if curve <= 0 {
|
2026-02-04 16:03:55 -06:00
|
|
|
points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}})
|
2026-03-02 10:40:14 -06:00
|
|
|
return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask)
|
2026-02-04 15:39:09 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// Non-linear scaling: low values stay fairly straight, high values become very winding.
|
|
|
|
|
strength := math.Pow(curve, 1.35)
|
|
|
|
|
if strength < 0.001 {
|
|
|
|
|
points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}})
|
2026-03-02 10:40:14 -06:00
|
|
|
return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask)
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
baseControls := int(math.Max(12, dist/(22.0-14.0*strength)))
|
|
|
|
|
controlPoints := make([]image.Point, baseControls+1)
|
|
|
|
|
perpX, perpY := -float64(dy)/dist, float64(dx)/dist
|
|
|
|
|
lengthScale := clamp(dist/(avgDim*0.55), 0.45, 2.4)
|
|
|
|
|
|
|
|
|
|
ampBase := clamp(dist*(0.01+0.13*strength*strength), 2, avgDim*0.16)
|
|
|
|
|
amp1 := ampBase * (0.9 + randSrc.Float64()*0.25)
|
|
|
|
|
amp2 := ampBase * (0.45 + randSrc.Float64()*0.20)
|
|
|
|
|
amp3 := ampBase * (0.20 + randSrc.Float64()*0.15)
|
|
|
|
|
|
|
|
|
|
w1 := clamp(dist*(1.10-0.70*strength), 30, avgDim*0.95)
|
|
|
|
|
w2 := clamp(dist*(0.55-0.30*strength), 16, avgDim*0.55)
|
|
|
|
|
w3 := clamp(dist*(0.26-0.12*strength), 8, avgDim*0.30)
|
|
|
|
|
|
2026-02-04 15:39:09 -06:00
|
|
|
type wave struct {
|
2026-02-26 11:05:38 -06:00
|
|
|
amplitude float64
|
|
|
|
|
wavelength float64
|
|
|
|
|
phase float64
|
2026-02-04 15:39:09 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
waves := []wave{
|
|
|
|
|
{
|
|
|
|
|
amplitude: amp1,
|
|
|
|
|
wavelength: w1,
|
|
|
|
|
phase: randSrc.Float64() * 2 * math.Pi,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
amplitude: amp2,
|
|
|
|
|
wavelength: w2,
|
|
|
|
|
phase: randSrc.Float64() * 2 * math.Pi,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
amplitude: amp3,
|
|
|
|
|
wavelength: w3,
|
|
|
|
|
phase: randSrc.Float64() * 2 * math.Pi,
|
|
|
|
|
},
|
2026-02-04 15:50:53 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
for i := 0; i <= baseControls; i++ {
|
|
|
|
|
t := float64(i) / float64(baseControls)
|
2026-02-04 15:39:09 -06:00
|
|
|
x := float64(start.X) + t*float64(dx)
|
|
|
|
|
y := float64(start.Y) + t*float64(dy)
|
|
|
|
|
|
2026-02-26 11:05:38 -06:00
|
|
|
// Keep endpoints fixed while allowing large mid-segment deflection.
|
|
|
|
|
envelope := math.Pow(math.Sin(t*math.Pi), 0.78)
|
|
|
|
|
offset := 0.0
|
|
|
|
|
for _, w := range waves {
|
|
|
|
|
angle := (dist*t/w.wavelength)*2*math.Pi + w.phase
|
|
|
|
|
offset += math.Sin(angle) * w.amplitude
|
2026-02-04 15:39:09 -06:00
|
|
|
}
|
2026-02-26 11:05:38 -06:00
|
|
|
offset *= envelope * lengthScale
|
|
|
|
|
|
|
|
|
|
x += offset * perpX
|
|
|
|
|
y += offset * perpY
|
2026-02-04 15:39:09 -06:00
|
|
|
controlPoints[i] = image.Point{X: int(math.Round(x)), Y: int(math.Round(y))}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 16:03:55 -06:00
|
|
|
points := bresenhamRoad(controlPoints)
|
2026-03-02 10:40:14 -06:00
|
|
|
return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask)
|
2026-02-26 11:05:38 -06:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
func toPathPoints(points []image.Point, waterMask *PixelMask) []PathPoint {
|
2026-02-04 16:03:55 -06:00
|
|
|
pathPoints := make([]PathPoint, len(points))
|
|
|
|
|
for i, p := range points {
|
2026-03-02 10:40:14 -06:00
|
|
|
isBridge := false
|
|
|
|
|
if waterMask != nil {
|
|
|
|
|
isBridge = waterMask.GetPoint(p)
|
|
|
|
|
}
|
|
|
|
|
pathPoints[i] = PathPoint{Point: p, IsBridge: isBridge}
|
2026-02-04 16:03:55 -06:00
|
|
|
}
|
|
|
|
|
return pathPoints
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
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 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
|
|
|
|
|
}
|
2026-03-02 11:20:57 -06:00
|
|
|
line := enforcePerpendicularWallCrossing(points, start, i, j, end, wallLayout)
|
2026-03-02 10:40:14 -06:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
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 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 || len(wallLayout.Coverages) == 0 {
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Straighten each wall crossing segment first.
|
|
|
|
|
for _, road := range roads {
|
|
|
|
|
road.Points = straightenPathAcrossWalls(road.Points, wallLayout, waterMask)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type roadInfo struct {
|
|
|
|
|
road *Road
|
|
|
|
|
ids []int
|
|
|
|
|
}
|
|
|
|
|
infos := make([]roadInfo, 0, len(roads))
|
|
|
|
|
for _, road := range roads {
|
|
|
|
|
infos = append(infos, roadInfo{road: road, ids: crossedWallIDs(road.Points, wallLayout)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const repeatWallFactor = 0.55
|
|
|
|
|
wallCrossCount := make(map[int]int)
|
|
|
|
|
requiredWalls := make(map[int]bool)
|
|
|
|
|
for i, cov := range wallLayout.Coverages {
|
|
|
|
|
if cov < 95 {
|
|
|
|
|
requiredWalls[i+1] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
keep := make([]bool, len(infos))
|
|
|
|
|
for i, info := range infos {
|
|
|
|
|
if len(info.ids) == 0 {
|
|
|
|
|
keep[i] = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if info.road.Start.IsExit || info.road.End.IsExit {
|
|
|
|
|
keep[i] = true
|
|
|
|
|
for _, wid := range info.ids {
|
|
|
|
|
wallCrossCount[wid]++
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-02 11:20:57 -06:00
|
|
|
if crossesSameWallMultipleTimes(info.road.Points, wallLayout) {
|
|
|
|
|
keep[i] = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-02 10:40:14 -06:00
|
|
|
|
|
|
|
|
keepProb := 1.0
|
|
|
|
|
for _, wid := range info.ids {
|
|
|
|
|
c := wallCrossCount[wid]
|
|
|
|
|
if c > 0 {
|
|
|
|
|
keepProb *= math.Pow(repeatWallFactor, float64(c))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if randSrc.Float64() <= keepProb {
|
|
|
|
|
keep[i] = true
|
|
|
|
|
for _, wid := range info.ids {
|
|
|
|
|
wallCrossCount[wid]++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure at least one crossing on each wall unless its configured coverage is >= 95%.
|
|
|
|
|
for wallID := range requiredWalls {
|
|
|
|
|
if wallCrossCount[wallID] > 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for i, info := range infos {
|
|
|
|
|
if keep[i] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !containsWallID(info.ids, wallID) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
keep[i] = true
|
|
|
|
|
for _, wid := range info.ids {
|
|
|
|
|
wallCrossCount[wid]++
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filtered := make([]*Road, 0, len(roads))
|
|
|
|
|
for i, info := range infos {
|
|
|
|
|
if keep[i] {
|
|
|
|
|
filtered = append(filtered, info.road)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return filtered
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 11:20:57 -06:00
|
|
|
func crossesSameWallMultipleTimes(points []PathPoint, wallLayout *FortificationLayout) bool {
|
|
|
|
|
if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = 32
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
adj := make([][]int, 0, len(roads)*2)
|
|
|
|
|
ensureAdj := func(n int) {
|
|
|
|
|
for len(adj) <= n {
|
|
|
|
|
adj = append(adj, nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
roads = append(roads, &Road{
|
|
|
|
|
Start: a,
|
|
|
|
|
End: b,
|
|
|
|
|
Points: path,
|
|
|
|
|
Importance: a.Connections + b.Connections + 2,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return roads
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:03:48 -06:00
|
|
|
// 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) {
|
2026-02-04 14:19:18 -06:00
|
|
|
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)
|
2026-02-26 13:03:48 -06:00
|
|
|
if mask != nil {
|
|
|
|
|
mask.SetXY(px, py)
|
|
|
|
|
}
|
2026-02-04 14:19:18 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if x0 == x1 && y0 == y1 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
e2 := 2 * err
|
|
|
|
|
if e2 >= dy {
|
|
|
|
|
err += dy
|
|
|
|
|
x0 += sx
|
|
|
|
|
}
|
|
|
|
|
if e2 <= dx {
|
|
|
|
|
err += dx
|
|
|
|
|
y0 += sy
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 09:31:42 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
bridgedBodies := bridgedRegionIDs(road.Points, regionByPixel, width, height)
|
|
|
|
|
if len(bridgedBodies) == 0 {
|
|
|
|
|
filtered = append(filtered, road)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 12:43:41 -05:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
_, 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)))
|
|
|
|
|
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
|
|
|
|
|
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, // high importance so gate roads get wide treatment
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
for _, r := range allRoads {
|
|
|
|
|
if r.Start != nil {
|
|
|
|
|
poiSet[r.Start] = true
|
|
|
|
|
}
|
|
|
|
|
if r.End != nil {
|
|
|
|
|
poiSet[r.End] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Remove gate road endpoints from the non-gate set.
|
|
|
|
|
for _, r := range gateRoads {
|
|
|
|
|
delete(poiSet, r.Start)
|
|
|
|
|
delete(poiSet, r.End)
|
|
|
|
|
}
|
|
|
|
|
pois := make([]*PointOfInterest, 0, len(poiSet))
|
|
|
|
|
for p := range poiSet {
|
|
|
|
|
pois = append(pois, p)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connectors := make([]*Road, 0, len(gateRoads)*2)
|
|
|
|
|
_, 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
// Find nearest POI reachable without crossing any wall.
|
|
|
|
|
var best *PointOfInterest
|
|
|
|
|
bestD2 := math.MaxFloat64
|
|
|
|
|
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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return append(allRoads, connectors...)
|
|
|
|
|
}
|