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
}
// Initialize random number generator
randSrc := rand.New(rand.NewSource(seed))
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)
for _, p := range allWaterPixels {
isWater[p] = true
@@ -31,13 +29,11 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
isRoad[p] = true
}
// Initialize building data structures
isBuilding := make(map[image.Point]bool)
var buildings [][]image.Point
var allBuildingPixels []image.Point
var anchorPoints []image.Point
// Determine anchor points for building placement
if len(roadPixels) > 0 {
anchorPoints = roadPixels
} else {
@@ -64,7 +60,6 @@ func GenerateBuildings(img *image.RGBA, width, height int, settings *Settings, r
return anchorPoints[i].X < anchorPoints[j].X
})
// Collect all land points for random placement
landPoints := make([]image.Point, 0, width*height)
for y := 0; y < height; y++ {
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
var anchor image.Point
if randSrc.Float64() > settings.BuildingDistribution/100.0 {
// Place near roads or other existing features
anchor = anchorPoints[randSrc.Intn(len(anchorPoints))]
} else {
// Place randomly on any available land
if len(landPoints) == 0 {
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.
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
if settings.BuildingComplexityRatio > randSrc.Float64()*100 {
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
buildingCenter = center
} else {
// Place subsequent components near existing ones
prevShape := shapeDescriptions[randSrc.Intn(len(shapeDescriptions))]
angle := randSrc.Float64() * 2 * math.Pi
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})
}
// Find the bounding box of the unscaled building
var minX, minY, maxX, maxY int
for i, sd := range shapeDescriptions {
halfSize := int(sd.size / 2)
@@ -252,7 +242,6 @@ func scalePixels(pixels []image.Point, finalSize float64) []image.Point {
return pixels
}
// Find the bounding box of the pixels
minX, minY := pixels[0].X, pixels[0].Y
maxX, maxY := pixels[0].X, pixels[0].Y
for _, p := range pixels {
@@ -274,7 +263,6 @@ func scalePixels(pixels []image.Point, finalSize float64) []image.Point {
currentWidth := float64(maxX - minX)
currentHeight := float64(maxY - minY)
// Determine the scaling factor
scale := finalSize / math.Max(currentWidth, currentHeight)
// 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.
func chooseShape(randSrc *rand.Rand, ratios map[string]float64) string {
// Create a slice of shapes and their cumulative weights
var shapes []string
var weights []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
randNum := randSrc.Float64() * cumulativeWeight
// Find the shape corresponding to the random number
for i, weight := range weights {
if randNum < weight {
return shapes[i]
@@ -407,7 +393,6 @@ func getBuildingPixels(center image.Point, size float64, shape string, isWater,
}
}
case "rectangles":
// Create rectangles with varied aspect ratios
longSide := size
shortSide := randSrc.Float64()*(size-float64(halfSize)) + float64(halfSize)
var w, h int
@@ -418,7 +403,6 @@ func getBuildingPixels(center image.Point, size float64, shape string, isWater,
}
halfW, halfH := w/2, h/2
// Check for collisions and gather pixels
for y := center.Y - halfH; y <= center.Y+halfH; y++ {
for x := center.X - halfW; x <= center.X+halfW; x++ {
p := image.Point{X: x, Y: y}
@@ -453,7 +437,6 @@ func FlattenBuildingAreas(heightMap *image.RGBA, buildings [][]image.Point, widt
return heightMap
}
// Create a copy of the heightmap to avoid modifying the original during processing.
newHeightMap := image.NewRGBA(heightMap.Bounds())
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)
}
// Create a buffer around the building.
buffer := make([]image.Point, 0)
for _, p := range building {
for y := p.Y - 5; y <= p.Y+5; y++ {
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"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 {
widget.BaseWidget
value binding.Float
@@ -52,7 +52,7 @@ func (r *numericInputSliderRenderer) Refresh() {
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 {
s := &numericInputSlider{
min: min,
@@ -80,7 +80,7 @@ func newNumericInputSlider(min, max float64, initialValue float64, format string
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)) {
text = strings.TrimSuffix(text, "px")
text = strings.TrimSuffix(text, "%")
@@ -104,7 +104,7 @@ func (s *numericInputSlider) validate(text string, onError func(bool)) {
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 {
r := &numericInputSliderRenderer{
slider: s,
+25 -129
View File
@@ -11,81 +11,35 @@ import (
"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 {
EdgeBandRatio float64 // fraction of river radius used for roughening band (e.g. 0.6)
RoughnessStrength float64 // 0..1 how aggressive clipping is at the edge
IslandAttemptProb float64 // chance per-river to attempt islands
IslandSeedChance float64 // chance per-water-pixel to become an island seed candidate
MinIslandSize int // minimum island pixel count
MaxIslandSize int // maximum island pixel count
WaterLevelBias float64 // baseline water level in normalized height units [0..1]; small bias subtracted to favor water
NoiseFrequency float64 // frequency for simplex noise
KeepInnerFraction float64 // fraction of inner radius always kept as channel (0..1)
MaxWorkers int // concurrency limit (0 means runtime.NumCPU())
MinWidthPx float64 // minimum river width in pixels (for sin wave amplitude calculation)
MaxWidthPx float64 // maximum river width in pixels (for sin wave amplitude calculation)
EdgeBandRatio float64
RoughnessStrength float64
IslandAttemptProb float64
IslandSeedChance float64
MinIslandSize int
MaxIslandSize int
WaterLevelBias float64
NoiseFrequency float64
KeepInnerFraction float64
MaxWorkers int
MinWidthPx float64
MaxWidthPx float64
}
// computeSinWaveEdgeOffset computes the radial offset for river edge roughening
// 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.
// computeSinWaveEdgeOffset computes dual sine wave edge roughening for realistic river banks
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
// Large wave: slower frequency for major width variations along the bank
largeWave := math.Sin(positionPhase) * largeAmplitude
// Small wave: faster frequency for subtle and natural bank details
smallWave := math.Sin(positionPhase*3.5) * smallAmplitude
// Return combined offset
return largeWave + smallWave
}
// RasterizeAndRoughenRiver rasterizes a river path, roughens edges using the heightmap and dual sin waves,
// 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)
// RasterizeAndRoughenRiver rasterizes a river path with natural edge roughening and optional islands
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 {
return nil
}
// Default parameters - tweak as needed
params := riverParams{
EdgeBandRatio: 0.6,
RoughnessStrength: 0.65,
@@ -95,7 +49,7 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
MaxIslandSize: 800,
WaterLevelBias: 0.02,
NoiseFrequency: 0.02,
KeepInnerFraction: 0.85, // keep central 85% of radius
KeepInnerFraction: 0.85,
MaxWorkers: 0,
MinWidthPx: minWidthPx,
MaxWidthPx: maxWidthPx,
@@ -104,10 +58,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
bounds := canvas.Bounds()
imgW, imgH := bounds.Dx(), bounds.Dy()
// Precompute normalized height grid for faster sampling.
heightGrid := precomputeHeightGrid(heightmap, imgW, imgH)
// Compute bounding box for path expanded by radius + edge band
radius := riverWidthPx / 2.0
edgeBand := radius * params.EdgeBandRatio
expand := int(math.Ceil(radius + edgeBand + 2))
@@ -151,11 +103,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
return nil
}
// Create base raster mask inside bounding box.
// baseMask[i] == 1 means inside nominal river radius (before roughening).
baseMask := make([]uint8, bw*bh)
// Rasterize simple circular stamping for each center point into baseMask
radiusSq := radius * radius
for _, c := range path {
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)
// Prepare noise generator
noise := opensimplex.New(seed)
noiseFreq := params.NoiseFrequency
// Determine inner keep radius (always keep central channel)
innerKeepRadius := radius * params.KeepInnerFraction
// Prepare final mask
finalMask := make([]uint8, bw*bh)
// Concurrency setup
workers := params.MaxWorkers
if workers <= 0 {
workers = runtime.NumCPU()
@@ -200,18 +144,13 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
rowsPerWorker := (bh + workers - 1) / workers
randBase := rand.New(rand.NewSource(seed))
// Precompute some weights for the decision formula
heightWeight := 2.0 * params.RoughnessStrength
distWeight := 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
smallAmplitude := largeAmplitude / 4.0
// Evaluate per-pixel decision in parallel
for wi := 0; wi < workers; wi++ {
startY := wi * rowsPerWorker
endY := startY + rowsPerWorker
@@ -228,11 +167,8 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
for y := startY; y < endY; y++ {
for x := 0; x < bw; x++ {
idx := y*bw + x
// If already inside base mask, candidate for water
if baseMask[idx] == 1 {
// If within inner keep radius: keep always
d := dist[idx]
// dist is approximate pixels; we compare to innerKeepRadius
absX := x + minX
absY := y + minY
if d <= float32(innerKeepRadius) {
@@ -240,12 +176,9 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
continue
}
// Apply sin wave offset for realistic edge roughening
sinWaveOffset := computeSinWaveEdgeOffset(absX, absY, largeAmplitude, smallAmplitude)
effectiveInnerRadius := innerKeepRadius + sinWaveOffset
// Compute influences
// normalizedDist: 0 at effectiveInnerRadius, 1 at effectiveInnerRadius + edgeBand
normDist := float64((float32(d) - float32(effectiveInnerRadius)) / float32(edgeBand))
if normDist < 0 {
normDist = 0
@@ -254,18 +187,15 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
normDist = 1
}
heightVal := sampleHeightGrid(heightGrid, imgW, imgH, absX, absY) // 0..1
// Apply bias so slightly lower areas favor water
heightVal := sampleHeightGrid(heightGrid, imgW, imgH, absX, absY)
heightAdj := float64(heightVal) - params.WaterLevelBias
noiseVal := noise.Eval2(float64(absX)*noiseFreq, float64(absY)*noiseFreq) // -1 .. 1
noiseNorm := (noiseVal + 1.0) / 2.0 // 0..1
noiseVal := noise.Eval2(float64(absX)*noiseFreq, float64(absY)*noiseFreq)
noiseNorm := (noiseVal + 1.0) / 2.0
score := distWeight*normDist + heightWeight*heightAdj + noiseWeight*(noiseNorm-0.5)
// Decision threshold: higher score means more likely land.
threshold := 0.35 + 0.5*params.RoughnessStrength
// Small stochastic factor to add natural variance
if localRand.Float64() < 0.0005 {
score += (localRand.Float64() - 0.5) * 0.2
}
@@ -282,14 +212,12 @@ func RasterizeAndRoughenRiver(canvas *image.RGBA, path []image.Point, riverWidth
}
wg.Wait()
// Optionally attempt islands with small probability
randForIsland := rand.New(rand.NewSource(seed + 1234567))
tryIslands := randForIsland.Float64() < params.IslandAttemptProb
if tryIslands {
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
for y := 0; y < bh; y++ {
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)
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 {
out := make([]float32, width*height)
if hmap == nil {
// default flat
for i := range out {
out[i] = 0.5
}
@@ -337,7 +263,7 @@ func precomputeHeightGrid(hmap image.Image, width, height int) []float32 {
return out
}
// sampleHeightGrid safe accessor
// sampleHeightGrid safely samples height at coordinates
func sampleHeightGrid(grid []float32, width, height, x, y int) float32 {
if x < 0 || x >= width || y < 0 || y >= height {
return 0.5
@@ -345,14 +271,11 @@ func sampleHeightGrid(grid []float32, width, height, x, y int) float32 {
return grid[y*width+x]
}
// chamferDistanceField computes a fast approximate distance (in pixels) from any pixel to the nearest
// baseMask==1 pixel. Distance is zero for pixels inside baseMask.
// This is a two-pass chamfer approximation (float), cheap and parallel friendly.
// chamferDistanceField computes fast approximate distance from any pixel to centerline
func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
const maxF = 1e6
dist := make([]float32, w*h)
// Initialize
for i := 0; i < w*h; i++ {
if baseMask[i] == 1 {
dist[i] = 0
@@ -368,28 +291,24 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
if dist[i] == 0 {
continue
}
// check left
if x > 0 {
v := dist[i-1] + 1.0
if v < dist[i] {
dist[i] = v
}
}
// check top
if y > 0 {
v := dist[i-w] + 1.0
if v < dist[i] {
dist[i] = v
}
}
// check top-left
if x > 0 && y > 0 {
v := dist[i-w-1] + 1.41421356
if v < dist[i] {
dist[i] = v
}
}
// check top-right
if x < w-1 && y > 0 {
v := dist[i-w+1] + 1.41421356
if v < dist[i] {
@@ -403,28 +322,24 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
for y := h - 1; y >= 0; y-- {
for x := w - 1; x >= 0; x-- {
i := y*w + x
// check right
if x < w-1 {
v := dist[i+1] + 1.0
if v < dist[i] {
dist[i] = v
}
}
// check bottom
if y < h-1 {
v := dist[i+w] + 1.0
if v < dist[i] {
dist[i] = v
}
}
// check bottom-right
if x < w-1 && y < h-1 {
v := dist[i+w+1] + 1.41421356
if v < dist[i] {
dist[i] = v
}
}
// check bottom-left
if x > 0 && y < h-1 {
v := dist[i+w-1] + 1.41421356
if v < dist[i] {
@@ -437,15 +352,9 @@ func chamferDistanceField(baseMask []uint8, w, h int) []float32 {
return dist
}
// generateIslandsInMask will attempt to create small islands inside contiguous 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
// generateIslandsInMask creates small islands inside water areas
func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []float32, fullW, fullH int, params *riverParams, seed int64) {
r := rand.New(rand.NewSource(seed))
// Collect candidates
type pt struct{ x, y int }
candidates := make([]pt, 0)
for y := 0; y < bh; y++ {
@@ -457,7 +366,6 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
absX := x + minX
absY := y + minY
hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY)
// candidate if slightly higher than local water bias
if float64(hv) > params.WaterLevelBias+0.03 {
if r.Float64() < params.IslandSeedChance {
candidates = append(candidates, pt{x, y})
@@ -469,7 +377,6 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
return
}
// Shuffle candidates to randomize island placement
r.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] })
visited := make([]uint8, bw*bh)
@@ -479,10 +386,8 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
if visited[ci] != 0 {
continue
}
// BFS grow island
maxSize := params.MaxIslandSize
minSize := params.MinIslandSize
// randomize size a bit
targetSize := minSize + r.Intn(maxSize-minSize+1)
queue := []pt{{c.x, c.y}}
@@ -494,13 +399,11 @@ func generateIslandsInMask(mask []uint8, bw, bh, minX, minY int, heightGrid []fl
p := queue[qi]
absX := p.x + minX
absY := p.y + minY
// Height constraint: island must be above a modest threshold
hv := sampleHeightGrid(heightGrid, fullW, fullH, absX, absY)
if float64(hv) < params.WaterLevelBias+0.01 {
continue
}
island = append(island, p)
// Expand
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
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 {
continue
}
// Only grow into water pixels
if mask[nidx] != 1 {
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 {
continue
}
// size check
if len(island) < minSize {
continue
}
// Carve the island: set mask pixels to 0 (land)
for _, p := range island {
mask[p.y*bw+p.x] = 0
}
// Optionally stop after creating a few islands to keep them rare
if r.Float64() < 0.7 {
// keep creating more sometimes, break otherwise
if r.Intn(3) == 0 {
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).
// This is a simple pass that clears pixels that have fewer than minNeighbors water neighbors.
// removeSpeckles removes tiny isolated water pixels
func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) {
arr := *mask
out := make([]uint8, len(arr))
@@ -584,7 +480,7 @@ func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) {
*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 {
var pts []image.Point
for y := 0; y < bh; y++ {
@@ -597,7 +493,7 @@ func maskToPoints(mask []uint8, bw, bh, minX, minY int) []image.Point {
return pts
}
// small clamp helpers
// clamp01 clamps value to 0..1 range
func clamp01(v float64) float64 {
if v < 0 {
return 0
+17 -42
View File
@@ -11,20 +11,20 @@ import (
"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 {
X, Y int
Connections int
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 {
Point image.Point
IsBridge bool
}
// Road represents a connection between two Points of Interest.
// Road represents a connection between two Points of Interest
type Road struct {
Start, End *PointOfInterest
Width int
@@ -32,9 +32,8 @@ type Road struct {
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) {
// Step 1: Initialize a transparent image for drawing roads
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
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))
roadColor := color.RGBA{R: 139, G: 69, B: 19, 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)
if len(pois) == 0 {
return nil, nil, img
}
// Step 4: Connect POIs to form roads
roads := connectPOIs(pois, width, height, settings, randSrc, allWaterPixels)
// Step 5: Assign widths to the roads based on their importance
assignRoadWidths(roads, settings)
// Step 6: Draw the roads on the image
var allRoadPixels []image.Point
var allBridgePixels []image.Point
for _, road := range roads {
@@ -70,7 +64,7 @@ func GenerateRoads(width, height int, settings *Settings, noiseImg image.Image,
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 {
numPOIs := settings.NumRoads / 2
if numPOIs == 0 {
@@ -91,33 +85,30 @@ func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.
centerX := width / 2
centerY := height / 2
// Distribution affects the radius of POI generation
maxRadius := math.Min(float64(width)/2, float64(height)/2)
radius := maxRadius * (settings.RoadDistribution / 100.0)
for i := 0; i < numPOIs; i++ {
var x, y int
found := false
for j := 0; j < 100; j++ { // Retries to find a land spot
for j := 0; j < 100; j++ {
if i < numExits {
// Create POIs at the map edges
side := randSrc.Intn(4)
switch side {
case 0: // Top
case 0:
x = randSrc.Intn(width)
y = 0
case 1: // Bottom
case 1:
x = randSrc.Intn(width)
y = height - 1
case 2: // Left
case 2:
x = 0
y = randSrc.Intn(height)
case 3: // Right
case 3:
x = width - 1
y = randSrc.Intn(height)
}
} else {
// Create POIs within the map
angle := randSrc.Float64() * 2 * math.Pi
r := math.Sqrt(randSrc.Float64()) * radius
x = int(float64(centerX) + r*math.Cos(angle))
@@ -138,7 +129,7 @@ func generatePOIs(width, height int, settings *Settings, allWaterPixels []image.
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 {
if len(pois) < 2 {
return nil
@@ -151,7 +142,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
visited := make(map[*PointOfInterest]bool)
existingRoads := make(map[string]bool)
// Find the center-most POI to start connecting from
centerX := width / 2
centerY := height / 2
var startNode *PointOfInterest
@@ -174,11 +164,9 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
visited[startNode] = true
// Use average dimension for controlling road path calculation
avgDim := float64(width+height) / 2.0
numControlPoints := max(int(avgDim*0.03), 60)
// Connect all POIs using a minimum spanning tree-like algorithm
for len(visited) < len(pois) {
var closest *PointOfInterest
var fromNode *PointOfInterest
@@ -192,7 +180,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
if !visited[other] {
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)
if uintptr(unsafe.Pointer(poi)) > uintptr(unsafe.Pointer(other)) {
key = fmt.Sprintf("%p-%p", other, poi)
@@ -201,7 +188,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
continue
}
// Avoid connecting two exit points directly
if poi.IsExit && other.IsExit {
continue
}
@@ -220,7 +206,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
fromNode.Connections++
closest.Connections++
// Add road to existing roads map to prevent duplicates
key := fmt.Sprintf("%p-%p", fromNode, closest)
if uintptr(unsafe.Pointer(fromNode)) > uintptr(unsafe.Pointer(closest)) {
key = fmt.Sprintf("%p-%p", closest, fromNode)
@@ -238,7 +223,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
}
}(fromNode, closest)
} else {
// No more reachable POIs, break the loop
break
}
}
@@ -252,7 +236,6 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
roads = append(roads, road)
}
// Calculate road importance based on the number of connections at its endpoints
for _, road := range roads {
road.Importance = road.Start.Connections + road.End.Connections
}
@@ -260,13 +243,12 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings,
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) {
if len(roads) == 0 {
return
}
// Sort roads by importance in descending order
sort.Slice(roads, func(i, j int) bool {
return roads[i].Importance > roads[j].Importance
})
@@ -278,13 +260,12 @@ func assignRoadWidths(roads []*Road, settings *Settings) {
widthStep = (maxWidth - minWidth) / float64(len(roads)-1)
}
// Assign widths, with more important roads being wider
for i, road := range roads {
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) {
var roadPixels []image.Point
var bridgePixels []image.Point
@@ -306,7 +287,7 @@ func drawRoad(img *image.RGBA, points []PathPoint, roadColor, bridgeColor color.
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 {
if len(path) < 2 {
return path
@@ -346,7 +327,7 @@ func bresenhamRoad(path []image.Point) []image.Point {
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 {
dx := end.X - start.X
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}]}}
}
// Adjust curviness based on the distance between the POIs
distanceFactor := math.Min(1.0, dist/(avgDim*0.5))
adjustedCurvyness := curvyness * distanceFactor
@@ -374,7 +354,6 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
return pathPoints
}
// Use sine waves to create curves in the road
type wave struct {
amplitude float64
numWaves float64
@@ -389,21 +368,18 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
}
baseNumWaves := (dist / mainWavelength) * adjustedCurvyness
// Main wave for overall curve
waves[0] = wave{
amplitude: amp,
numWaves: baseNumWaves * (0.75 + randSrc.Float64()*0.5),
phase: randSrc.Float64() * 2 * math.Pi,
}
// Smaller wave for minor detours and a more natural look
waves[1] = wave{
amplitude: amp / 4,
numWaves: baseNumWaves * 4 * (0.75 + randSrc.Float64()*0.5),
phase: randSrc.Float64() * 2 * math.Pi,
}
// Generate control points for the curve
controlPoints := make([]image.Point, numControlPoints+1)
for i := 0; i <= numControlPoints; i++ {
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))}
}
// Create the final path using Bresenham's algorithm between control points
points := bresenhamRoad(controlPoints)
pathPoints := make([]PathPoint, len(points))
for i, p := range points {
@@ -435,7 +410,7 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r
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 {
var points []image.Point
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
}
// abs returns the absolute value of an integer.
// abs returns the absolute value of an integer
func abs(x int) int {
if x < 0 {
return -x
+3 -5
View File
@@ -2,21 +2,19 @@ package main
import "math/rand"
// SeedProvider is a simple struct that provides a stream of random seeds
// from a single initial seed. This ensures that the entire map generation
// process is deterministic if the same initial seed is used.
// SeedProvider provides a stream of random seeds from a single initial seed
type SeedProvider struct {
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 {
return &SeedProvider{
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 {
return sp.rand.Int63()
}
+8 -29
View File
@@ -14,16 +14,14 @@ import (
"github.com/ojrac/opensimplex-go"
)
// Constants for Perlin noise generation
const (
alpha = 2.
beta = 2.
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 {
// Initialize Perlin noise generator
p := perlin.NewPerlin(alpha, beta, n, seed)
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
}
// Use multiple goroutines to speed up noise generation
numGoroutines := runtime.NumCPU()
var wg sync.WaitGroup
rowsPerGoroutine := height / numGoroutines
@@ -47,7 +44,6 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
defer wg.Done()
for y := startY; y < endY; y++ {
for x := 0; x < width; x++ {
// Combine multiple octaves of noise for more detail
var noise float64
frequency := 1.0
amplitude := 1.0
@@ -60,7 +56,6 @@ func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) im
frequency *= 2.0
}
// Normalize the noise value and set the pixel color
noise /= maxAmplitude
grayColor := uint8((noise + 1) * 127.5)
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
}
// 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 {
bounds := heightmap.Bounds()
composite := image.NewRGBA(bounds)
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)
overlay := image.NewUniform(color.RGBA{R: 128, G: 128, B: 128, A: alphaValue})
draw.Draw(composite, bounds, overlay, image.Point{}, draw.Over)
@@ -87,23 +81,20 @@ func ApplyRoughness(heightmap image.Image, roughness float64) image.Image {
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 {
bounds := heightmap.Bounds()
width := bounds.Dx()
// Create a new black image to draw the lakes on
lakeMask := image.NewRGBA(bounds)
black := color.RGBA{0, 0, 0, 255}
for _, p := range lakePixels {
lakeMask.Set(p.X, p.Y, black)
}
// Apply a Gaussian blur to the lake mask to create smooth edges
blurRadius := float64(width) * 0.05
blurredLakeMask := imaging.Blur(lakeMask, blurRadius)
// Composite the blurred lake mask onto the heightmap with some opacity
composite := image.NewRGBA(bounds)
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)
@@ -111,33 +102,27 @@ func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Imag
return composite
}
// FlattenRoadAreas smoothens the terrain under roads.
// FlattenRoadAreas smooths terrain under roads
func FlattenRoadAreas(heightmap image.Image, roadPixels []image.Point) image.Image {
bounds := heightmap.Bounds()
width := bounds.Dx()
// Create a mask with the road pixels
roadMask := image.NewGray(bounds)
for _, p := range roadPixels {
roadMask.SetGray(p.X, p.Y, color.Gray{Y: 255})
}
// Blur the road mask to create a smooth transition
blurRadius := float64(width) * 0.01
blurredRoadMask := imaging.Blur(roadMask, blurRadius)
// Blur the entire heightmap
blurredHeightmap := imaging.Blur(heightmap, blurRadius)
// Create a new composite image
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 x := bounds.Min.X; x < bounds.Max.X; x++ {
maskAlpha, _, _, _ := blurredRoadMask.At(x, y).RGBA()
if maskAlpha > 0 {
// Linearly interpolate between the original and blurred heightmap
originalColor := heightmap.At(x, y)
blurredColor := blurredHeightmap.At(x, y)
@@ -161,12 +146,11 @@ func FlattenRoadAreas(heightmap image.Image, roadPixels []image.Point) image.Ima
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 {
width := img.Bounds().Dx()
height := img.Bounds().Dy()
// Step 1: Calculate the number of trees to place based on coverage percentage.
avgTreeSize := (minTreeSize + maxTreeSize) / 2
if avgTreeSize <= 0 {
return nil
@@ -183,20 +167,18 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
return nil
}
// Step 2: Generate a simplex noise map to guide tree placement.
noise := opensimplex.New(seed)
treeNoiseMap := image.NewGray(image.Rect(0, 0, width, height))
treeNoiseZoom := 0.05
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
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)})
}
}
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)
for _, p := range lakePixels {
isLake[p] = true
@@ -214,12 +196,11 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
randSrc := rand.New(rand.NewSource(seed))
// Step 3: Determine initial points for clumps of trees.
numClumpTrees := min(int(treeClumpiness), numTreesToPlace)
initialPoints := make([]image.Point, 0, 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)}
if treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] && !isRoad[p] && !isBuilding[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
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]
}, seed)
var treePixels []image.Point
// Step 5: Draw the trees on the image.
numGoroutines := runtime.NumCPU()
if len(allPoints) < numGoroutines {
numGoroutines = len(allPoints)
@@ -297,7 +276,7 @@ func GenerateTrees(img *image.RGBA, lakePixels, roadPixels, buildingPixels []ima
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 {
randSrc := rand.New(rand.NewSource(seed))
points := initialPoints
+34 -51
View File
@@ -12,19 +12,16 @@ import (
"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 {
point image.Point
score float64
index int // required for heap.Interface
index int
}
// priorityQueue implements a max-heap for lakePixel structs.
type priorityQueue []*lakePixel
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) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index = i
@@ -46,10 +43,8 @@ func (pq *priorityQueue) Pop() any {
return item
}
// GenerateLakes creates lakes on the map using a growth algorithm.
// When lakeEdgeRoughness is 0, lakes grow in perfect circles. Higher values add noise-based irregularity.
// GenerateLakes creates lakes on the map using a priority queue growth algorithm
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))
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
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))))
if gridDim == 0 {
return canvas, nil
@@ -71,7 +66,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
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)
for i := range chunkIndices {
chunkIndices[i] = i
@@ -83,7 +78,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
totalArea := float64(width * height)
noiseGen := opensimplex.New(seed)
// Step 3: Generate a lake in a subset of the chunks.
// Generate each lake
for i := range numLakes {
if i >= len(chunkIndices) {
break
@@ -91,7 +86,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
var currentLake []image.Point
// Each lake gets a random size within the defined range.
// Randomize lake size within specified range
lakeSize := lakeSizeLower
if lakeSizeUpper > lakeSizeLower {
lakeSize = lakeSizeLower + randSrc.Float64()*(lakeSizeUpper-lakeSizeLower)
@@ -112,12 +107,12 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
(chunkGridY+1)*chunkHeight,
)
// Use a priority queue-based growth algorithm within the chunk.
// Initialize priority queue growth algorithm
pq := &priorityQueue{}
heap.Init(pq)
visited := make(map[image.Point]bool)
// Start the growth near the center of the chunk.
// Start growth at chunk center
startPt := image.Point{
X: chunkRect.Min.X + chunkWidth/2,
Y: chunkRect.Min.Y + chunkHeight/2,
@@ -126,33 +121,31 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
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
seedY := randSrc.Float64() * 10000.0
radius := math.Sqrt(float64(targetPixelsPerLake) / math.Pi)
noiseFreq := 0.01 + (0.2 / (radius + 1.0))
// Score function determines which pixels to add to lake
getScore := func(pt image.Point) float64 {
dx, dy := pt.X-startPt.X, pt.Y-startPt.Y
dist := math.Sqrt(float64(dx*dx + dy*dy))
distPenalty := math.Pow(dist/radius, 3.0)
// Only apply noise if edge roughness is requested
if lakeEdgeRoughness > 0 {
noise := noiseGen.Eval2(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq)
// Scale noise contribution by roughness setting
noiseContribution := noise * (lakeEdgeRoughness / 100.0)
return noiseContribution - distPenalty
}
// Pure circular growth when variability is 0
return -distPenalty
}
heap.Push(pq, &lakePixel{point: startPt, score: getScore(startPt)})
visited[startPt] = true
// Grow the lake until it reaches its target size.
// Grow lake to target size
lakeCount := 0
for pq.Len() > 0 && lakeCount < targetPixelsPerLake {
current := heap.Pop(pq).(*lakePixel)
@@ -161,7 +154,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
currentLake = append(currentLake, current.point)
lakeCount++
// Add neighbors to the priority queue.
// Add neighboring pixels to growth queue
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
@@ -189,14 +182,14 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo
return canvas, allLakes
}
// River represents a river on the map.
// River represents a river on the map
type River struct {
Width float64
Start, End 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) {
if numRivers == 0 {
return inputImage, nil
@@ -212,7 +205,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
randSrc := rand.New(rand.NewSource(seed))
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)
lakePixelMap := make(map[image.Point]int)
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)
for i := range numRivers {
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)
}
// Sort rivers by width in descending order.
// Sort rivers by width in descending order
sort.Slice(rivers, func(i, j int) bool {
return rivers[i].Width > rivers[j].Width
})
numControlPoints := max(int(avgDim*0.03), 60)
// Generate each river.
// Generate each river
for i := range rivers {
r := &rivers[i]
// Determine the start and end edges of the river.
// Pick random start and end edges
startEdge := randSrc.Intn(4)
endEdge := (startEdge + randSrc.Intn(3) + 1) % 4
r.Start = getPointOnEdge(width, height, startEdge, 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)
// Check for intersections with other water bodies.
// Check for intersections with existing water
for _, p := range path {
if isWater[p] {
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])
r.End = lakeCenter
} else {
// If the river intersects with another river, end it at the intersection point.
// End river at intersection with another river
r.End = p
}
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
radius := riverWidthPx / 2.0
@@ -282,7 +275,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness
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 {
if len(path) < 2 {
return path
@@ -322,7 +315,7 @@ func bresenhamRiver(path []image.Point) []image.Point {
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 {
dx := end.X - start.X
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})
}
// Use sine waves to create curves in the river.
// Use multiple sine waves at different frequencies for natural curves
type wave struct {
amplitude float64
numWaves float64
@@ -362,7 +355,7 @@ func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randS
amp /= 3
}
// Generate control points for the curve.
// Generate control points along the path
controlPoints := make([]image.Point, numControlPoints+1)
for i := 0; i <= numControlPoints; i++ {
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))}
}
// Create the final path using Bresenham's algorithm between control points.
// Create final path using Bresenham between control points
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 {
if len(pixels) == 0 {
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 {
switch edge {
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.
// 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%).
// drawCircle draws a circular river cross-section with sine wave edge roughening
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()
// 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)
// Small amplitude is controlled by riverEdgeRoughness
// At 0%, no detail; at 100%, detail amplitude equals large amplitude
smallAmplitude := largeAmplitude * (riverEdgeRoughness / 100.0)
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)
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
largeWave := math.Sin(positionPhase) * largeAmplitude
smallWave := math.Sin(positionPhase*3.5) * smallAmplitude
waveOffset := largeWave + smallWave
// Effective radius varies based on sin wave
effectiveRadius := radius + waveOffset
if dist <= effectiveRadius {