cleaned up comments

This commit is contained in:
Grimsace
2026-02-18 15:26:29 -06:00
parent 710c05f716
commit 266b93639c
7 changed files with 91 additions and 278 deletions
-18
View File
@@ -16,11 +16,9 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
return nil, nil return nil, nil
} }
// Initialize random number generator
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
buildingColor := color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray color for buildings buildingColor := color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray color for buildings
// Create lookup maps for water and road pixels for efficient collision detection
isWater := make(map[image.Point]bool) isWater := make(map[image.Point]bool)
for _, p := range allWaterPixels { for _, p := range allWaterPixels {
isWater[p] = true isWater[p] = true
@@ -31,13 +29,11 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
isRoad[p] = true isRoad[p] = true
} }
// Initialize building data structures
isBuilding := make(map[image.Point]bool) isBuilding := make(map[image.Point]bool)
var buildings [][]image.Point var buildings [][]image.Point
var allBuildingPixels []image.Point var allBuildingPixels []image.Point
var anchorPoints []image.Point var anchorPoints []image.Point
// Determine anchor points for building placement
if len(roadPixels) > 0 { if len(roadPixels) > 0 {
anchorPoints = roadPixels anchorPoints = roadPixels
} else { } else {
@@ -64,7 +60,6 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
return anchorPoints[i].X < anchorPoints[j].X return anchorPoints[i].X < anchorPoints[j].X
}) })
// Collect all land points for random placement
landPoints := make([]image.Point, 0, width*height) landPoints := make([]image.Point, 0, width*height)
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
for x := 0; x < width; x++ { for x := 0; x < width; x++ {
@@ -86,10 +81,8 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
// Select an anchor point for the new building // Select an anchor point for the new building
var anchor image.Point var anchor image.Point
if randSrc.Float64() > settings.BuildingDistribution/100.0 { if randSrc.Float64() > settings.BuildingDistribution/100.0 {
// Place near roads or other existing features
anchor = anchorPoints[randSrc.Intn(len(anchorPoints))] anchor = anchorPoints[randSrc.Intn(len(anchorPoints))]
} else { } else {
// Place randomly on any available land
if len(landPoints) == 0 { if len(landPoints) == 0 {
continue // No land to place buildings on continue // No land to place buildings on
} }
@@ -152,7 +145,6 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
// getProceduralBuildingPixels generates a complex building by connecting multiple shapes. // getProceduralBuildingPixels generates a complex building by connecting multiple shapes.
func getProceduralBuildingPixels(center image.Point, size float64, settings *Settings, isWater, isRoad, isBuilding map[image.Point]bool, width, height int, randSrc *rand.Rand) ([]image.Point, bool) { func getProceduralBuildingPixels(center image.Point, size float64, settings *Settings, isWater, isRoad, isBuilding map[image.Point]bool, width, height int, randSrc *rand.Rand) ([]image.Point, bool) {
// Determine complexity
complexity := settings.MinBuildingComplexity complexity := settings.MinBuildingComplexity
if settings.BuildingComplexityRatio > randSrc.Float64()*100 { if settings.BuildingComplexityRatio > randSrc.Float64()*100 {
complexity = settings.MinBuildingComplexity + randSrc.Intn(settings.MaxBuildingComplexity-settings.MinBuildingComplexity+1) complexity = settings.MinBuildingComplexity + randSrc.Intn(settings.MaxBuildingComplexity-settings.MinBuildingComplexity+1)
@@ -177,7 +169,6 @@ func getProceduralBuildingPixels(center image.Point, size float64, settings *Set
newCenter = center newCenter = center
buildingCenter = center buildingCenter = center
} else { } else {
// Place subsequent components near existing ones
prevShape := shapeDescriptions[randSrc.Intn(len(shapeDescriptions))] prevShape := shapeDescriptions[randSrc.Intn(len(shapeDescriptions))]
angle := randSrc.Float64() * 2 * math.Pi angle := randSrc.Float64() * 2 * math.Pi
dist := componentSize * (0.25 + randSrc.Float64()*0.5) // Overlap between 25% and 75% dist := componentSize * (0.25 + randSrc.Float64()*0.5) // Overlap between 25% and 75%
@@ -189,7 +180,6 @@ func getProceduralBuildingPixels(center image.Point, size float64, settings *Set
shapeDescriptions = append(shapeDescriptions, shapeDescription{shape, newCenter, componentSize}) shapeDescriptions = append(shapeDescriptions, shapeDescription{shape, newCenter, componentSize})
} }
// Find the bounding box of the unscaled building
var minX, minY, maxX, maxY int var minX, minY, maxX, maxY int
for i, sd := range shapeDescriptions { for i, sd := range shapeDescriptions {
halfSize := int(sd.size / 2) halfSize := int(sd.size / 2)
@@ -252,7 +242,6 @@ func scalePixels(pixels []image.Point, finalSize float64) []image.Point {
return pixels return pixels
} }
// Find the bounding box of the pixels
minX, minY := pixels[0].X, pixels[0].Y minX, minY := pixels[0].X, pixels[0].Y
maxX, maxY := pixels[0].X, pixels[0].Y maxX, maxY := pixels[0].X, pixels[0].Y
for _, p := range pixels { for _, p := range pixels {
@@ -274,7 +263,6 @@ func scalePixels(pixels []image.Point, finalSize float64) []image.Point {
currentWidth := float64(maxX - minX) currentWidth := float64(maxX - minX)
currentHeight := float64(maxY - minY) currentHeight := float64(maxY - minY)
// Determine the scaling factor
scale := finalSize / math.Max(currentWidth, currentHeight) scale := finalSize / math.Max(currentWidth, currentHeight)
// Calculate the center of the bounding box // Calculate the center of the bounding box
@@ -351,7 +339,6 @@ func getComponentPixels(center image.Point, size float64, shape string, randSrc
// chooseShape selects a building shape based on the provided ratios. // chooseShape selects a building shape based on the provided ratios.
func chooseShape(randSrc *rand.Rand, ratios map[string]float64) string { func chooseShape(randSrc *rand.Rand, ratios map[string]float64) string {
// Create a slice of shapes and their cumulative weights
var shapes []string var shapes []string
var weights []float64 var weights []float64
var cumulativeWeight float64 var cumulativeWeight float64
@@ -364,7 +351,6 @@ func chooseShape(randSrc *rand.Rand, ratios map[string]float64) string {
// Generate a random number between 0 and the total weight // Generate a random number between 0 and the total weight
randNum := randSrc.Float64() * cumulativeWeight randNum := randSrc.Float64() * cumulativeWeight
// Find the shape corresponding to the random number
for i, weight := range weights { for i, weight := range weights {
if randNum < weight { if randNum < weight {
return shapes[i] return shapes[i]
@@ -407,7 +393,6 @@ func getBuildingPixels(center image.Point, size float64, shape string, isWater,
} }
} }
case "rectangles": case "rectangles":
// Create rectangles with varied aspect ratios
longSide := size longSide := size
shortSide := randSrc.Float64()*(size-float64(halfSize)) + float64(halfSize) shortSide := randSrc.Float64()*(size-float64(halfSize)) + float64(halfSize)
var w, h int var w, h int
@@ -418,7 +403,6 @@ func getBuildingPixels(center image.Point, size float64, shape string, isWater,
} }
halfW, halfH := w/2, h/2 halfW, halfH := w/2, h/2
// Check for collisions and gather pixels
for y := center.Y - halfH; y <= center.Y+halfH; y++ { for y := center.Y - halfH; y <= center.Y+halfH; y++ {
for x := center.X - halfW; x <= center.X+halfW; x++ { for x := center.X - halfW; x <= center.X+halfW; x++ {
p := image.Point{X: x, Y: y} p := image.Point{X: x, Y: y}
@@ -453,7 +437,6 @@ func FlattenBuildingAreas(heightMap *image.RGBA, buildings [][]image.Point, widt
return heightMap return heightMap
} }
// Create a copy of the heightmap to avoid modifying the original during processing.
newHeightMap := image.NewRGBA(heightMap.Bounds()) newHeightMap := image.NewRGBA(heightMap.Bounds())
copy(newHeightMap.Pix, heightMap.Pix) copy(newHeightMap.Pix, heightMap.Pix)
@@ -478,7 +461,6 @@ func FlattenBuildingAreas(heightMap *image.RGBA, buildings [][]image.Point, widt
newHeightMap.Set(p.X, p.Y, avgColor) newHeightMap.Set(p.X, p.Y, avgColor)
} }
// Create a buffer around the building.
buffer := make([]image.Point, 0) buffer := make([]image.Point, 0)
for _, p := range building { for _, p := range building {
for y := p.Y - 5; y <= p.Y+5; y++ { for y := p.Y - 5; y <= p.Y+5; y++ {
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
// numericInputSlider is a custom widget that combines a slider and a text entry for numeric input. // numericInputSlider combines a slider and text entry for numeric input
type numericInputSlider struct { type numericInputSlider struct {
widget.BaseWidget widget.BaseWidget
value binding.Float value binding.Float
@@ -52,7 +52,7 @@ func (r *numericInputSliderRenderer) Refresh() {
func (r *numericInputSliderRenderer) Destroy() {} func (r *numericInputSliderRenderer) Destroy() {}
// newNumericInputSlider creates a new numericInputSlider widget. // newNumericInputSlider creates a new numericInputSlider widget
func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider { func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider {
s := &numericInputSlider{ s := &numericInputSlider{
min: min, min: min,
@@ -80,7 +80,7 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string
return s return s
} }
// validate checks the 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.TrimSuffix(text, "px") text = strings.TrimSuffix(text, "px")
text = strings.TrimSuffix(text, "%") text = strings.TrimSuffix(text, "%")
@@ -104,7 +104,7 @@ func (s *numericInputSlider) validate(text string, onError func(bool)) {
s.value.Set(val) s.value.Set(val)
} }
// CreateRenderer is a method required by the Fyne toolkit to render the widget. // CreateRenderer renders the widget as required by Fyne toolkit
func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer { func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer {
r := &numericInputSliderRenderer{ r := &numericInputSliderRenderer{
slider: s, slider: s,
+25 -129
View File
@@ -11,81 +11,35 @@ import (
"github.com/ojrac/opensimplex-go" "github.com/ojrac/opensimplex-go"
) )
// rivers.go
//
// New river roughening implementation that uses the heightmap to clip river edges,
// occasionally creates islands, and is designed to be efficient and multithreadable.
//
// This file exposes one main function intended to be called from the river generation
// pipeline in place of per-pixel painting: `RasterizeAndRoughenRiver`. It:
// - rasterizes the river centerline into a local mask (bounding box)
// - computes a fast distance field (chamfer approximation) from the centerline
// - evaluates a heightmap-aware stochastic rule to remove/add edge pixels to roughen
// - occasionally grows islands inside the river
// - writes final water pixels back to the provided canvas and updates the provided isWater map
//
// Usage (conceptual):
// addedPixels := RasterizeAndRoughenRiver(canvas, path, riverWidthPx, heightmap, isWater, seed)
//
// NOTE: Because the project already contained a `drawCircle` helper, this new pipeline
// is implemented as standalone routines in this file. To use it, replace the existing
// per-circle painting logic in `GenerateRivers` with a call to `RasterizeAndRoughenRiver`.
//
// The parameters below were chosen conservatively; tweak them to taste.
type riverParams struct { type riverParams struct {
EdgeBandRatio float64 // fraction of river radius used for roughening band (e.g. 0.6) EdgeBandRatio float64
RoughnessStrength float64 // 0..1 how aggressive clipping is at the edge RoughnessStrength float64
IslandAttemptProb float64 // chance per-river to attempt islands IslandAttemptProb float64
IslandSeedChance float64 // chance per-water-pixel to become an island seed candidate IslandSeedChance float64
MinIslandSize int // minimum island pixel count MinIslandSize int
MaxIslandSize int // maximum island pixel count MaxIslandSize int
WaterLevelBias float64 // baseline water level in normalized height units [0..1]; small bias subtracted to favor water WaterLevelBias float64
NoiseFrequency float64 // frequency for simplex noise NoiseFrequency float64
KeepInnerFraction float64 // fraction of inner radius always kept as channel (0..1) KeepInnerFraction float64
MaxWorkers int // concurrency limit (0 means runtime.NumCPU()) MaxWorkers int
MinWidthPx float64 // minimum river width in pixels (for sin wave amplitude calculation) MinWidthPx float64
MaxWidthPx float64 // maximum river width in pixels (for sin wave amplitude calculation) MaxWidthPx float64
} }
// computeSinWaveEdgeOffset computes the radial offset for river edge roughening // computeSinWaveEdgeOffset computes dual sine wave edge roughening for realistic river banks
// using dual sine waves. The larger wave has amplitude based on the difference
// between max and min river widths, and the smaller wave is a quarter of that amplitude.
// This creates realistic undulating river banks with both large and small-scale variations.
func computeSinWaveEdgeOffset(absX, absY int, largeAmplitude, smallAmplitude float64) float64 { func computeSinWaveEdgeOffset(absX, absY int, largeAmplitude, smallAmplitude float64) float64 {
// Use position to create phase for the sine waves
// Position phase creates variation as we move through the image
positionPhase := float64(absX)*0.008 + float64(absY)*0.012 positionPhase := float64(absX)*0.008 + float64(absY)*0.012
// Large wave: slower frequency for major width variations along the bank
largeWave := math.Sin(positionPhase) * largeAmplitude largeWave := math.Sin(positionPhase) * largeAmplitude
// Small wave: faster frequency for subtle and natural bank details
smallWave := math.Sin(positionPhase*3.5) * smallAmplitude smallWave := math.Sin(positionPhase*3.5) * smallAmplitude
// Return combined offset
return largeWave + smallWave return largeWave + smallWave
} }
// RasterizeAndRoughenRiver rasterizes a river path, roughens edges using the heightmap and dual sin waves, // RasterizeAndRoughenRiver rasterizes a river path with natural edge roughening and optional islands
// optionally creates islands, paints the final water into `canvas`, and marks pixels in `isWater`.
// It returns a slice of image.Point containing all newly added water pixels for this river.
//
// Parameters:
// - canvas: destination image (will be modified)
// - path: ordered centerline points for the river
// - riverWidthPx: nominal width in pixels
// - heightmap: heightmap image used to guide roughening (expects 0..1 grayscale via RGBA() conversion)
// - isWater: map used to record already-water pixels (prevents painting over lakes/rivers). This map will be updated.
// - seed: random seed to make generation deterministic
// - minWidthPx: minimum river width in pixels (used for sin wave amplitude calculation)
// - maxWidthPx: maximum river width in pixels (used for sin wave amplitude calculation)
func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidthPx float64, heightmap image.Image, isWater map[image.Point]bool, seed int64, minWidthPx, maxWidthPx float64) []image.Point { func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidthPx float64, heightmap image.Image, isWater map[image.Point]bool, seed int64, minWidthPx, maxWidthPx float64) []image.Point {
if canvas == nil || len(path) == 0 || riverWidthPx <= 0 { if canvas == nil || len(path) == 0 || riverWidthPx <= 0 {
return nil return nil
} }
// Default parameters - tweak as needed
params := riverParams{ params := riverParams{
EdgeBandRatio: 0.6, EdgeBandRatio: 0.6,
RoughnessStrength: 0.65, RoughnessStrength: 0.65,
@@ -95,7 +49,7 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
MaxIslandSize: 800, MaxIslandSize: 800,
WaterLevelBias: 0.02, WaterLevelBias: 0.02,
NoiseFrequency: 0.02, NoiseFrequency: 0.02,
KeepInnerFraction: 0.85, // keep central 85% of radius KeepInnerFraction: 0.85,
MaxWorkers: 0, MaxWorkers: 0,
MinWidthPx: minWidthPx, MinWidthPx: minWidthPx,
MaxWidthPx: maxWidthPx, MaxWidthPx: maxWidthPx,
@@ -104,10 +58,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
bounds := canvas.Bounds() bounds := canvas.Bounds()
imgW, imgH := bounds.Dx(), bounds.Dy() imgW, imgH := bounds.Dx(), bounds.Dy()
// Precompute normalized height grid for faster sampling.
heightGrid := precomputeHeightGrid(heightmap, imgW, imgH) heightGrid := precomputeHeightGrid(heightmap, imgW, imgH)
// Compute bounding box for path expanded by radius + edge band
radius := riverWidthPx / 2.0 radius := riverWidthPx / 2.0
edgeBand := radius * params.EdgeBandRatio edgeBand := radius * params.EdgeBandRatio
expand := int(math.Ceil(radius + edgeBand + 2)) expand := int(math.Ceil(radius + edgeBand + 2))
@@ -151,11 +103,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
return nil return nil
} }
// Create base raster mask inside bounding box.
// baseMask[i] == 1 means inside nominal river radius (before roughening).
baseMask := make([]uint8, bw*bh) baseMask := make([]uint8, bw*bh)
// Rasterize simple circular stamping for each center point into baseMask
radiusSq := radius * radius radiusSq := radius * radius
for _, c := range path { for _, c := range path {
cx := c.X - minX cx := c.X - minX
@@ -178,20 +127,15 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
} }
} }
// Compute distance field (approximate Euclidean) from centerline (distance 0 at pixels inside baseMask)
dist := chamferDistanceField(baseMask, bw, bh) dist := chamferDistanceField(baseMask, bw, bh)
// Prepare noise generator
noise := opensimplex.New(seed) noise := opensimplex.New(seed)
noiseFreq := params.NoiseFrequency noiseFreq := params.NoiseFrequency
// Determine inner keep radius (always keep central channel)
innerKeepRadius := radius * params.KeepInnerFraction innerKeepRadius := radius * params.KeepInnerFraction
// Prepare final mask
finalMask := make([]uint8, bw*bh) finalMask := make([]uint8, bw*bh)
// Concurrency setup
workers := params.MaxWorkers workers := params.MaxWorkers
if workers <= 0 { if workers <= 0 {
workers = runtime.NumCPU() workers = runtime.NumCPU()
@@ -200,18 +144,13 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
rowsPerWorker := (bh + workers - 1) / workers rowsPerWorker := (bh + workers - 1) / workers
randBase := rand.New(rand.NewSource(seed)) randBase := rand.New(rand.NewSource(seed))
// Precompute some weights for the decision formula
heightWeight := 2.0 * params.RoughnessStrength heightWeight := 2.0 * params.RoughnessStrength
distWeight := params.RoughnessStrength distWeight := params.RoughnessStrength
noiseWeight := 0.5 * params.RoughnessStrength noiseWeight := 0.5 * params.RoughnessStrength
// Compute sin wave amplitudes for realistic edge roughening
// Large amplitude is the difference between max and min river widths
// Small amplitude is a quarter of the large amplitude for subtle bank details
largeAmplitude := params.MaxWidthPx - params.MinWidthPx largeAmplitude := params.MaxWidthPx - params.MinWidthPx
smallAmplitude := largeAmplitude / 4.0 smallAmplitude := largeAmplitude / 4.0
// Evaluate per-pixel decision in parallel
for wi := 0; wi < workers; wi++ { for wi := 0; wi < workers; wi++ {
startY := wi * rowsPerWorker startY := wi * rowsPerWorker
endY := startY + rowsPerWorker endY := startY + rowsPerWorker
@@ -228,11 +167,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
for y := startY; y < endY; y++ { for y := startY; y < endY; y++ {
for x := 0; x < bw; x++ { for x := 0; x < bw; x++ {
idx := y*bw + x idx := y*bw + x
// If already inside base mask, candidate for water
if baseMask[idx] == 1 { if baseMask[idx] == 1 {
// If within inner keep radius: keep always
d := dist[idx] d := dist[idx]
// dist is approximate pixels; we compare to innerKeepRadius
absX := x + minX absX := x + minX
absY := y + minY absY := y + minY
if d <= float32(innerKeepRadius) { if d <= float32(innerKeepRadius) {
@@ -240,12 +176,9 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
continue continue
} }
// Apply sin wave offset for realistic edge roughening
sinWaveOffset := computeSinWaveEdgeOffset(absX, absY, largeAmplitude, smallAmplitude) sinWaveOffset := computeSinWaveEdgeOffset(absX, absY, largeAmplitude, smallAmplitude)
effectiveInnerRadius := innerKeepRadius + sinWaveOffset effectiveInnerRadius := innerKeepRadius + sinWaveOffset
// Compute influences
// normalizedDist: 0 at effectiveInnerRadius, 1 at effectiveInnerRadius + edgeBand
normDist := float64((float32(d) - float32(effectiveInnerRadius)) / float32(edgeBand)) normDist := float64((float32(d) - float32(effectiveInnerRadius)) / float32(edgeBand))
if normDist < 0 { if normDist < 0 {
normDist = 0 normDist = 0
@@ -254,18 +187,15 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
normDist = 1 normDist = 1
} }
heightVal := sampleHeightGrid(heightGrid, imgW, imgH, absX, absY) // 0..1 heightVal := sampleHeightGrid(heightGrid, imgW, imgH, absX, absY)
// Apply bias so slightly lower areas favor water
heightAdj := float64(heightVal) - params.WaterLevelBias heightAdj := float64(heightVal) - params.WaterLevelBias
noiseVal := noise.Eval2(float64(absX)*noiseFreq, float64(absY)*noiseFreq) // -1 .. 1 noiseVal := noise.Eval2(float64(absX)*noiseFreq, float64(absY)*noiseFreq)
noiseNorm := (noiseVal + 1.0) / 2.0 // 0..1 noiseNorm := (noiseVal + 1.0) / 2.0
score := distWeight*normDist + heightWeight*heightAdj + noiseWeight*(noiseNorm-0.5) score := distWeight*normDist + heightWeight*heightAdj + noiseWeight*(noiseNorm-0.5)
// Decision threshold: higher score means more likely land.
threshold := 0.35 + 0.5*params.RoughnessStrength threshold := 0.35 + 0.5*params.RoughnessStrength
// Small stochastic factor to add natural variance
if localRand.Float64() < 0.0005 { if localRand.Float64() < 0.0005 {
score += (localRand.Float64() - 0.5) * 0.2 score += (localRand.Float64() - 0.5) * 0.2
} }
@@ -282,14 +212,12 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
} }
wg.Wait() wg.Wait()
// Optionally attempt islands with small probability
randForIsland := rand.New(rand.NewSource(seed + 1234567)) randForIsland := rand.New(rand.NewSource(seed + 1234567))
tryIslands := randForIsland.Float64() < params.IslandAttemptProb tryIslands := randForIsland.Float64() < params.IslandAttemptProb
if tryIslands { if tryIslands {
generateIslandsInMask(finalMask, bw, bh, minX, minY, heightGrid, imgW, imgH, &params, seed+4242) generateIslandsInMask(finalMask, bw, bh, minX, minY, heightGrid, imgW, imgH, &params, seed+4242)
} }
// Paint finalMask to canvas and collect pixels (only those not already water)
var added []image.Point var added []image.Point
for y := 0; y < bh; y++ { for y := 0; y < bh; y++ {
absY := y + minY absY := y + minY
@@ -308,17 +236,15 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
} }
} }
// Small cleanup: remove tiny isolated water pixels (optional - lightweight)
removeSpeckles(&finalMask, bw, bh, 2) removeSpeckles(&finalMask, bw, bh, 2)
return added return added
} }
// precomputeHeightGrid converts the heightmap to a float32 grid [0..1] sized width*height. // precomputeHeightGrid converts heightmap to normalized float32 grid
func precomputeHeightGrid(hmap image.Image, width, height int) []float32 { func precomputeHeightGrid(hmap image.Image, width, height int) []float32 {
out := make([]float32, width*height) out := make([]float32, width*height)
if hmap == nil { if hmap == nil {
// default flat
for i := range out { for i := range out {
out[i] = 0.5 out[i] = 0.5
} }
@@ -337,7 +263,7 @@ func precomputeHeightGrid(hmap image.Image, width, height int) []float32 {
return out return out
} }
// sampleHeightGrid safe accessor // sampleHeightGrid safely samples height at coordinates
func sampleHeightGrid(grid []float32, width, height, x, y int) float32 { func sampleHeightGrid(grid []float32, width, height, x, y int) float32 {
if x < 0 || x >= width || y < 0 || y >= height { if x < 0 || x >= width || y < 0 || y >= height {
return 0.5 return 0.5
@@ -345,14 +271,11 @@ func sampleHeightGrid(grid []float32, width, height, x, y int) float32 {
return grid[y*width+x] return grid[y*width+x]
} }
// chamferDistanceField computes a fast approximate distance (in pixels) from any pixel to the nearest // chamferDistanceField computes fast approximate distance from any pixel to centerline
// baseMask==1 pixel. Distance is zero for pixels inside baseMask.
// This is a two-pass chamfer approximation (float), cheap and parallel friendly.
func chamferDistanceField(baseMask []uint8, w, h int) []float32 { func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
const maxF = 1e6 const maxF = 1e6
dist := make([]float32, w*h) dist := make([]float32, w*h)
// Initialize
for i := 0; i < w*h; i++ { for i := 0; i < w*h; i++ {
if baseMask[i] == 1 { if baseMask[i] == 1 {
dist[i] = 0 dist[i] = 0
@@ -368,28 +291,24 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
if dist[i] == 0 { if dist[i] == 0 {
continue continue
} }
// check left
if x > 0 { if x > 0 {
v := dist[i-1] + 1.0 v := dist[i-1] + 1.0
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check top
if y > 0 { if y > 0 {
v := dist[i-w] + 1.0 v := dist[i-w] + 1.0
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check top-left
if x > 0 && y > 0 { if x > 0 && y > 0 {
v := dist[i-w-1] + 1.41421356 v := dist[i-w-1] + 1.41421356
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check top-right
if x < w-1 && y > 0 { if x < w-1 && y > 0 {
v := dist[i-w+1] + 1.41421356 v := dist[i-w+1] + 1.41421356
if v < dist[i] { if v < dist[i] {
@@ -403,28 +322,24 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
for y := h - 1; y >= 0; y-- { for y := h - 1; y >= 0; y-- {
for x := w - 1; x >= 0; x-- { for x := w - 1; x >= 0; x-- {
i := y*w + x i := y*w + x
// check right
if x < w-1 { if x < w-1 {
v := dist[i+1] + 1.0 v := dist[i+1] + 1.0
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check bottom
if y < h-1 { if y < h-1 {
v := dist[i+w] + 1.0 v := dist[i+w] + 1.0
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check bottom-right
if x < w-1 && y < h-1 { if x < w-1 && y < h-1 {
v := dist[i+w+1] + 1.41421356 v := dist[i+w+1] + 1.41421356
if v < dist[i] { if v < dist[i] {
dist[i] = v dist[i] = v
} }
} }
// check bottom-left
if x > 0 && y < h-1 { if x > 0 && y < h-1 {
v := dist[i+w-1] + 1.41421356 v := dist[i+w-1] + 1.41421356
if v < dist[i] { if v < dist[i] {
@@ -437,15 +352,9 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
return dist return dist
} }
// generateIslandsInMask will attempt to create small islands inside contiguous water areas. // generateIslandsInMask creates small islands inside water areas
// It modifies the mask in place (1=water, 0=land). The algorithm:
// - choose candidate water pixels with slightly higher-than-water height
// - use a small BFS flood constrained by height to form island patches
// - reject patches that touch the bounding box edge (we want enclosed islands)
// - enforce size limits
func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []float32, fullW, fullH int, params *riverParams, seed int64) { func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []float32, fullW, fullH int, params *riverParams, seed int64) {
r := rand.New(rand.NewSource(seed)) r := rand.New(rand.NewSource(seed))
// Collect candidates
type pt struct{ x, y int } type pt struct{ x, y int }
candidates := make([]pt, 0) candidates := make([]pt, 0)
for y := 0; y < bh; y++ { for y := 0; y < bh; y++ {
@@ -457,7 +366,6 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
absX := x + minX absX := x + minX
absY := y + minY absY := y + minY
hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY) hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY)
// candidate if slightly higher than local water bias
if float64(hv) > params.WaterLevelBias+0.03 { if float64(hv) > params.WaterLevelBias+0.03 {
if r.Float64() < params.IslandSeedChance { if r.Float64() < params.IslandSeedChance {
candidates = append(candidates, pt{x, y}) candidates = append(candidates, pt{x, y})
@@ -469,7 +377,6 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
return return
} }
// Shuffle candidates to randomize island placement
r.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) r.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] })
visited := make([]uint8, bw*bh) visited := make([]uint8, bw*bh)
@@ -479,10 +386,8 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
if visited[ci] != 0 { if visited[ci] != 0 {
continue continue
} }
// BFS grow island
maxSize := params.MaxIslandSize maxSize := params.MaxIslandSize
minSize := params.MinIslandSize minSize := params.MinIslandSize
// randomize size a bit
targetSize := minSize + r.Intn(maxSize-minSize+1) targetSize := minSize + r.Intn(maxSize-minSize+1)
queue := []pt{{c.x, c.y}} queue := []pt{{c.x, c.y}}
@@ -494,13 +399,11 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
p := queue[qi] p := queue[qi]
absX := p.x + minX absX := p.x + minX
absY := p.y + minY absY := p.y + minY
// Height constraint: island must be above a modest threshold
hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY) hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY)
if float64(hv) < params.WaterLevelBias+0.01 { if float64(hv) < params.WaterLevelBias+0.01 {
continue continue
} }
island = append(island, p) island = append(island, p)
// Expand
for dy := -1; dy <= 1; dy++ { for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ { for dx := -1; dx <= 1; dx++ {
nx, ny := p.x+dx, p.y+dy nx, ny := p.x+dx, p.y+dy
@@ -512,7 +415,6 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
if visited[nidx] != 0 { if visited[nidx] != 0 {
continue continue
} }
// Only grow into water pixels
if mask[nidx] != 1 { if mask[nidx] != 1 {
continue continue
} }
@@ -522,24 +424,19 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
} }
} }
// If island touches bbox edge, reject it (we want enclosed islands)
if touchesEdge { if touchesEdge {
continue continue
} }
// size check
if len(island) < minSize { if len(island) < minSize {
continue continue
} }
// Carve the island: set mask pixels to 0 (land)
for _, p := range island { for _, p := range island {
mask[p.y*bw+p.x] = 0 mask[p.y*bw+p.x] = 0
} }
// Optionally stop after creating a few islands to keep them rare
if r.Float64() < 0.7 { if r.Float64() < 0.7 {
// keep creating more sometimes, break otherwise
if r.Intn(3) == 0 { if r.Intn(3) == 0 {
break break
} }
@@ -547,8 +444,7 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
} }
} }
// removeSpeckles removes tiny isolated water components (erodes islands smaller than threshold). // removeSpeckles removes tiny isolated water pixels
// This is a simple pass that clears pixels that have fewer than minNeighbors water neighbors.
func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) { func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) {
arr := *mask arr := *mask
out := make([]uint8, len(arr)) out := make([]uint8, len(arr))
@@ -584,7 +480,7 @@ func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) {
*mask = arr *mask = arr
} }
// (Optional) utility used for debug or visualization - not used directly in pipeline. // maskToPoints converts mask to point slice for visualization
func maskToPoints(mask []uint8, bw, bh, minX, minY int) []image.Point { func maskToPoints(mask []uint8, bw, bh, minX, minY int) []image.Point {
var pts []image.Point var pts []image.Point
for y := 0; y < bh; y++ { for y := 0; y < bh; y++ {
@@ -597,7 +493,7 @@ func maskToPoints(mask []uint8, bw, bh, minX, minY int) []image.Point {
return pts return pts
} }
// small clamp helpers // clamp01 clamps value to 0..1 range
func clamp01(v float64) float64 { func clamp01(v float64) float64 {
if v < 0 { if v < 0 {
return 0 return 0
+17 -42
View File
@@ -11,20 +11,20 @@ import (
"unsafe" "unsafe"
) )
// PointOfInterest represents a location on the map where roads may start, end, or intersect. // PointOfInterest represents a location where roads may start, end, or intersect
type PointOfInterest struct { type PointOfInterest struct {
X, Y int X, Y int
Connections int Connections int
IsExit bool IsExit bool
} }
// PathPoint represents a single point in a road's path, with a flag to indicate if it's a bridge. // PathPoint represents a single point in a road's path with bridge flag
type PathPoint struct { type PathPoint struct {
Point image.Point Point image.Point
IsBridge bool IsBridge bool
} }
// Road represents a connection between two Points of Interest. // Road represents a connection between two Points of Interest
type Road struct { type Road struct {
Start, End *PointOfInterest Start, End *PointOfInterest
Width int Width int
@@ -32,9 +32,8 @@ type Road struct {
Importance int Importance int
} }
// GenerateRoads is the main function for creating roads on the map. // GenerateRoads creates roads on the map
func GenerateRoads(width, height int, settings *Settings, noiseImg image.Image, allWaterPixels []image.Point, seed int64) ([]image.Point, []image.Point, *image.RGBA) { func GenerateRoads(width, height int, settings *Settings, noiseImg image.Image, allWaterPixels []image.Point, seed int64) ([]image.Point, []image.Point, *image.RGBA) {
// Step 1: Initialize a transparent image for drawing roads
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
for x := 0; x < width; x++ { for x := 0; x < width; x++ {
@@ -42,23 +41,18 @@ func GenerateRoads(width, height int, settings *Settings, noiseImg image.Image,
} }
} }
// Step 2: Set up random number generator and colors
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255} roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255}
bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255} bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255}
// Step 3: Generate Points of Interest (POIs)
pois := generatePOIs(width, height, settings, allWaterPixels, randSrc) pois := generatePOIs(width, height, settings, allWaterPixels, randSrc)
if len(pois) == 0 { if len(pois) == 0 {
return nil, nil, img return nil, nil, img
} }
// Step 4: Connect POIs to form roads
roads := connectPOIs(pois, width, height, settings, randSrc, allWaterPixels) roads := connectPOIs(pois, width, height, settings, randSrc, allWaterPixels)
// Step 5: Assign widths to the roads based on their importance
assignRoadWidths(roads, settings) assignRoadWidths(roads, settings)
// Step 6: Draw the roads on the image
var allRoadPixels []image.Point var allRoadPixels []image.Point
var allBridgePixels []image.Point var allBridgePixels []image.Point
for _, road := range roads { for _, road := range roads {
@@ -70,7 +64,7 @@ func GenerateRoads(width, height int, settings *Settings, noiseImg image.Image,
return allRoadPixels, allBridgePixels, img return allRoadPixels, allBridgePixels, img
} }
// generatePOIs creates the initial set of points where roads will originate. // generatePOIs creates initial points where roads will originate
func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.Point, randSrc *rand.Rand) []*PointOfInterest { func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.Point, randSrc *rand.Rand) []*PointOfInterest {
numPOIs := settings.NumRoads / 2 numPOIs := settings.NumRoads / 2
if numPOIs == 0 { if numPOIs == 0 {
@@ -91,33 +85,30 @@ func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.
centerX := width / 2 centerX := width / 2
centerY := height / 2 centerY := height / 2
// Distribution affects the radius of POI generation
maxRadius := math.Min(float64(width)/2, float64(height)/2) maxRadius := math.Min(float64(width)/2, float64(height)/2)
radius := maxRadius * (settings.RoadDistribution / 100.0) radius := maxRadius * (settings.RoadDistribution / 100.0)
for i := 0; i < numPOIs; i++ { for i := 0; i < numPOIs; i++ {
var x, y int var x, y int
found := false found := false
for j := 0; j < 100; j++ { // Retries to find a land spot for j := 0; j < 100; j++ {
if i < numExits { if i < numExits {
// Create POIs at the map edges
side := randSrc.Intn(4) side := randSrc.Intn(4)
switch side { switch side {
case 0: // Top case 0:
x = randSrc.Intn(width) x = randSrc.Intn(width)
y = 0 y = 0
case 1: // Bottom case 1:
x = randSrc.Intn(width) x = randSrc.Intn(width)
y = height - 1 y = height - 1
case 2: // Left case 2:
x = 0 x = 0
y = randSrc.Intn(height) y = randSrc.Intn(height)
case 3: // Right case 3:
x = width - 1 x = width - 1
y = randSrc.Intn(height) y = randSrc.Intn(height)
} }
} else { } else {
// Create POIs within the map
angle := randSrc.Float64() * 2 * math.Pi angle := randSrc.Float64() * 2 * math.Pi
r := math.Sqrt(randSrc.Float64()) * radius r := math.Sqrt(randSrc.Float64()) * radius
x = int(float64(centerX) + r*math.Cos(angle)) x = int(float64(centerX) + r*math.Cos(angle))
@@ -138,7 +129,7 @@ func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.
return pois return pois
} }
// connectPOIs creates roads by connecting the generated Points of Interest. // connectPOIs creates roads by connecting Points of Interest
func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, allWaterPixels []image.Point) []*Road { func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, allWaterPixels []image.Point) []*Road {
if len(pois) < 2 { if len(pois) < 2 {
return nil return nil
@@ -151,7 +142,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
visited := make(map[*PointOfInterest]bool) visited := make(map[*PointOfInterest]bool)
existingRoads := make(map[string]bool) existingRoads := make(map[string]bool)
// Find the center-most POI to start connecting from
centerX := width / 2 centerX := width / 2
centerY := height / 2 centerY := height / 2
var startNode *PointOfInterest var startNode *PointOfInterest
@@ -174,11 +164,9 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
visited[startNode] = true visited[startNode] = true
// Use average dimension for controlling road path calculation
avgDim := float64(width+height) / 2.0 avgDim := float64(width+height) / 2.0
numControlPoints := max(int(avgDim*0.03), 60) numControlPoints := max(int(avgDim*0.03), 60)
// Connect all POIs using a minimum spanning tree-like algorithm
for len(visited) < len(pois) { for len(visited) < len(pois) {
var closest *PointOfInterest var closest *PointOfInterest
var fromNode *PointOfInterest var fromNode *PointOfInterest
@@ -192,7 +180,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
if !visited[other] { if !visited[other] {
dist := math.Sqrt(math.Pow(float64(poi.X-other.X), 2) + math.Pow(float64(poi.Y-other.Y), 2)) dist := math.Sqrt(math.Pow(float64(poi.X-other.X), 2) + math.Pow(float64(poi.Y-other.Y), 2))
// Check if a road already exists between these two POIs
key := fmt.Sprintf("%p-%p", poi, other) key := fmt.Sprintf("%p-%p", poi, other)
if uintptr(unsafe.Pointer(poi)) > uintptr(unsafe.Pointer(other)) { if uintptr(unsafe.Pointer(poi)) > uintptr(unsafe.Pointer(other)) {
key = fmt.Sprintf("%p-%p", other, poi) key = fmt.Sprintf("%p-%p", other, poi)
@@ -201,7 +188,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
continue continue
} }
// Avoid connecting two exit points directly
if poi.IsExit && other.IsExit { if poi.IsExit && other.IsExit {
continue continue
} }
@@ -220,7 +206,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
fromNode.Connections++ fromNode.Connections++
closest.Connections++ closest.Connections++
// Add road to existing roads map to prevent duplicates
key := fmt.Sprintf("%p-%p", fromNode, closest) key := fmt.Sprintf("%p-%p", fromNode, closest)
if uintptr(unsafe.Pointer(fromNode)) > uintptr(unsafe.Pointer(closest)) { if uintptr(unsafe.Pointer(fromNode)) > uintptr(unsafe.Pointer(closest)) {
key = fmt.Sprintf("%p-%p", closest, fromNode) key = fmt.Sprintf("%p-%p", closest, fromNode)
@@ -238,7 +223,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
} }
}(fromNode, closest) }(fromNode, closest)
} else { } else {
// No more reachable POIs, break the loop
break break
} }
} }
@@ -252,7 +236,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
roads = append(roads, road) roads = append(roads, road)
} }
// Calculate road importance based on the number of connections at its endpoints
for _, road := range roads { for _, road := range roads {
road.Importance = road.Start.Connections + road.End.Connections road.Importance = road.Start.Connections + road.End.Connections
} }
@@ -260,13 +243,12 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
return roads return roads
} }
// assignRoadWidths sets the width of each road based on its importance. // assignRoadWidths sets road width based on importance
func assignRoadWidths(roads []*Road, settings *Settings) { func assignRoadWidths(roads []*Road, settings *Settings) {
if len(roads) == 0 { if len(roads) == 0 {
return return
} }
// Sort roads by importance in descending order
sort.Slice(roads, func(i, j int) bool { sort.Slice(roads, func(i, j int) bool {
return roads[i].Importance > roads[j].Importance return roads[i].Importance > roads[j].Importance
}) })
@@ -278,13 +260,12 @@ func assignRoadWidths(roads []*Road, settings *Settings) {
widthStep = (maxWidth - minWidth) / float64(len(roads)-1) widthStep = (maxWidth - minWidth) / float64(len(roads)-1)
} }
// Assign widths, with more important roads being wider
for i, road := range roads { for i, road := range roads {
road.Width = int(maxWidth - float64(i)*widthStep) road.Width = int(maxWidth - float64(i)*widthStep)
} }
} }
// drawRoad draws a single road on the image, including bridges. // drawRoad draws a single road on the image including bridges
func drawRoad(img *image.RGBA, points []PathPoint, roadColor, bridgeColor color.Color, width int) ([]image.Point, []image.Point) { func drawRoad(img *image.RGBA, points []PathPoint, roadColor, bridgeColor color.Color, width int) ([]image.Point, []image.Point) {
var roadPixels []image.Point var roadPixels []image.Point
var bridgePixels []image.Point var bridgePixels []image.Point
@@ -306,7 +287,7 @@ func drawRoad(img *image.RGBA, points []PathPoint, roadColor, bridgeColor color.
return roadPixels, bridgePixels return roadPixels, bridgePixels
} }
// bresenhamRoad uses Bresenham's line algorithm to create a path between control points. // bresenhamRoad creates a path between control points using Bresenham's algorithm
func bresenhamRoad(path []image.Point) []image.Point { func bresenhamRoad(path []image.Point) []image.Point {
if len(path) < 2 { if len(path) < 2 {
return path return path
@@ -346,7 +327,7 @@ func bresenhamRoad(path []image.Point) []image.Point {
return fullPath return fullPath
} }
// calculateRoadPath computes the path for a road, including curves and bridges. // calculateRoadPath computes the path for a road including curves and bridges
func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, numControlPoints int, allWaterPixels []image.Point) []PathPoint { func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, numControlPoints int, allWaterPixels []image.Point) []PathPoint {
dx := end.X - start.X dx := end.X - start.X
dy := end.Y - start.Y dy := end.Y - start.Y
@@ -361,7 +342,6 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
return []PathPoint{{Point: image.Point{X: start.X, Y: start.Y}, IsBridge: waterMap[image.Point{X: start.X, Y: start.Y}]}} return []PathPoint{{Point: image.Point{X: start.X, Y: start.Y}, IsBridge: waterMap[image.Point{X: start.X, Y: start.Y}]}}
} }
// Adjust curviness based on the distance between the POIs
distanceFactor := math.Min(1.0, dist/(avgDim*0.5)) distanceFactor := math.Min(1.0, dist/(avgDim*0.5))
adjustedCurvyness := curvyness * distanceFactor adjustedCurvyness := curvyness * distanceFactor
@@ -374,7 +354,6 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
return pathPoints return pathPoints
} }
// Use sine waves to create curves in the road
type wave struct { type wave struct {
amplitude float64 amplitude float64
numWaves float64 numWaves float64
@@ -389,21 +368,18 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
} }
baseNumWaves := (dist / mainWavelength) * adjustedCurvyness baseNumWaves := (dist / mainWavelength) * adjustedCurvyness
// Main wave for overall curve
waves[0] = wave{ waves[0] = wave{
amplitude: amp, amplitude: amp,
numWaves: baseNumWaves * (0.75 + randSrc.Float64()*0.5), numWaves: baseNumWaves * (0.75 + randSrc.Float64()*0.5),
phase: randSrc.Float64() * 2 * math.Pi, phase: randSrc.Float64() * 2 * math.Pi,
} }
// Smaller wave for minor detours and a more natural look
waves[1] = wave{ waves[1] = wave{
amplitude: amp / 4, amplitude: amp / 4,
numWaves: baseNumWaves * 4 * (0.75 + randSrc.Float64()*0.5), numWaves: baseNumWaves * 4 * (0.75 + randSrc.Float64()*0.5),
phase: randSrc.Float64() * 2 * math.Pi, phase: randSrc.Float64() * 2 * math.Pi,
} }
// Generate control points for the curve
controlPoints := make([]image.Point, numControlPoints+1) controlPoints := make([]image.Point, numControlPoints+1)
for i := 0; i <= numControlPoints; i++ { for i := 0; i <= numControlPoints; i++ {
t := float64(i) / float64(numControlPoints) t := float64(i) / float64(numControlPoints)
@@ -426,7 +402,6 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
controlPoints[i] = image.Point{X: int(math.Round(x)), Y: int(math.Round(y))} controlPoints[i] = image.Point{X: int(math.Round(x)), Y: int(math.Round(y))}
} }
// Create the final path using Bresenham's algorithm between control points
points := bresenhamRoad(controlPoints) points := bresenhamRoad(controlPoints)
pathPoints := make([]PathPoint, len(points)) pathPoints := make([]PathPoint, len(points))
for i, p := range points { for i, p := range points {
@@ -435,7 +410,7 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
return pathPoints return pathPoints
} }
// drawLine draws a line with a specified width on the image. // drawLine draws a line with specified width on the image
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int) []image.Point { func drawLine(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int) []image.Point {
var points []image.Point var points []image.Point
dx := abs(x1 - x0) dx := abs(x1 - x0)
@@ -478,7 +453,7 @@ func drawLine(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int) [
return points return points
} }
// abs returns the absolute value of an integer. // abs returns the absolute value of an integer
func abs(x int) int { func abs(x int) int {
if x < 0 { if x < 0 {
return -x return -x
+3 -5
View File
@@ -2,21 +2,19 @@ package main
import "math/rand" import "math/rand"
// SeedProvider is a simple struct that provides a stream of random seeds // SeedProvider provides a stream of random seeds from a single initial seed
// from a single initial seed. This ensures that the entire map generation
// process is deterministic if the same initial seed is used.
type SeedProvider struct { type SeedProvider struct {
rand *rand.Rand rand *rand.Rand
} }
// NewSeedProvider creates a new SeedProvider with the given initial seed. // NewSeedProvider creates a new SeedProvider with the given initial seed
func NewSeedProvider(seed int64) *SeedProvider { func NewSeedProvider(seed int64) *SeedProvider {
return &SeedProvider{ return &SeedProvider{
rand: rand.New(rand.NewSource(seed)), rand: rand.New(rand.NewSource(seed)),
} }
} }
// Next returns the next random seed in the sequence. // Next returns the next random seed in the sequence
func (sp *SeedProvider) Next() int64 { func (sp *SeedProvider) Next() int64 {
return sp.rand.Int63() return sp.rand.Int63()
} }
+8 -29
View File
@@ -14,16 +14,14 @@ import (
"github.com/ojrac/opensimplex-go" "github.com/ojrac/opensimplex-go"
) )
// Constants for Perlin noise generation
const ( const (
alpha = 2. alpha = 2.
beta = 2. beta = 2.
n = 3 n = 3
) )
// GenerateHeightmap creates a grayscale image representing the terrain's elevation using Perlin noise. // GenerateHeightmap creates terrain elevation using Perlin noise
func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) image.Image { func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) image.Image {
// Initialize Perlin noise generator
p := perlin.NewPerlin(alpha, beta, n, seed) p := perlin.NewPerlin(alpha, beta, n, seed)
img := image.NewGray(image.Rect(0, 0, width, height)) img := image.NewGray(image.Rect(0, 0, width, height))
@@ -31,7 +29,6 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
scale = 100.0 scale = 100.0
} }
// Use multiple goroutines to speed up noise generation
numGoroutines := runtime.NumCPU() numGoroutines := runtime.NumCPU()
var wg sync.WaitGroup var wg sync.WaitGroup
rowsPerGoroutine := height / numGoroutines rowsPerGoroutine := height / numGoroutines
@@ -47,7 +44,6 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
defer wg.Done() defer wg.Done()
for y := startY; y < endY; y++ { for y := startY; y < endY; y++ {
for x := 0; x < width; x++ { for x := 0; x < width; x++ {
// Combine multiple octaves of noise for more detail
var noise float64 var noise float64
frequency := 1.0 frequency := 1.0
amplitude := 1.0 amplitude := 1.0
@@ -60,7 +56,6 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
frequency *= 2.0 frequency *= 2.0
} }
// Normalize the noise value and set the pixel color
noise /= maxAmplitude noise /= maxAmplitude
grayColor := uint8((noise + 1) * 127.5) grayColor := uint8((noise + 1) * 127.5)
img.SetGray(x, y, color.Gray{Y: grayColor}) img.SetGray(x, y, color.Gray{Y: grayColor})
@@ -73,13 +68,12 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
return img return img
} }
// ApplyRoughness adds a visual roughness effect to the heightmap. // ApplyRoughness adds visual roughness effect to the heightmap
func ApplyRoughness(heightmap image.Image, roughness float64) image.Image { func ApplyRoughness(heightmap image.Image, roughness float64) image.Image {
bounds := heightmap.Bounds() bounds := heightmap.Bounds()
composite := image.NewRGBA(bounds) composite := image.NewRGBA(bounds)
draw.Draw(composite, bounds, heightmap, image.Point{}, draw.Src) draw.Draw(composite, bounds, heightmap, image.Point{}, draw.Src)
// The alpha value of the overlay determines the roughness effect
alphaValue := 255 - uint8(roughness*2.55) alphaValue := 255 - uint8(roughness*2.55)
overlay := image.NewUniform(color.RGBA{R: 128, G: 128, B: 128, A: alphaValue}) overlay := image.NewUniform(color.RGBA{R: 128, G: 128, B: 128, A: alphaValue})
draw.Draw(composite, bounds, overlay, image.Point{}, draw.Over) draw.Draw(composite, bounds, overlay, image.Point{}, draw.Over)
@@ -87,23 +81,20 @@ func ApplyRoughness(heightmap image.Image, roughness float64) image.Image {
return composite return composite
} }
// DarkenLakeAreas applies a visual darkening effect to the heightmap where lakes exist. // DarkenLakeAreas darkens the heightmap where lakes exist
func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Image { func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Image {
bounds := heightmap.Bounds() bounds := heightmap.Bounds()
width := bounds.Dx() width := bounds.Dx()
// Create a new black image to draw the lakes on
lakeMask := image.NewRGBA(bounds) lakeMask := image.NewRGBA(bounds)
black := color.RGBA{0, 0, 0, 255} black := color.RGBA{0, 0, 0, 255}
for _, p := range lakePixels { for _, p := range lakePixels {
lakeMask.Set(p.X, p.Y, black) lakeMask.Set(p.X, p.Y, black)
} }
// Apply a Gaussian blur to the lake mask to create smooth edges
blurRadius := float64(width) * 0.05 blurRadius := float64(width) * 0.05
blurredLakeMask := imaging.Blur(lakeMask, blurRadius) blurredLakeMask := imaging.Blur(lakeMask, blurRadius)
// Composite the blurred lake mask onto the heightmap with some opacity
composite := image.NewRGBA(bounds) composite := image.NewRGBA(bounds)
draw.Draw(composite, bounds, heightmap, image.Point{}, draw.Src) draw.Draw(composite, bounds, heightmap, image.Point{}, draw.Src)
draw.DrawMask(composite, bounds, blurredLakeMask, image.Point{}, image.NewUniform(color.Alpha{192}), image.Point{}, draw.Over) draw.DrawMask(composite, bounds, blurredLakeMask, image.Point{}, image.NewUniform(color.Alpha{192}), image.Point{}, draw.Over)
@@ -111,33 +102,27 @@ func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Imag
return composite return composite
} }
// FlattenRoadAreas smoothens the terrain under roads. // FlattenRoadAreas smooths terrain under roads
func FlattenRoadAreas(heightmap image.Image, roadPixels []image.Point) image.Image { func FlattenRoadAreas(heightmap image.Image, roadPixels []image.Point) image.Image {
bounds := heightmap.Bounds() bounds := heightmap.Bounds()
width := bounds.Dx() width := bounds.Dx()
// Create a mask with the road pixels
roadMask := image.NewGray(bounds) roadMask := image.NewGray(bounds)
for _, p := range roadPixels { for _, p := range roadPixels {
roadMask.SetGray(p.X, p.Y, color.Gray{Y: 255}) roadMask.SetGray(p.X, p.Y, color.Gray{Y: 255})
} }
// Blur the road mask to create a smooth transition
blurRadius := float64(width) * 0.01 blurRadius := float64(width) * 0.01
blurredRoadMask := imaging.Blur(roadMask, blurRadius) blurredRoadMask := imaging.Blur(roadMask, blurRadius)
// Blur the entire heightmap
blurredHeightmap := imaging.Blur(heightmap, blurRadius) blurredHeightmap := imaging.Blur(heightmap, blurRadius)
// Create a new composite image
composite := image.NewRGBA(bounds) composite := image.NewRGBA(bounds)
// Interpolate between the original and blurred heightmap based on the road mask
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ { for x := bounds.Min.X; x < bounds.Max.X; x++ {
maskAlpha, _, _, _ := blurredRoadMask.At(x, y).RGBA() maskAlpha, _, _, _ := blurredRoadMask.At(x, y).RGBA()
if maskAlpha > 0 { if maskAlpha > 0 {
// Linearly interpolate between the original and blurred heightmap
originalColor := heightmap.At(x, y) originalColor := heightmap.At(x, y)
blurredColor := blurredHeightmap.At(x, y) blurredColor := blurredHeightmap.At(x, y)
@@ -161,12 +146,11 @@ func FlattenRoadAreas(heightmap image.Image, roadPixels []image.Point) image.Ima
return composite return composite
} }
// GenerateTrees places trees on the map. // GenerateTrees places trees on the map based on coverage and noise
func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []image.Point, minTreeSize, maxTreeSize, treeCoverage, treeClumpiness float64, seed int64) []image.Point { func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []image.Point, minTreeSize, maxTreeSize, treeCoverage, treeClumpiness float64, seed int64) []image.Point {
width := img.Bounds().Dx() width := img.Bounds().Dx()
height := img.Bounds().Dy() height := img.Bounds().Dy()
// Step 1: Calculate the number of trees to place based on coverage percentage.
avgTreeSize := (minTreeSize + maxTreeSize) / 2 avgTreeSize := (minTreeSize + maxTreeSize) / 2
if avgTreeSize <= 0 { if avgTreeSize <= 0 {
return nil return nil
@@ -183,20 +167,18 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
return nil return nil
} }
// Step 2: Generate a simplex noise map to guide tree placement.
noise := opensimplex.New(seed) noise := opensimplex.New(seed)
treeNoiseMap := image.NewGray(image.Rect(0, 0, width, height)) treeNoiseMap := image.NewGray(image.Rect(0, 0, width, height))
treeNoiseZoom := 0.05 treeNoiseZoom := 0.05
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
for x := 0; x < width; x++ { for x := 0; x < width; x++ {
val := noise.Eval2(float64(x)*treeNoiseZoom, float64(y)*treeNoiseZoom) val := noise.Eval2(float64(x)*treeNoiseZoom, float64(y)*treeNoiseZoom)
val = (val + 1) / 2 // Normalize to 0-1 val = (val + 1) / 2
treeNoiseMap.SetGray(x, y, color.Gray{Y: uint8(val * 255)}) treeNoiseMap.SetGray(x, y, color.Gray{Y: uint8(val * 255)})
} }
} }
threshold := uint8(255 * (1 - (treeCoverage / 100.0))) threshold := uint8(255 * (1 - (treeCoverage / 100.0)))
// Create lookup maps for water, roads, and buildings for efficient collision detection
isLake := make(map[image.Point]bool) isLake := make(map[image.Point]bool)
for _, p := range lakePixels { for _, p := range lakePixels {
isLake[p] = true isLake[p] = true
@@ -214,12 +196,11 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
// Step 3: Determine initial points for clumps of trees.
numClumpTrees := min(int(treeClumpiness), numTreesToPlace) numClumpTrees := min(int(treeClumpiness), numTreesToPlace)
initialPoints := make([]image.Point, 0, numClumpTrees) initialPoints := make([]image.Point, 0, numClumpTrees)
for range numClumpTrees { for range numClumpTrees {
for range 100 { // try 100 times to find a valid spot for range 100 {
p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)} p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)}
if treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] && !isRoad[p] && !isBuilding[p] { if treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] && !isRoad[p] && !isBuilding[p] {
initialPoints = append(initialPoints, p) initialPoints = append(initialPoints, p)
@@ -228,14 +209,12 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
} }
} }
// Step 4: Place remaining trees using Poisson Disc Sampling for a natural distribution.
minRadius := minTreeSize minRadius := minTreeSize
allPoints := poissonDiscSampling(width, height, minRadius, 30, initialPoints, func(p image.Point) bool { allPoints := poissonDiscSampling(width, height, minRadius, 30, initialPoints, func(p image.Point) bool {
return treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] && !isRoad[p] && !isBuilding[p] return treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] && !isRoad[p] && !isBuilding[p]
}, seed) }, seed)
var treePixels []image.Point var treePixels []image.Point
// Step 5: Draw the trees on the image.
numGoroutines := runtime.NumCPU() numGoroutines := runtime.NumCPU()
if len(allPoints) < numGoroutines { if len(allPoints) < numGoroutines {
numGoroutines = len(allPoints) numGoroutines = len(allPoints)
@@ -297,7 +276,7 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
return treePixels return treePixels
} }
// poissonDiscSampling generates points that are randomly distributed but no closer than a given minimum radius. // poissonDiscSampling generates randomly distributed points with minimum radius separation
func poissonDiscSampling(width, height int, minRadius float64, k int, initialPoints []image.Point, isValid func(image.Point) bool, seed int64) []image.Point { func poissonDiscSampling(width, height int, minRadius float64, k int, initialPoints []image.Point, isValid func(image.Point) bool, seed int64) []image.Point {
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
points := initialPoints points := initialPoints
+34 -51
View File
@@ -12,19 +12,16 @@ import (
"github.com/ojrac/opensimplex-go" "github.com/ojrac/opensimplex-go"
) )
// lakePixel represents a potential pixel to be added to a lake during growth.
// It is used in a priority queue to determine the next pixel to add.
type lakePixel struct { type lakePixel struct {
point image.Point point image.Point
score float64 score float64
index int // required for heap.Interface index int
} }
// priorityQueue implements a max-heap for lakePixel structs.
type priorityQueue []*lakePixel type priorityQueue []*lakePixel
func (pq priorityQueue) Len() int { return len(pq) } func (pq priorityQueue) Len() int { return len(pq) }
func (pq priorityQueue) Less(i, j int) bool { return pq[i].score > pq[j].score } // Max-heap func (pq priorityQueue) Less(i, j int) bool { return pq[i].score > pq[j].score }
func (pq priorityQueue) Swap(i, j int) { func (pq priorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i] pq[i], pq[j] = pq[j], pq[i]
pq[i].index = i pq[i].index = i
@@ -46,10 +43,8 @@ func (pq *priorityQueue) Pop() any {
return item return item
} }
// GenerateLakes creates lakes on the map using a growth algorithm. // GenerateLakes creates lakes on the map using a priority queue growth algorithm
// When lakeEdgeRoughness is 0, lakes grow in perfect circles. Higher values add noise-based irregularity.
func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper float64, seed int64, lakeEdgeRoughness float64) (image.Image, [][]image.Point) { func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper float64, seed int64, lakeEdgeRoughness float64) (image.Image, [][]image.Point) {
// Initialize a white canvas to draw the lakes on
canvas := image.NewRGBA(image.Rect(0, 0, width, height)) canvas := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
@@ -60,7 +55,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
var allLakes [][]image.Point var allLakes [][]image.Point
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
// Step 1: Divide the image into a grid to distribute the lakes. // Divide the image into a grid to distribute lakes evenly
gridDim := int(math.Ceil(math.Sqrt(float64(numLakes)))) gridDim := int(math.Ceil(math.Sqrt(float64(numLakes))))
if gridDim == 0 { if gridDim == 0 {
return canvas, nil return canvas, nil
@@ -71,7 +66,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
return canvas, nil return canvas, nil
} }
// Step 2: Create a shuffled list of chunk indices to randomize lake placement. // Shuffle chunk indices for random lake placement
chunkIndices := make([]int, gridDim*gridDim) chunkIndices := make([]int, gridDim*gridDim)
for i := range chunkIndices { for i := range chunkIndices {
chunkIndices[i] = i chunkIndices[i] = i
@@ -83,7 +78,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
totalArea := float64(width * height) totalArea := float64(width * height)
noiseGen := opensimplex.New(seed) noiseGen := opensimplex.New(seed)
// Step 3: Generate a lake in a subset of the chunks. // Generate each lake
for i := range numLakes { for i := range numLakes {
if i >= len(chunkIndices) { if i >= len(chunkIndices) {
break break
@@ -91,7 +86,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
var currentLake []image.Point var currentLake []image.Point
// Each lake gets a random size within the defined range. // Randomize lake size within specified range
lakeSize := lakeSizeLower lakeSize := lakeSizeLower
if lakeSizeUpper > lakeSizeLower { if lakeSizeUpper > lakeSizeLower {
lakeSize = lakeSizeLower + randSrc.Float64()*(lakeSizeUpper-lakeSizeLower) lakeSize = lakeSizeLower + randSrc.Float64()*(lakeSizeUpper-lakeSizeLower)
@@ -112,12 +107,12 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
(chunkGridY+1)*chunkHeight, (chunkGridY+1)*chunkHeight,
) )
// Use a priority queue-based growth algorithm within the chunk. // Initialize priority queue growth algorithm
pq := &priorityQueue{} pq := &priorityQueue{}
heap.Init(pq) heap.Init(pq)
visited := make(map[image.Point]bool) visited := make(map[image.Point]bool)
// Start the growth near the center of the chunk. // Start growth at chunk center
startPt := image.Point{ startPt := image.Point{
X: chunkRect.Min.X + chunkWidth/2, X: chunkRect.Min.X + chunkWidth/2,
Y: chunkRect.Min.Y + chunkHeight/2, Y: chunkRect.Min.Y + chunkHeight/2,
@@ -126,33 +121,31 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
continue continue
} }
// Use noise to create a more natural lake shape (only if roughness > 0). // Setup noise generation for natural lake shapes
seedX := randSrc.Float64() * 10000.0 seedX := randSrc.Float64() * 10000.0
seedY := randSrc.Float64() * 10000.0 seedY := randSrc.Float64() * 10000.0
radius := math.Sqrt(float64(targetPixelsPerLake) / math.Pi) radius := math.Sqrt(float64(targetPixelsPerLake) / math.Pi)
noiseFreq := 0.01 + (0.2 / (radius + 1.0)) noiseFreq := 0.01 + (0.2 / (radius + 1.0))
// Score function determines which pixels to add to lake
getScore := func(pt image.Point) float64 { getScore := func(pt image.Point) float64 {
dx, dy := pt.X-startPt.X, pt.Y-startPt.Y dx, dy := pt.X-startPt.X, pt.Y-startPt.Y
dist := math.Sqrt(float64(dx*dx + dy*dy)) dist := math.Sqrt(float64(dx*dx + dy*dy))
distPenalty := math.Pow(dist/radius, 3.0) distPenalty := math.Pow(dist/radius, 3.0)
// Only apply noise if edge roughness is requested
if lakeEdgeRoughness > 0 { if lakeEdgeRoughness > 0 {
noise := noiseGen.Eval2(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq) noise := noiseGen.Eval2(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq)
// Scale noise contribution by roughness setting
noiseContribution := noise * (lakeEdgeRoughness / 100.0) noiseContribution := noise * (lakeEdgeRoughness / 100.0)
return noiseContribution - distPenalty return noiseContribution - distPenalty
} }
// Pure circular growth when variability is 0
return -distPenalty return -distPenalty
} }
heap.Push(pq, &lakePixel{point: startPt, score: getScore(startPt)}) heap.Push(pq, &lakePixel{point: startPt, score: getScore(startPt)})
visited[startPt] = true visited[startPt] = true
// Grow the lake until it reaches its target size. // Grow lake to target size
lakeCount := 0 lakeCount := 0
for pq.Len() > 0 && lakeCount < targetPixelsPerLake { for pq.Len() > 0 && lakeCount < targetPixelsPerLake {
current := heap.Pop(pq).(*lakePixel) current := heap.Pop(pq).(*lakePixel)
@@ -161,7 +154,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
currentLake = append(currentLake, current.point) currentLake = append(currentLake, current.point)
lakeCount++ lakeCount++
// Add neighbors to the priority queue. // Add neighboring pixels to growth queue
for dy := -1; dy <= 1; dy++ { for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ { for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 { if dx == 0 && dy == 0 {
@@ -189,14 +182,14 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
return canvas, allLakes return canvas, allLakes
} }
// River represents a river on the map. // River represents a river on the map
type River struct { type River struct {
Width float64 Width float64
Start, End image.Point Start, End image.Point
Points []image.Point Points []image.Point
} }
// GenerateRivers creates rivers on the map. // GenerateRivers creates rivers flowing across the map from edge to edge
func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness float64, inputImage image.Image, lakes [][]image.Point, seed int64, heightmap image.Image, riverWidthVariability, riverEdgeRoughness float64) (image.Image, []image.Point) { func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness float64, inputImage image.Image, lakes [][]image.Point, seed int64, heightmap image.Image, riverWidthVariability, riverEdgeRoughness float64) (image.Image, []image.Point) {
if numRivers == 0 { if numRivers == 0 {
return inputImage, nil return inputImage, nil
@@ -212,7 +205,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
randSrc := rand.New(rand.NewSource(seed)) randSrc := rand.New(rand.NewSource(seed))
avgDim := float64(width+height) / 2.0 avgDim := float64(width+height) / 2.0
// Create a map of water pixels for collision detection. // Build water pixel lookup maps
isWater := make(map[image.Point]bool) isWater := make(map[image.Point]bool)
lakePixelMap := make(map[image.Point]int) lakePixelMap := make(map[image.Point]int)
for i, lake := range lakes { for i, lake := range lakes {
@@ -222,7 +215,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
} }
} }
// Create rivers with varying widths. // Create rivers with progressively varying widths
rivers := make([]River, numRivers) rivers := make([]River, numRivers)
for i := range numRivers { for i := range numRivers {
widthPercent := float64(i) / float64(numRivers-1) widthPercent := float64(i) / float64(numRivers-1)
@@ -232,36 +225,36 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
rivers[i].Width = maxWidth - widthPercent*(maxWidth-minWidth) rivers[i].Width = maxWidth - widthPercent*(maxWidth-minWidth)
} }
// Sort rivers by width in descending order. // Sort rivers by width in descending order
sort.Slice(rivers, func(i, j int) bool { sort.Slice(rivers, func(i, j int) bool {
return rivers[i].Width > rivers[j].Width return rivers[i].Width > rivers[j].Width
}) })
numControlPoints := max(int(avgDim*0.03), 60) numControlPoints := max(int(avgDim*0.03), 60)
// Generate each river. // Generate each river
for i := range rivers { for i := range rivers {
r := &rivers[i] r := &rivers[i]
// Determine the start and end edges of the river. // Pick random start and end edges
startEdge := randSrc.Intn(4) startEdge := randSrc.Intn(4)
endEdge := (startEdge + randSrc.Intn(3) + 1) % 4 endEdge := (startEdge + randSrc.Intn(3) + 1) % 4
r.Start = getPointOnEdge(width, height, startEdge, randSrc) r.Start = getPointOnEdge(width, height, startEdge, randSrc)
r.End = getPointOnEdge(width, height, endEdge, randSrc) r.End = getPointOnEdge(width, height, endEdge, randSrc)
// Calculate the river's path. // Calculate river path with curves
path := calculateRiverPath(r.Start, r.End, curvyness/100.0, avgDim, randSrc, numControlPoints) path := calculateRiverPath(r.Start, r.End, curvyness/100.0, avgDim, randSrc, numControlPoints)
// Check for intersections with other water bodies. // Check for intersections with existing water
for _, p := range path { for _, p := range path {
if isWater[p] { if isWater[p] {
if lakeIndex, isLake := lakePixelMap[p]; isLake { if lakeIndex, isLake := lakePixelMap[p]; isLake {
// If the river intersects with a lake, end the river at the lake's center. // End river at lake center if it intersects
lakeCenter := findCenter(lakes[lakeIndex]) lakeCenter := findCenter(lakes[lakeIndex])
r.End = lakeCenter r.End = lakeCenter
} else { } else {
// If the river intersects with another river, end it at the intersection point. // End river at intersection with another river
r.End = p r.End = p
} }
path = calculateRiverPath(r.Start, r.End, curvyness/100.0, avgDim, randSrc, numControlPoints) path = calculateRiverPath(r.Start, r.End, curvyness/100.0, avgDim, randSrc, numControlPoints)
@@ -269,7 +262,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
} }
} }
// Draw the river on the canvas. // Draw river on canvas
riverWidthPx := (r.Width / 100.0) * avgDim riverWidthPx := (r.Width / 100.0) * avgDim
radius := riverWidthPx / 2.0 radius := riverWidthPx / 2.0
@@ -282,7 +275,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
return canvas, allRiverPixels return canvas, allRiverPixels
} }
// bresenhamRiver creates a path between control points using Bresenham's line algorithm. // bresenhamRiver draws a line between control points using Bresenham's algorithm
func bresenhamRiver(path []image.Point) []image.Point { func bresenhamRiver(path []image.Point) []image.Point {
if len(path) < 2 { if len(path) < 2 {
return path return path
@@ -322,7 +315,7 @@ func bresenhamRiver(path []image.Point) []image.Point {
return fullPath return fullPath
} }
// calculateRiverPath computes the path for a river, including curves. // calculateRiverPath computes a curved path for a river using sine waves
func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randSrc *rand.Rand, numControlPoints int) []image.Point { func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randSrc *rand.Rand, numControlPoints int) []image.Point {
dx := end.X - start.X dx := end.X - start.X
dy := end.Y - start.Y dy := end.Y - start.Y
@@ -336,7 +329,7 @@ func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randS
return bresenhamRiver([]image.Point{start, end}) return bresenhamRiver([]image.Point{start, end})
} }
// Use sine waves to create curves in the river. // Use multiple sine waves at different frequencies for natural curves
type wave struct { type wave struct {
amplitude float64 amplitude float64
numWaves float64 numWaves float64
@@ -362,7 +355,7 @@ func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randS
amp /= 3 amp /= 3
} }
// Generate control points for the curve. // Generate control points along the path
controlPoints := make([]image.Point, numControlPoints+1) controlPoints := make([]image.Point, numControlPoints+1)
for i := 0; i <= numControlPoints; i++ { for i := 0; i <= numControlPoints; i++ {
t := float64(i) / float64(numControlPoints) t := float64(i) / float64(numControlPoints)
@@ -381,11 +374,11 @@ func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randS
controlPoints[i] = image.Point{X: int(math.Round(x)), Y: int(math.Round(y))} controlPoints[i] = image.Point{X: int(math.Round(x)), Y: int(math.Round(y))}
} }
// Create the final path using Bresenham's algorithm between control points. // Create final path using Bresenham between control points
return bresenhamRiver(controlPoints) return bresenhamRiver(controlPoints)
} }
// findCenter finds the center of a slice of points. // findCenter calculates the center point of a set of pixels
func findCenter(pixels []image.Point) image.Point { func findCenter(pixels []image.Point) image.Point {
if len(pixels) == 0 { if len(pixels) == 0 {
return image.Point{} return image.Point{}
@@ -401,7 +394,7 @@ func findCenter(pixels []image.Point) image.Point {
} }
} }
// getPointOnEdge returns a random point on a specified edge of the map. // getPointOnEdge returns a random point on the specified map edge
func getPointOnEdge(width, height, edge int, randSrc *rand.Rand) image.Point { func getPointOnEdge(width, height, edge int, randSrc *rand.Rand) image.Point {
switch edge { switch edge {
case 0: // Top case 0: // Top
@@ -415,20 +408,11 @@ func getPointOnEdge(width, height, edge int, randSrc *rand.Rand) image.Point {
} }
} }
// drawCircle draws a circle on the image and adds its pixels to the given slice. // drawCircle draws a circular river cross-section with sine wave edge roughening
// The outer edges are roughened using dual sin waves for natural-looking banks.
// riverWidthVariability controls the amplitude of width changes (0-100%).
// riverEdgeRoughness controls the detail level of the edge roughness (0-100%).
func drawCircle(img *image.RGBA, center image.Point, radius float64, c color.Color, pixels *[]image.Point, isWater map[image.Point]bool, heightmap image.Image, riverWidthVariability, riverEdgeRoughness float64) { func drawCircle(img *image.RGBA, center image.Point, radius float64, c color.Color, pixels *[]image.Point, isWater map[image.Point]bool, heightmap image.Image, riverWidthVariability, riverEdgeRoughness float64) {
bounds := img.Bounds() bounds := img.Bounds()
// Calculate dual sin wave amplitudes for outer edge roughening
// Large amplitude represents major variations in river width (controlled by riverWidthVariability)
// At 0%, no width variation; at 100%, amplitude is 50% of radius
largeAmplitude := (radius * 0.5) * (riverWidthVariability / 100.0) largeAmplitude := (radius * 0.5) * (riverWidthVariability / 100.0)
// Small amplitude is controlled by riverEdgeRoughness
// At 0%, no detail; at 100%, detail amplitude equals large amplitude
smallAmplitude := largeAmplitude * (riverEdgeRoughness / 100.0) smallAmplitude := largeAmplitude * (riverEdgeRoughness / 100.0)
for y := int(math.Floor(float64(center.Y) - radius)); y <= int(math.Ceil(float64(center.Y)+radius)); y++ { for y := int(math.Floor(float64(center.Y) - radius)); y <= int(math.Ceil(float64(center.Y)+radius)); y++ {
@@ -441,13 +425,12 @@ func drawCircle(img *image.RGBA, center image.Point, radius float64, c color.Col
dx, dy := float64(x-center.X), float64(y-center.Y) dx, dy := float64(x-center.X), float64(y-center.Y)
dist := math.Sqrt(dx*dx + dy*dy) dist := math.Sqrt(dx*dx + dy*dy)
// Apply dual sin wave offset to create rough edges // Apply dual sine waves for edge roughness
positionPhase := float64(x)*0.008 + float64(y)*0.012 positionPhase := float64(x)*0.008 + float64(y)*0.012
largeWave := math.Sin(positionPhase) * largeAmplitude largeWave := math.Sin(positionPhase) * largeAmplitude
smallWave := math.Sin(positionPhase*3.5) * smallAmplitude smallWave := math.Sin(positionPhase*3.5) * smallAmplitude
waveOffset := largeWave + smallWave waveOffset := largeWave + smallWave
// Effective radius varies based on sin wave
effectiveRadius := radius + waveOffset effectiveRadius := radius + waveOffset
if dist <= effectiveRadius { if dist <= effectiveRadius {