diff --git a/README.md b/README.md index 60cfbc3..ca9ed4d 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ A remake in go of a program that generates maps of rpg like towns. Inspied by Ro | **Lakes** | The number of lakes to generate on the map. | `0` to `100` | | **Lake Size Lower** | The minimum size of a generated lake, as a percentage of the smaller of the map's width or height. | `1%` to `100%` | | **Lake Size Upper** | The maximum size of a generated lake, as a percentage of the smaller of the map's width or height. | `1%` to `100%` | -| **Min Tree Size** | The minimum size of a generated tree in pixels. | `1` to `100` | -| **Max Tree Size** | The maximum size of a generated tree in pixels. | `1` to `100` | +| **Min Tree Size** | The minimum size of a generated tree as a percentage of the average image dimension (`(width + height) / 2`). | `0.2%` to `15%` in `0.2%` steps | +| **Max Tree Size** | The maximum size of a generated tree as a percentage of the average image dimension (`(width + height) / 2`). | `0.2%` to `15%` in `0.2%` steps | | **Tree Coverage** | The density of trees on the map, as a percentage of the total land area. At 100%, the land will be completely covered in trees, forming a dense forest. At 0%, there will be no trees. | `0%` (no trees) to `100%` (dense forest) | | **Tree Clumpiness** | Controls how much trees are clumped together. At 100%, trees will be tightly clustered in a few areas. At 0%, they will be distributed evenly across the map. | `0%` (evenly distributed) to `100%` (highly clumped) | | **Seed** | The random seed used for generation. Using the same seed will produce the exact same map every time. This is useful for sharing and reproducing maps. | Any integer | diff --git a/main.go b/main.go index fed98db..bf15792 100644 --- a/main.go +++ b/main.go @@ -482,7 +482,7 @@ func main() { settings.RiverEdgeRoughness = val })) - minTreeSizeSlider := newNumericInputSlider(1, 150, settings.MinTreeSize, "%.0fpx", "Min Tree Size") + minTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MinTreeSize, treeSizePercentStep, "%.1f%%", "Min Tree Size") minTreeSizeSlider.entry.OnChanged = func(s string) { minTreeSizeSlider.validate(s, func(hasError bool) { errorStates["minTreeSize"] = hasError @@ -494,7 +494,7 @@ func main() { settings.MinTreeSize = val })) - maxTreeSizeSlider := newNumericInputSlider(1, 150, settings.MaxTreeSize, "%.0fpx", "Max Tree Size") + maxTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MaxTreeSize, treeSizePercentStep, "%.1f%%", "Max Tree Size") maxTreeSizeSlider.entry.OnChanged = func(s string) { maxTreeSizeSlider.validate(s, func(hasError bool) { errorStates["maxTreeSize"] = hasError diff --git a/settings.go b/settings.go index 95f7fdc..66c754b 100644 --- a/settings.go +++ b/settings.go @@ -120,8 +120,8 @@ func LoadSettings() (*Settings, error) { LakeSizeUpper: 5, LakeEdgeRoughness: 50, LakeShape: "circle", - MinTreeSize: 5, - MaxTreeSize: 20, + MinTreeSize: 1.6, + MaxTreeSize: 6.6, TreeCoverage: 20, TreeClumpiness: 50, Seed: time.Now().UnixNano(), @@ -199,6 +199,18 @@ func LoadSettings() (*Settings, error) { settings.MinRoadAngle = 18 } + // Tree sizes are percentages of average image dimension. + // Migrate older pixel-based values when they exceed the valid percentage range. + if settings.MinTreeSize > maxTreeSizePercent || settings.MaxTreeSize > maxTreeSizePercent { + avgDim := averageImageDimension(settings.Width, settings.Height) + if avgDim < 1 { + avgDim = 1 + } + settings.MinTreeSize = (settings.MinTreeSize / avgDim) * 100.0 + settings.MaxTreeSize = (settings.MaxTreeSize / avgDim) * 100.0 + } + settings.MinTreeSize, settings.MaxTreeSize = normalizeTreeSizePercentRange(settings.MinTreeSize, settings.MaxTreeSize) + // 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 { diff --git a/terrain.go b/terrain.go index 1dbb08f..94a904c 100644 --- a/terrain.go +++ b/terrain.go @@ -18,8 +18,54 @@ const ( alpha = 2. beta = 2. n = 3 + + minTreeSizePercent = 0.2 + maxTreeSizePercent = 15.0 + treeSizePercentStep = 0.2 ) +func clampTreeSizePercent(v float64) float64 { + if v < minTreeSizePercent { + return minTreeSizePercent + } + if v > maxTreeSizePercent { + return maxTreeSizePercent + } + return v +} + +func snapTreeSizePercent(v float64) float64 { + v = clampTreeSizePercent(v) + steps := math.Round((v - minTreeSizePercent) / treeSizePercentStep) + return clampTreeSizePercent(minTreeSizePercent + steps*treeSizePercentStep) +} + +func normalizeTreeSizePercentRange(minPercent, maxPercent float64) (float64, float64) { + minPercent = snapTreeSizePercent(minPercent) + maxPercent = snapTreeSizePercent(maxPercent) + if minPercent > maxPercent { + minPercent, maxPercent = maxPercent, minPercent + } + return minPercent, maxPercent +} + +func getTreeSizeRangePixels(minPercent, maxPercent float64, width, height int) (float64, float64) { + minPercent, maxPercent = normalizeTreeSizePercentRange(minPercent, maxPercent) + 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 +} + // GenerateHeightmap creates terrain elevation using Perlin noise func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) image.Image { p := perlin.NewPerlin(alpha, beta, n, seed) @@ -161,21 +207,18 @@ func FlattenRoadAreas(heightmap image.Image, roadMask *PixelMask) image.Image { func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask, minTreeSize, maxTreeSize, treeCoverage, treeClumpiness float64, seed int64) *PixelMask { width := img.Bounds().Dx() height := img.Bounds().Dy() + minTreeSizePx, maxTreeSizePx := getTreeSizeRangePixels(minTreeSize, maxTreeSize, width, height) - avgTreeSize := (minTreeSize + maxTreeSize) / 2 - if avgTreeSize <= 0 { - return nil + totalPixels := width * height + if totalPixels <= 0 || treeCoverage <= 0 { + return NewPixelMask(width, height) } - avgRadius := avgTreeSize / 2 - avgTreeArea := math.Pi * avgRadius * avgRadius - if avgTreeArea == 0 { - return nil + targetTreePixels := int((float64(totalPixels) * treeCoverage) / 100.0) + if treeCoverage >= 100 { + targetTreePixels = totalPixels } - totalArea := float64(width * height) - targetTreePixels := totalArea * (treeCoverage / 100.0) - numTreesToPlace := int(targetTreePixels / avgTreeArea) - if numTreesToPlace == 0 { - return nil + if targetTreePixels < 1 { + targetTreePixels = 1 } noise := opensimplex.New(seed) @@ -201,8 +244,7 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask } randSrc := rand.New(rand.NewSource(seed)) - - numClumpTrees := min(int(treeClumpiness), numTreesToPlace) + numClumpTrees := max(1, int(treeClumpiness)) initialPoints := make([]image.Point, 0, numClumpTrees) for range numClumpTrees { @@ -214,71 +256,67 @@ func GenerateTrees(img *image.RGBA, waterMask, roadMask, buildingMask *PixelMask } } } + if len(initialPoints) == 0 { + for i := 0; i < 256; i++ { + p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)} + if treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !waterMask.GetPoint(p) && !roadMask.GetPoint(p) && !buildingMask.GetPoint(p) { + initialPoints = append(initialPoints, p) + break + } + } + if len(initialPoints) == 0 { + return NewPixelMask(width, height) + } + } - minRadius := minTreeSize + minRadius := minTreeSizePx allPoints := poissonDiscSampling(width, height, minRadius, 30, initialPoints, func(p image.Point) bool { return treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !waterMask.GetPoint(p) && !roadMask.GetPoint(p) && !buildingMask.GetPoint(p) }, seed) - - numGoroutines := runtime.NumCPU() - if len(allPoints) < numGoroutines { - numGoroutines = len(allPoints) + treeMask := NewPixelMask(width, height) + if len(allPoints) == 0 { + return treeMask } - if numGoroutines == 0 { - return nil - } - - var wg sync.WaitGroup - results := make(chan []image.Point, numGoroutines) - pointsPerGoroutine := (len(allPoints) + numGoroutines - 1) / numGoroutines - - for i := 0; i < numGoroutines; i++ { - start := i * pointsPerGoroutine - if start >= len(allPoints) { - break + treePixelsPlaced := 0 + sizeRand := rand.New(rand.NewSource(seed + 17)) + done := false + for _, p := range allPoints { + size := minTreeSizePx + sizeRand.Float64()*(maxTreeSizePx-minTreeSizePx) + if size <= 0 { + continue } - end := start + pointsPerGoroutine - if end > len(allPoints) { - end = len(allPoints) - } - - wg.Add(1) - go func(points []image.Point, seed int64) { - defer wg.Done() - localRand := rand.New(rand.NewSource(seed)) - localTreePixels := make([]image.Point, 0) - for _, p := range points { - size := minTreeSize + localRand.Float64()*(maxTreeSize-minTreeSize) - if size <= 0 { + r := size / 2 + r2 := r * r + 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) { continue } - r := size / 2 - 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) { - continue - } - - if (math.Pow(float64(x-p.X), 2) + math.Pow(float64(y-p.Y), 2)) <= r*r { - localTreePixels = append(localTreePixels, pt) - } + dx := float64(x - p.X) + dy := float64(y - p.Y) + 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 } } } - results <- localTreePixels - }(allPoints[start:end], seed+int64(i)) - } - - wg.Wait() - close(results) - - treeMask := NewPixelMask(width, height) - for res := range results { - for _, p := range res { - treeMask.SetPoint(p) + if done { + break + } + } + if done { + break } } + for y := 0; y < treeMask.Height; y++ { row := y * treeMask.Width for x := 0; x < treeMask.Width; x++ {