scaled buildings and roads based on image dimensions

This commit is contained in:
Grimsace
2026-02-26 12:49:01 -06:00
parent d2d249ac0c
commit 1dd9bf2c13
6 changed files with 185 additions and 28 deletions
+4 -4
View File
@@ -31,14 +31,14 @@ A remake in go of a program that generates maps of rpg like towns. Inspied by Ro
| **Min River Width** | The minimum width of a generated river, as a percentage of the smaller of the map's width or height. | `1%` to `100%` | | **Min River Width** | The minimum width of a generated river, as a percentage of the smaller of the map's width or height. | `1%` to `100%` |
| **Max River Width** | The maximum width of a generated river, as a percentage of the smaller of the map's width or height. | `1%` to `100%` | | **Max River Width** | The maximum width of a generated river, as a percentage of the smaller of the map's width or height. | `1%` to `100%` |
| **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) | | **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 in pixels. | `1` to `100` | | **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 in pixels. | `1` to `100` | | **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 |
| **Road Exits** | The number of roads that start at the edge of the map and extend inwards. | `0` to `100` | | **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°` | | **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) | | **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) |
| **Road Distribution** | Controls the distribution of roads. At 100%, roads will be spread out across the entire map. At 0%, they will be clustered in the center. | `0%` (centered) to `100%` (spread out) | | **Road Distribution** | Controls the distribution of roads. At 100%, roads will be spread out across the entire map. At 0%, they will be clustered in the center. | `0%` (centered) to `100%` (spread out) |
| **Num Buildings** | The number of buildings to generate. | `0` to `1000` | | **Num Buildings** | The number of buildings to generate. | `0` to `1000` |
| **Min Building Size** | The minimum size of a generated building in pixels. | `1` to `100` | | **Min Building Size** | The minimum size of a generated building as a percentage of the average image dimension (`(width + height) / 2`). | `0.5%` to `25%` in `0.5%` steps |
| **Max Building Size** | The maximum size of a generated building in pixels. | `1` to `100` | | **Max Building Size** | The maximum size of a generated building as a percentage of the average image dimension (`(width + height) / 2`). | `0.5%` to `25%` in `0.5%` steps |
| **Building Distro** | Controls the distribution of buildings. At 100%, buildings will be spread out across the entire map. At 0%, they will be always near a road. | `0%` (centered) to `100%` (spread out) | | **Building Distro** | Controls the distribution of buildings. At 100%, buildings will be spread out across the entire map. At 0%, they will be always near a road. | `0%` (centered) to `100%` (spread out) |
| **Building Shape** | The shape of the buildings. | `squares` or `circles` | | **Building Shape** | The shape of the buildings. | `squares` or `circles` |
+54 -1
View File
@@ -9,6 +9,58 @@ import (
"sync" "sync"
) )
const (
minBuildingSizePercent = 0.5
maxBuildingSizePercent = 25.0
buildingSizePercentStep = 0.5
)
func averageImageDimension(width, height int) float64 {
return (float64(width) + float64(height)) / 2.0
}
func clampBuildingSizePercent(v float64) float64 {
if v < minBuildingSizePercent {
return minBuildingSizePercent
}
if v > maxBuildingSizePercent {
return maxBuildingSizePercent
}
return v
}
func snapBuildingSizePercent(v float64) float64 {
v = clampBuildingSizePercent(v)
steps := math.Round((v - minBuildingSizePercent) / buildingSizePercentStep)
return clampBuildingSizePercent(minBuildingSizePercent + steps*buildingSizePercentStep)
}
func normalizeBuildingSizePercentRange(minPercent, maxPercent float64) (float64, float64) {
minPercent = snapBuildingSizePercent(minPercent)
maxPercent = snapBuildingSizePercent(maxPercent)
if minPercent > maxPercent {
minPercent, maxPercent = maxPercent, minPercent
}
return minPercent, maxPercent
}
func getBuildingSizeRangePixels(settings *Settings, width, height int) (float64, float64) {
minPercent, maxPercent := normalizeBuildingSizePercentRange(settings.MinBuildingSize, settings.MaxBuildingSize)
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
}
// GenerateBuildings creates and places buildings on the map. // GenerateBuildings creates and places buildings on the map.
func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, roadPixels, allWaterPixels []image.Point, seed int64) ([][]image.Point, []image.Point) { func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, roadPixels, allWaterPixels []image.Point, seed int64) ([][]image.Point, []image.Point) {
// Early exit if no buildings are to be generated // Early exit if no buildings are to be generated
@@ -87,6 +139,7 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
buildingsPlaced := 0 buildingsPlaced := 0
searchTries := 100 // Number of attempts to find a spot for a building around an anchor searchTries := 100 // Number of attempts to find a spot for a building around an anchor
maxPlacementAttempts := settings.NumBuildings * 5 // To prevent infinite loops maxPlacementAttempts := settings.NumBuildings * 5 // To prevent infinite loops
minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
for buildingsPlaced < settings.NumBuildings && maxPlacementAttempts > 0 { for buildingsPlaced < settings.NumBuildings && maxPlacementAttempts > 0 {
maxPlacementAttempts-- maxPlacementAttempts--
@@ -133,7 +186,7 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
} }
// Attempt to create a building at the selected center // Attempt to create a building at the selected center
size := settings.MinBuildingSize + randSrc.Float64()*(settings.MaxBuildingSize-settings.MinBuildingSize) size := minBuildingSizePx + randSrc.Float64()*(maxBuildingSizePx-minBuildingSizePx)
shape := settings.BuildingShape shape := settings.BuildingShape
if shape == "mixed" { if shape == "mixed" {
shape = chooseShape(randSrc, settings.BuildingShapeRatios) shape = chooseShape(randSrc, settings.BuildingShapeRatios)
+30 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
@@ -42,6 +43,7 @@ type numericInputSlider struct {
widget.BaseWidget widget.BaseWidget
value binding.Float value binding.Float
min, max float64 min, max float64
step float64
slider *widget.Slider slider *widget.Slider
entry *widget.Entry entry *widget.Entry
format string format string
@@ -83,6 +85,7 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string
min: min, min: min,
max: max, max: max,
format: format, format: format,
step: 0,
} }
s.ExtendBaseWidget(s) s.ExtendBaseWidget(s)
@@ -105,6 +108,17 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string
return s return s
} }
func newNumericInputSliderWithStep(min, max, initialValue, step float64, format string, labelText string) *numericInputSlider {
s := newNumericInputSlider(min, max, initialValue, format, labelText)
if step > 0 {
s.step = step
s.slider.Step = step
rounded := min + math.Round((initialValue-min)/step)*step
s.value.Set(rounded)
}
return s
}
// validate checks text entry for valid numeric input within the defined range // validate checks text entry for valid numeric input within the defined range
func (s *numericInputSlider) validate(text string, onError func(bool)) { func (s *numericInputSlider) validate(text string, onError func(bool)) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
@@ -121,11 +135,26 @@ func (s *numericInputSlider) validate(text string, onError func(bool)) {
} }
if val < s.min || val > s.max { if val < s.min || val > s.max {
s.errorLabel.SetText(fmt.Sprintf("Out of range (%.0f-%.0f)", s.min, s.max)) s.errorLabel.SetText(fmt.Sprintf(
"Out of range (%s-%s)",
strconv.FormatFloat(s.min, 'f', -1, 64),
strconv.FormatFloat(s.max, 'f', -1, 64),
))
s.errorLabel.Show() s.errorLabel.Show()
onError(true) onError(true)
return return
} }
if s.step > 0 {
steps := math.Round((val - s.min) / s.step)
snapped := s.min + steps*s.step
if math.Abs(val-snapped) > 1e-9 {
s.errorLabel.SetText(fmt.Sprintf("Use increments of %s", strconv.FormatFloat(s.step, 'f', -1, 64)))
s.errorLabel.Show()
onError(true)
return
}
val = snapped
}
s.errorLabel.Hide() s.errorLabel.Hide()
onError(false) onError(false)
+4 -4
View File
@@ -429,7 +429,7 @@ func main() {
val, _ := treeClumpinessSlider.value.Get() val, _ := treeClumpinessSlider.value.Get()
settings.TreeClumpiness = val settings.TreeClumpiness = val
})) }))
minRoadWidthSlider := newNumericInputSlider(1, 150, settings.MinRoadWidth, "%.0fpx", "Min Road Width") minRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MinRoadWidth, roadWidthPercentStep, "%.1f%%", "Min Road Width")
minRoadWidthSlider.entry.OnChanged = func(s string) { minRoadWidthSlider.entry.OnChanged = func(s string) {
minRoadWidthSlider.validate(s, func(hasError bool) { minRoadWidthSlider.validate(s, func(hasError bool) {
errorStates["minRoadWidth"] = hasError errorStates["minRoadWidth"] = hasError
@@ -441,7 +441,7 @@ func main() {
settings.MinRoadWidth = val settings.MinRoadWidth = val
})) }))
maxRoadWidthSlider := newNumericInputSlider(1, 150, settings.MaxRoadWidth, "%.0fpx", "Max Road Width") maxRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MaxRoadWidth, roadWidthPercentStep, "%.1f%%", "Max Road Width")
maxRoadWidthSlider.entry.OnChanged = func(s string) { maxRoadWidthSlider.entry.OnChanged = func(s string) {
maxRoadWidthSlider.validate(s, func(hasError bool) { maxRoadWidthSlider.validate(s, func(hasError bool) {
errorStates["maxRoadWidth"] = hasError errorStates["maxRoadWidth"] = hasError
@@ -753,7 +753,7 @@ func main() {
settings.NumBuildings = int(val) settings.NumBuildings = int(val)
})) }))
minBuildingSizeSlider := newNumericInputSlider(1, 150, settings.MinBuildingSize, "%.0fpx", "Min Building Size") minBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MinBuildingSize, buildingSizePercentStep, "%.1f%%", "Min Building Size")
minBuildingSizeSlider.entry.OnChanged = func(s string) { minBuildingSizeSlider.entry.OnChanged = func(s string) {
minBuildingSizeSlider.validate(s, func(hasError bool) { minBuildingSizeSlider.validate(s, func(hasError bool) {
errorStates["minBuildingSize"] = hasError errorStates["minBuildingSize"] = hasError
@@ -765,7 +765,7 @@ func main() {
settings.MinBuildingSize = val settings.MinBuildingSize = val
})) }))
maxBuildingSizeSlider := newNumericInputSlider(1, 150, settings.MaxBuildingSize, "%.0fpx", "Max Building Size") maxBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MaxBuildingSize, buildingSizePercentStep, "%.1f%%", "Max Building Size")
maxBuildingSizeSlider.entry.OnChanged = func(s string) { maxBuildingSizeSlider.entry.OnChanged = func(s string) {
maxBuildingSizeSlider.validate(s, func(hasError bool) { maxBuildingSizeSlider.validate(s, func(hasError bool) {
errorStates["maxBuildingSize"] = hasError errorStates["maxBuildingSize"] = hasError
+53 -5
View File
@@ -33,6 +33,54 @@ type Road struct {
var lastExitRoadPixels []image.Point var lastExitRoadPixels []image.Point
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
}
func getExitRoadPixels() []image.Point { func getExitRoadPixels() []image.Point {
out := make([]image.Point, len(lastExitRoadPixels)) out := make([]image.Point, len(lastExitRoadPixels))
copy(out, lastExitRoadPixels) copy(out, lastExitRoadPixels)
@@ -61,7 +109,7 @@ func GenerateRoads(width, height int, settings *Settings, _ image.Image, allWate
if len(roads) == 0 { if len(roads) == 0 {
return nil, nil, img return nil, nil, img
} }
assignRoadWidths(roads, settings, randSrc) assignRoadWidths(roads, settings, randSrc, width, height)
allRoadPixels := make([]image.Point, 0, len(roads)*64) allRoadPixels := make([]image.Point, 0, len(roads)*64)
allBridgePixels := make([]image.Point, 0, len(roads)*16) allBridgePixels := make([]image.Point, 0, len(roads)*16)
@@ -82,7 +130,8 @@ func GenerateRoads(width, height int, settings *Settings, _ image.Image, allWate
func generatePOIs(width, height int, settings *Settings, waterMap map[image.Point]bool, randSrc *rand.Rand, roadTarget int) []*PointOfInterest { func generatePOIs(width, height int, settings *Settings, waterMap map[image.Point]bool, randSrc *rand.Rand, roadTarget int) []*PointOfInterest {
distribution := clamp01(settings.RoadDistribution / 100.0) distribution := clamp01(settings.RoadDistribution / 100.0)
avgBuildingSize := (settings.MinBuildingSize + settings.MaxBuildingSize) / 2.0 minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height)
avgBuildingSize := (minBuildingSizePx + maxBuildingSizePx) / 2.0
if avgBuildingSize < 1 { if avgBuildingSize < 1 {
avgBuildingSize = 1 avgBuildingSize = 1
} }
@@ -512,13 +561,12 @@ func normalizeAngle(a float64) float64 {
return a return a
} }
func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand) { func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int) {
if len(roads) == 0 { if len(roads) == 0 {
return return
} }
minWidth := settings.MinRoadWidth minWidth, maxWidth := getRoadWidthRangePixels(settings, width, height)
maxWidth := settings.MaxRoadWidth
if maxWidth < minWidth { if maxWidth < minWidth {
minWidth, maxWidth = maxWidth, minWidth minWidth, maxWidth = maxWidth, minWidth
} }
+40 -13
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"io"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -130,15 +131,15 @@ func LoadSettings() (*Settings, error) {
RiverCurvyness: 50, RiverCurvyness: 50,
RiverWidthVariability: 50, RiverWidthVariability: 50,
RiverEdgeRoughness: 50, RiverEdgeRoughness: 50,
MinRoadWidth: 2, MinRoadWidth: 0.7,
MaxRoadWidth: 8, MaxRoadWidth: 2.7,
RoadExits: 5, RoadExits: 5,
RoadCurvyness: 50, RoadCurvyness: 50,
RoadDistribution: 50, RoadDistribution: 50,
MinRoadAngle: 18, MinRoadAngle: 18,
NumBuildings: 200, NumBuildings: 200,
MinBuildingSize: 10, MinBuildingSize: 3.5,
MaxBuildingSize: 30, MaxBuildingSize: 10.0,
BuildingDistribution: 20, BuildingDistribution: 20,
BuildingShape: "mixed", BuildingShape: "mixed",
BuildingShapeRatios: map[string]float64{ BuildingShapeRatios: map[string]float64{
@@ -157,17 +158,19 @@ func LoadSettings() (*Settings, error) {
} }
defer file.Close() defer file.Close()
// Decode through a wrapper so we can tell whether newer fields were present. // Decode once into Settings, and separately inspect raw keys for field-presence checks.
type settingsDisk struct { raw, err := io.ReadAll(file)
Settings if err != nil {
MinRoadAngle *float64 `json:"min_road_angle"` return nil, err
} }
var disk settingsDisk var settings Settings
decoder := json.NewDecoder(file) if err := json.Unmarshal(raw, &settings); err != nil {
if err := decoder.Decode(&disk); err != nil { return nil, err
}
var rawKeys map[string]json.RawMessage
if err := json.Unmarshal(raw, &rawKeys); err != nil {
return nil, err return nil, err
} }
settings := disk.Settings
if settings.LakeShape == "" { if settings.LakeShape == "" {
settings.LakeShape = "circle" settings.LakeShape = "circle"
@@ -192,10 +195,34 @@ func LoadSettings() (*Settings, error) {
if settings.BuildingComplexityRatio == 0 { if settings.BuildingComplexityRatio == 0 {
settings.BuildingComplexityRatio = 50 settings.BuildingComplexityRatio = 50
} }
if disk.MinRoadAngle == nil { if _, ok := rawKeys["min_road_angle"]; !ok {
settings.MinRoadAngle = 18 settings.MinRoadAngle = 18
} }
// Road widths are percentages of average image dimension.
// Migrate older pixel-based values when they exceed the valid percentage range.
if settings.MinRoadWidth > maxRoadWidthPercent || settings.MaxRoadWidth > maxRoadWidthPercent {
avgDim := averageImageDimension(settings.Width, settings.Height)
if avgDim < 1 {
avgDim = 1
}
settings.MinRoadWidth = (settings.MinRoadWidth / avgDim) * 100.0
settings.MaxRoadWidth = (settings.MaxRoadWidth / avgDim) * 100.0
}
settings.MinRoadWidth, settings.MaxRoadWidth = normalizeRoadWidthPercentRange(settings.MinRoadWidth, settings.MaxRoadWidth)
// Building sizes are percentages of average image dimension.
// Migrate older pixel-based values when they exceed the valid percentage range.
if settings.MinBuildingSize > maxBuildingSizePercent || settings.MaxBuildingSize > maxBuildingSizePercent {
avgDim := averageImageDimension(settings.Width, settings.Height)
if avgDim < 1 {
avgDim = 1
}
settings.MinBuildingSize = (settings.MinBuildingSize / avgDim) * 100.0
settings.MaxBuildingSize = (settings.MaxBuildingSize / avgDim) * 100.0
}
settings.MinBuildingSize, settings.MaxBuildingSize = normalizeBuildingSizePercentRange(settings.MinBuildingSize, settings.MaxBuildingSize)
// Ensure LastExportPath is set to a default value if it's empty // Ensure LastExportPath is set to a default value if it's empty
if settings.LastExportPath == "" { if settings.LastExportPath == "" {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()