Made Cities/Towns Not perfectly circular.

This commit is contained in:
Grimsace
2026-02-27 10:05:11 -06:00
parent 2037859574
commit 9b3b1811f4
5 changed files with 298 additions and 32 deletions
+1
View File
@@ -33,6 +33,7 @@ A remake in go of a program that generates maps of rpg like towns. Inspied by Ro
| **River Curvyness** | How curvy the rivers are. At 100%, rivers will meander significantly. At 0%, they will be perfectly straight lines. | `0%` (straight) to `100%` (very curvy) |
| **Min Road Width** | The minimum width of a generated road as a percentage of the average image dimension (`(width + height) / 2`). | `0.1%` to `5%` in `0.1%` steps |
| **Max Road Width** | The maximum width of a generated road as a percentage of the average image dimension (`(width + height) / 2`). | `0.1%` to `5%` in `0.1%` steps |
| **Buildings Per Road** | Controls internal road count by setting how many buildings correspond to one road segment. Lower values create more roads; higher values create fewer roads. | `1` to `20` |
| **Road Exits** | The number of roads that start at the edge of the map and extend inwards. | `0` to `100` |
| **Minimum Road Angle** | The minimum angle allowed between two roads at a junction. Higher values reduce tightly packed, nearly parallel branches. | `0°` to `180°` |
| **Road Curvyness** | How curvy the roads are. At 100%, roads will have many twists and turns. At 0%, they will be perfectly straight. | `0%` (straight) to `100%` (very curvy) |
+13
View File
@@ -553,6 +553,18 @@ func main() {
settings.MaxRoadWidth = val
}))
buildingsPerRoadSlider := newNumericInputSlider(1, 20, float64(settings.BuildingsPerRoad), "%.0f", "Buildings Per Road")
buildingsPerRoadSlider.entry.OnChanged = func(s string) {
buildingsPerRoadSlider.validate(s, func(hasError bool) {
errorStates["buildingsPerRoad"] = hasError
updateGenerateBtnState()
})
}
buildingsPerRoadSlider.value.AddListener(binding.NewDataListener(func() {
val, _ := buildingsPerRoadSlider.value.Get()
settings.BuildingsPerRoad = int(val)
}))
roadExitsSlider := newNumericInputSlider(0, 100, float64(settings.RoadExits), "%.0f", "Road Exits")
roadExitsSlider.entry.OnChanged = func(s string) {
roadExitsSlider.validate(s, func(hasError bool) {
@@ -924,6 +936,7 @@ func main() {
roadsTab := container.NewTabItem("Roads", container.NewVBox(
minRoadWidthSlider,
maxRoadWidthSlider,
buildingsPerRoadSlider,
roadExitsSlider,
minRoadAngleSlider,
roadCurvynessSlider,
+251 -22
View File
@@ -95,7 +95,50 @@ func GenerateRoads(
roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255}
bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255}
roadTarget := estimateRoadTarget(settings, randSrc)
// Edge-case mode: no buildings.
if settings.NumBuildings == 0 {
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
}
var roads []*Road
if internalRoads > 0 {
roadTarget := internalRoads
pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget)
if len(pois) >= 2 {
roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, roadTarget)
// Use existing exit-road logic when internal roads are present.
roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask)
}
} else if settings.RoadDistribution <= 0 && exitRoads > 0 {
// Only in 0% distribution mode: exit roads are edge-to-edge.
roads = generateEdgeToEdgeExitRoads(exitRoads, width, height, settings, randSrc, waterMask)
}
if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil
}
roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc)
if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil
}
assignRoadWidths(roads, settings, randSrc, width, height)
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.IsExit || road.End.IsExit {
drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask)
}
}
return roadMask, bridgeMask, exitRoadMask, roadMask.ToPoints()
}
roadTarget := estimateRoadTarget(settings)
pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget)
if len(pois) < 2 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil
@@ -106,6 +149,10 @@ func GenerateRoads(
if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil
}
roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc)
if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil
}
assignRoadWidths(roads, settings, randSrc, width, height)
roadMask := NewPixelMask(width, height)
@@ -122,8 +169,52 @@ func GenerateRoads(
return roadMask, bridgeMask, exitRoadMask, roadAnchors
}
func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask) []*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)
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)}
}
}
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 {
@@ -142,13 +233,13 @@ func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, r
centerX := width / 2
centerY := height / 2
maxRadius := math.Min(float64(width), float64(height)) * 0.48
minRadius := math.Min(float64(width), float64(height)) * 0.10
radius := minRadius + (maxRadius-minRadius)*distribution
effectiveRadius := math.Sqrt(targetCoverage) * (math.Min(float64(width), float64(height)) * 0.5)
warpPhaseA := randSrc.Float64() * 2 * math.Pi
warpPhaseB := randSrc.Float64() * 2 * math.Pi
pois := make([]*PointOfInterest, 0, coreNodes)
for len(pois) < coreNodes {
x, y, ok := sampleCorePOI(centerX, centerY, radius, width, height, randSrc)
x, y, ok := sampleCorePOI(width, height, distribution, targetCoverage, warpPhaseA, warpPhaseB, randSrc)
if !ok {
break
}
@@ -166,7 +257,7 @@ func generatePOIs(width, height int, settings *Settings, waterMask *PixelMask, r
for _, poi := range pois {
centerDist := math.Hypot(float64(poi.X-centerX), float64(poi.Y-centerY))
centerFactor := 1.0 - clamp01(centerDist/(radius+1))
centerFactor := 1.0 - clamp01(centerDist/(effectiveRadius+1))
sizeFactor := clamp01((avgBuildingSize - 4.0) / 40.0)
poi.ArterialWeight = clamp01(0.60*centerFactor + 0.40*sizeFactor)
}
@@ -193,13 +284,41 @@ func estimateCoreNodeCount(width, height int, distribution, avgBuildingSize floa
return nodes
}
func sampleCorePOI(centerX, centerY int, radius float64, width, height int, randSrc *rand.Rand) (int, int, bool) {
for i := 0; i < 60; i++ {
t := randSrc.Float64() * 2 * math.Pi
r := radius * math.Sqrt(randSrc.Float64())
x := centerX + int(math.Round(r*math.Cos(t)))
y := centerY + int(math.Round(r*math.Sin(t)))
if x >= 0 && x < width && y >= 0 && y < height {
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 {
return x, y, true
}
}
@@ -497,16 +616,18 @@ func chooseExitAnchor(pois []*PointOfInterest, usedExits []image.Point, randSrc
return best
}
func estimateRoadTarget(settings *Settings, randSrc *rand.Rand) int {
// Two random numbers in [1,10], averaged -> triangular distribution centered at 10.5.
divisor := float64((randSrc.Intn(10)+1)+(randSrc.Intn(10)+1)) / 2.0
roads := int(math.Round(float64(max(settings.NumBuildings, 1)) / divisor))
if roads < 4 {
roads = 4
func estimateRoadTarget(settings *Settings) int {
if settings.NumBuildings <= 0 {
return 0
}
// Keep exits connectable and cap by graph size.
if roads < settings.RoadExits {
roads = settings.RoadExits
// 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
}
return roads
}
@@ -835,6 +956,114 @@ func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width
}
}
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
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
+5
View File
@@ -38,6 +38,7 @@ type Settings struct {
// Road settings
MinRoadWidth float64 `json:"min_road_width"`
MaxRoadWidth float64 `json:"max_road_width"`
BuildingsPerRoad int `json:"buildings_per_road"`
RoadExits int `json:"road_exits"`
RoadCurvyness float64 `json:"road_curvyness"`
RoadDistribution float64 `json:"road_distribution"`
@@ -133,6 +134,7 @@ func LoadSettings() (*Settings, error) {
RiverEdgeRoughness: 50,
MinRoadWidth: 0.7,
MaxRoadWidth: 2.7,
BuildingsPerRoad: 6,
RoadExits: 5,
RoadCurvyness: 50,
RoadDistribution: 50,
@@ -195,6 +197,9 @@ func LoadSettings() (*Settings, error) {
if settings.BuildingComplexityRatio == 0 {
settings.BuildingComplexityRatio = 50
}
if settings.BuildingsPerRoad == 0 {
settings.BuildingsPerRoad = 6
}
if _, ok := rawKeys["min_road_angle"]; !ok {
settings.MinRoadAngle = 18
}
+23 -5
View File
@@ -290,10 +290,13 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask
}
r := size / 2
r2 := r * r
candidatePixels := make([]int, 0)
rejectTree := false
for y := p.Y - int(r); y <= p.Y+int(r); y++ {
for x := p.X - int(r); x <= p.X+int(r); x++ {
pt := image.Point{X: x, Y: y}
if !pt.In(img.Bounds()) || waterMask.GetPoint(pt) || roadMask.GetPoint(pt) || buildingMask.GetPoint(pt) {
if !pt.In(img.Bounds()) {
continue
}
dx := float64(x - p.X)
@@ -301,7 +304,26 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask
if dx*dx+dy*dy > r2 {
continue
}
// Reject entire tree if any footprint pixel touches water or buildings.
if waterMask.GetPoint(pt) || buildingMask.GetPoint(pt) {
rejectTree = true
break
}
// Keep existing road behavior: do not draw over roads.
if roadMask.GetPoint(pt) {
continue
}
idx := y*width + x
candidatePixels = append(candidatePixels, idx)
}
if rejectTree {
break
}
}
if rejectTree || len(candidatePixels) == 0 {
continue
}
for _, idx := range candidatePixels {
if treeMask.Data[idx] == 0 {
treeMask.Data[idx] = 1
treePixelsPlaced++
@@ -315,10 +337,6 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask
break
}
}
if done {
break
}
}
for y := 0; y < treeMask.Height; y++ {
row := y * treeMask.Width