From 9b3b1811f4d9e9ecf84dadfc031e63e8805d700e Mon Sep 17 00:00:00 2001 From: Grimsace Date: Fri, 27 Feb 2026 09:31:42 -0600 Subject: [PATCH] Made Cities/Towns Not perfectly circular. --- README.md | 1 + main.go | 13 +++ roads.go | 273 +++++++++++++++++++++++++++++++++++++++++++++++----- settings.go | 5 + terrain.go | 38 ++++++-- 5 files changed, 298 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ca9ed4d..1cb19fb 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/main.go b/main.go index bf15792..d3ab2db 100644 --- a/main.go +++ b/main.go @@ -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, diff --git a/roads.go b/roads.go index c5380e0..b9df1ce 100644 --- a/roads.go +++ b/roads.go @@ -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 diff --git a/settings.go b/settings.go index 66c754b..d8c1839 100644 --- a/settings.go +++ b/settings.go @@ -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 } diff --git a/terrain.go b/terrain.go index d2d211f..1cb9bb5 100644 --- a/terrain.go +++ b/terrain.go @@ -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,20 +304,35 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask if dx*dx+dy*dy > r2 { continue } - idx := y*width + x - if treeMask.Data[idx] == 0 { - treeMask.Data[idx] = 1 - treePixelsPlaced++ - if treePixelsPlaced >= targetTreePixels { - done = true - break - } + // 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 done { + if rejectTree { break } } + if rejectTree || len(candidatePixels) == 0 { + continue + } + for _, idx := range candidatePixels { + if treeMask.Data[idx] == 0 { + treeMask.Data[idx] = 1 + treePixelsPlaced++ + if treePixelsPlaced >= targetTreePixels { + done = true + break + } + } + } if done { break }