diff --git a/README.md b/README.md index c2a5d54..60cfbc3 100644 --- a/README.md +++ b/README.md @@ -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%` | | **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) | -| **Min Road Width** | The minimum width of a generated road in pixels. | `1` to `100` | -| **Max Road Width** | The maximum 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 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` | | **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 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` | -| **Min Building Size** | The minimum size of a generated building in pixels. | `1` to `100` | -| **Max Building Size** | The maximum 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 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 Shape** | The shape of the buildings. | `squares` or `circles` | diff --git a/buildings.go b/buildings.go index c46168c..cae90d6 100644 --- a/buildings.go +++ b/buildings.go @@ -9,6 +9,58 @@ import ( "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. 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 @@ -87,6 +139,7 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r buildingsPlaced := 0 searchTries := 100 // Number of attempts to find a spot for a building around an anchor maxPlacementAttempts := settings.NumBuildings * 5 // To prevent infinite loops + minBuildingSizePx, maxBuildingSizePx := getBuildingSizeRangePixels(settings, width, height) for buildingsPlaced < settings.NumBuildings && maxPlacementAttempts > 0 { 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 - size := settings.MinBuildingSize + randSrc.Float64()*(settings.MaxBuildingSize-settings.MinBuildingSize) + size := minBuildingSizePx + randSrc.Float64()*(maxBuildingSizePx-minBuildingSizePx) shape := settings.BuildingShape if shape == "mixed" { shape = chooseShape(randSrc, settings.BuildingShapeRatios) diff --git a/custom_widgets.go b/custom_widgets.go index aa395b6..4f7bfa1 100644 --- a/custom_widgets.go +++ b/custom_widgets.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "math" "strconv" "strings" @@ -42,6 +43,7 @@ type numericInputSlider struct { widget.BaseWidget value binding.Float min, max float64 + step float64 slider *widget.Slider entry *widget.Entry format string @@ -83,6 +85,7 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string min: min, max: max, format: format, + step: 0, } s.ExtendBaseWidget(s) @@ -105,6 +108,17 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string 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 func (s *numericInputSlider) validate(text string, onError func(bool)) { text = strings.TrimSpace(text) @@ -121,11 +135,26 @@ func (s *numericInputSlider) validate(text string, onError func(bool)) { } 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() onError(true) 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() onError(false) diff --git a/main.go b/main.go index ec8211f..a3b2c0f 100644 --- a/main.go +++ b/main.go @@ -429,7 +429,7 @@ func main() { val, _ := treeClumpinessSlider.value.Get() 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.validate(s, func(hasError bool) { errorStates["minRoadWidth"] = hasError @@ -441,7 +441,7 @@ func main() { 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.validate(s, func(hasError bool) { errorStates["maxRoadWidth"] = hasError @@ -753,7 +753,7 @@ func main() { 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.validate(s, func(hasError bool) { errorStates["minBuildingSize"] = hasError @@ -765,7 +765,7 @@ func main() { 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.validate(s, func(hasError bool) { errorStates["maxBuildingSize"] = hasError diff --git a/roads.go b/roads.go index 01ac16d..82c0e98 100644 --- a/roads.go +++ b/roads.go @@ -33,6 +33,54 @@ type Road struct { 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 { out := make([]image.Point, len(lastExitRoadPixels)) copy(out, lastExitRoadPixels) @@ -61,7 +109,7 @@ func GenerateRoads(width, height int, settings *Settings, _ image.Image, allWate if len(roads) == 0 { return nil, nil, img } - assignRoadWidths(roads, settings, randSrc) + assignRoadWidths(roads, settings, randSrc, width, height) allRoadPixels := make([]image.Point, 0, len(roads)*64) 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 { 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 { avgBuildingSize = 1 } @@ -512,13 +561,12 @@ func normalizeAngle(a float64) float64 { 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 { return } - minWidth := settings.MinRoadWidth - maxWidth := settings.MaxRoadWidth + minWidth, maxWidth := getRoadWidthRangePixels(settings, width, height) if maxWidth < minWidth { minWidth, maxWidth = maxWidth, minWidth } diff --git a/settings.go b/settings.go index 73906e2..95f7fdc 100644 --- a/settings.go +++ b/settings.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "io" "os" "path/filepath" "time" @@ -130,15 +131,15 @@ func LoadSettings() (*Settings, error) { RiverCurvyness: 50, RiverWidthVariability: 50, RiverEdgeRoughness: 50, - MinRoadWidth: 2, - MaxRoadWidth: 8, + MinRoadWidth: 0.7, + MaxRoadWidth: 2.7, RoadExits: 5, RoadCurvyness: 50, RoadDistribution: 50, MinRoadAngle: 18, NumBuildings: 200, - MinBuildingSize: 10, - MaxBuildingSize: 30, + MinBuildingSize: 3.5, + MaxBuildingSize: 10.0, BuildingDistribution: 20, BuildingShape: "mixed", BuildingShapeRatios: map[string]float64{ @@ -157,17 +158,19 @@ func LoadSettings() (*Settings, error) { } defer file.Close() - // Decode through a wrapper so we can tell whether newer fields were present. - type settingsDisk struct { - Settings - MinRoadAngle *float64 `json:"min_road_angle"` + // Decode once into Settings, and separately inspect raw keys for field-presence checks. + raw, err := io.ReadAll(file) + if err != nil { + return nil, err } - var disk settingsDisk - decoder := json.NewDecoder(file) - if err := decoder.Decode(&disk); err != nil { + var settings Settings + if err := json.Unmarshal(raw, &settings); err != nil { + return nil, err + } + var rawKeys map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawKeys); err != nil { return nil, err } - settings := disk.Settings if settings.LakeShape == "" { settings.LakeShape = "circle" @@ -192,10 +195,34 @@ func LoadSettings() (*Settings, error) { if settings.BuildingComplexityRatio == 0 { settings.BuildingComplexityRatio = 50 } - if disk.MinRoadAngle == nil { + if _, ok := rawKeys["min_road_angle"]; !ok { 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 if settings.LastExportPath == "" { homeDir, err := os.UserHomeDir()