diff --git a/heightmap.go b/heightmap.go index f7ce85e..52d86c3 100644 --- a/heightmap.go +++ b/heightmap.go @@ -4,8 +4,6 @@ import ( "image" "image/color" "image/draw" - "math/rand" - "time" "github.com/aquilax/go-perlin" ) @@ -16,10 +14,14 @@ const ( n = 3 ) -func GenerateHeightmap(width, height, octaves int) image.Image { - p := perlin.NewPerlin(alpha, beta, n, rand.New(rand.NewSource(time.Now().UnixNano())).Int63()) +func GenerateHeightmap(width, height, octaves int, scale float64, seed int64) image.Image { + p := perlin.NewPerlin(alpha, beta, n, seed) img := image.NewGray(image.Rect(0, 0, width, height)) + if scale == 0 { + scale = 100.0 + } + for x := 0; x < width; x++ { for y := 0; y < height; y++ { var noise float64 @@ -28,7 +30,7 @@ func GenerateHeightmap(width, height, octaves int) image.Image { maxAmplitude := 0.0 for i := 0; i < octaves; i++ { - noise += p.Noise2D(float64(x)*frequency/100, float64(y)*frequency/100) * amplitude + noise += p.Noise2D(float64(x)*frequency/scale, float64(y)*frequency/scale) * amplitude maxAmplitude += amplitude amplitude /= 2.0 frequency *= 2.0 diff --git a/main.go b/main.go index 6e1bf2f..0a2dea8 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,15 @@ package main import ( "fmt" + "image" + "image/color" "log" "strconv" + "time" + + "image/png" + "os" + "path/filepath" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -24,12 +31,6 @@ func main() { settings = &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5} } - w.SetOnClosed(func() { - if err := settings.Save(); err != nil { - log.Println("Error saving settings:", err) - } - }) - canvasImg := &canvas.Image{ FillMode: canvas.ImageFillContain, } @@ -38,13 +39,74 @@ func main() { FillMode: canvas.ImageFillContain, } - // Initial image generation - noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail)) - canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg) - darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) - compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) - heightmapImg.Image = compositeImg - canvasImg.Image = canvasWithLakes + configDir, err := os.UserConfigDir() + if err != nil { + log.Fatal("Failed to get user config dir:", err) + } + appConfigDir := filepath.Join(configDir, "rpgcitymakerreborn") + + canvasPath := filepath.Join(appConfigDir, "canvas.png") + heightmapPath := filepath.Join(appConfigDir, "heightmap.png") + + w.SetOnClosed(func() { + if err := settings.Save(); err != nil { + log.Println("Error saving settings:", err) + } + + if canvasImg.Image != nil { + canvasFile, err := os.Create(canvasPath) + if err != nil { + log.Println("Failed to create canvas file:", err) + } else { + defer canvasFile.Close() + if err := png.Encode(canvasFile, canvasImg.Image); err != nil { + log.Println("Failed to encode canvas image:", err) + } + } + } + + if heightmapImg.Image != nil { + heightmapFile, err := os.Create(heightmapPath) + if err != nil { + log.Println("Failed to create heightmap file:", err) + } else { + defer heightmapFile.Close() + if err := png.Encode(heightmapFile, heightmapImg.Image); err != nil { + log.Println("Failed to encode heightmap image:", err) + } + } + } + }) + + canvasFile, err := os.Open(canvasPath) + if err == nil { + defer canvasFile.Close() + img, err := png.Decode(canvasFile) + if err == nil { + canvasImg.Image = img + } + } + + heightmapFile, err := os.Open(heightmapPath) + if err == nil { + defer heightmapFile.Close() + img, err := png.Decode(heightmapFile) + if err == nil { + heightmapImg.Image = img + } + } + + if canvasImg.Image == nil || heightmapImg.Image == nil { + // Initial image generation + noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, settings.Seed) + lakeImage, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg, settings.Seed) + finalImage := lakeImage.(*image.RGBA) + GenerateTrees(finalImage, lakePixels, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, settings.Seed) + darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) + compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) + heightmapImg.Image = compositeImg + canvasImg.Image = finalImage + } detailLabel := widget.NewLabel(fmt.Sprintf("Detail: %.0f", settings.Detail)) detailSlider := widget.NewSlider(1, 16) @@ -95,35 +157,113 @@ func main() { } lakeSizeUpperSlider.SetValue(settings.LakeSizeUpper) - widthEntry := widget.NewEntry() - widthEntry.SetText(strconv.Itoa(settings.Width)) - widthEntry.OnChanged = func(s string) { - val, err := strconv.Atoi(s) - if err == nil { - settings.Width = val - } - } + minTreeSizeLabel := widget.NewLabel(fmt.Sprintf("Min Tree Size: %.0fpx", settings.MinTreeSize)) + minTreeSizeSlider := widget.NewSlider(1, 150) + maxTreeSizeLabel := widget.NewLabel(fmt.Sprintf("Max Tree Size: %.0fpx", settings.MaxTreeSize)) + maxTreeSizeSlider := widget.NewSlider(1, 150) - heightEntry := widget.NewEntry() - heightEntry.SetText(strconv.Itoa(settings.Height)) - heightEntry.OnChanged = func(s string) { - val, err := strconv.Atoi(s) - if err == nil { - settings.Height = val + minTreeSizeSlider.OnChanged = func(val float64) { + settings.MinTreeSize = val + if settings.MinTreeSize > settings.MaxTreeSize { + settings.MaxTreeSize = settings.MinTreeSize + maxTreeSizeSlider.SetValue(settings.MaxTreeSize) } + minTreeSizeLabel.SetText(fmt.Sprintf("Min Tree Size: %.0fpx", settings.MinTreeSize)) } + minTreeSizeSlider.SetValue(settings.MinTreeSize) + + maxTreeSizeSlider.OnChanged = func(val float64) { + settings.MaxTreeSize = val + if settings.MaxTreeSize < settings.MinTreeSize { + settings.MinTreeSize = settings.MaxTreeSize + minTreeSizeSlider.SetValue(settings.MinTreeSize) + } + maxTreeSizeLabel.SetText(fmt.Sprintf("Max Tree Size: %.0fpx", settings.MaxTreeSize)) + } + maxTreeSizeSlider.SetValue(settings.MaxTreeSize) + + treeCoverageLabel := widget.NewLabel(fmt.Sprintf("Tree Coverage: %.0f%%", settings.TreeCoverage)) + treeCoverageSlider := widget.NewSlider(1, 100) + treeCoverageSlider.OnChanged = func(val float64) { + settings.TreeCoverage = val + treeCoverageLabel.SetText(fmt.Sprintf("Tree Coverage: %.0f%%", settings.TreeCoverage)) + } + treeCoverageSlider.SetValue(settings.TreeCoverage) + + treeClumpinessLabel := widget.NewLabel(fmt.Sprintf("Tree Clumpiness: %.0f%%", settings.TreeClumpiness)) + treeClumpinessSlider := widget.NewSlider(0, 100) + treeClumpinessSlider.OnChanged = func(val float64) { + settings.TreeClumpiness = val + treeClumpinessLabel.SetText(fmt.Sprintf("Tree Clumpiness: %.0f%%", settings.TreeClumpiness)) + } + treeClumpinessSlider.SetValue(settings.TreeClumpiness) + + errorLabel := canvas.NewText("", color.RGBA{R: 255, A: 255}) + errorLabel.TextSize = 12 + errorLabel.Hide() generateBtn := widget.NewButton("Generate", func() { - noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail)) - canvasWithLakes, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg) + noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, settings.Seed) + lakeImage, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg, settings.Seed) + finalImage := lakeImage.(*image.RGBA) + GenerateTrees(finalImage, lakePixels, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, settings.Seed) darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) heightmapImg.Image = compositeImg heightmapImg.Refresh() - canvasImg.Image = canvasWithLakes + canvasImg.Image = finalImage canvasImg.Refresh() }) + widthEntry := widget.NewEntry() + widthEntry.SetText(strconv.Itoa(settings.Width)) + widthEntry.OnChanged = func(s string) { + val, err := strconv.Atoi(s) + if err != nil { + errorLabel.Text = "Width must be a number" + errorLabel.Show() + generateBtn.Disable() + return + } + errorLabel.Hide() + generateBtn.Enable() + settings.Width = val + } + heightEntry := widget.NewEntry() + heightEntry.SetText(strconv.Itoa(settings.Height)) + heightEntry.OnChanged = func(s string) { + val, err := strconv.Atoi(s) + if err != nil { + errorLabel.Text = "Height must be a number" + errorLabel.Show() + generateBtn.Disable() + return + } + errorLabel.Hide() + generateBtn.Enable() + settings.Height = val + } + + seedEntry := widget.NewEntry() + seedEntry.SetText(strconv.FormatInt(settings.Seed, 10)) + seedEntry.OnChanged = func(s string) { + val, err := strconv.ParseInt(s, 10, 64) + if err != nil { + errorLabel.Text = "Seed must be a number" + errorLabel.Show() + generateBtn.Disable() + return + } + errorLabel.Hide() + generateBtn.Enable() + settings.Seed = val + } + + randomizeBtn := widget.NewButton("Randomize", func() { + settings.Seed = time.Now().UnixNano() + seedEntry.SetText(strconv.FormatInt(settings.Seed, 10)) + }) + terrainTab := container.NewTabItem("Terrain", container.NewVBox( detailLabel, detailSlider, @@ -135,6 +275,14 @@ func main() { lakeSizeLowerSlider, lakeSizeUpperLabel, lakeSizeUpperSlider, + minTreeSizeLabel, + minTreeSizeSlider, + maxTreeSizeLabel, + maxTreeSizeSlider, + treeCoverageLabel, + treeCoverageSlider, + treeClumpinessLabel, + treeClumpinessSlider, )) imageTab := container.NewTabItem("Image", container.NewVBox( @@ -142,7 +290,11 @@ func main() { widthEntry, widget.NewLabel("Height:"), heightEntry, + widget.NewLabel("Seed:"), + seedEntry, + randomizeBtn, generateBtn, + errorLabel, )) tabs := container.NewAppTabs( diff --git a/settings.go b/settings.go index 14bda0b..a5a212e 100644 --- a/settings.go +++ b/settings.go @@ -4,16 +4,22 @@ import ( "encoding/json" "os" "path/filepath" + "time" ) type Settings struct { - Detail float64 `json:"detail"` - Roughness float64 `json:"roughness"` - Width int `json:"width"` - Height int `json:"height"` - Lakes int `json:"lakes"` - LakeSizeLower float64 `json:"lake_size_lower"` - LakeSizeUpper float64 `json:"lake_size_upper"` + Detail float64 `json:"detail"` + Roughness float64 `json:"roughness"` + Width int `json:"width"` + Height int `json:"height"` + Lakes int `json:"lakes"` + LakeSizeLower float64 `json:"lake_size_lower"` + LakeSizeUpper float64 `json:"lake_size_upper"` + MinTreeSize float64 `json:"min_tree_size"` + MaxTreeSize float64 `json:"max_tree_size"` + TreeCoverage float64 `json:"tree_coverage"` + TreeClumpiness float64 `json:"tree_clumpiness"` + Seed int64 `json:"seed"` } func (s *Settings) Save() error { @@ -48,7 +54,20 @@ func LoadSettings() (*Settings, error) { file, err := os.Open(configFile) if err != nil { if os.IsNotExist(err) { - return &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5}, nil + return &Settings{ + Detail: 1, + Roughness: 0, + Width: 300, + Height: 300, + Lakes: 0, + LakeSizeLower: 1, + LakeSizeUpper: 5, + MinTreeSize: 5, + MaxTreeSize: 20, + TreeCoverage: 20, + TreeClumpiness: 50, + Seed: time.Now().UnixNano(), + }, nil } return nil, err } diff --git a/terrain.go b/terrain.go index 4fc5140..9709182 100644 --- a/terrain.go +++ b/terrain.go @@ -7,7 +7,6 @@ import ( "image/draw" "math" "math/rand" - "time" "github.com/aquilax/go-perlin" ) @@ -45,7 +44,7 @@ func (pq *priorityQueue) Pop() interface{} { } // GenerateLakes creates a specific number of lakes by dividing the image into chunks and placing one lake per chunk. -func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper float64, heightmap image.Image) (image.Image, []image.Point) { +func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper float64, heightmap image.Image, seed int64) (image.Image, []image.Point) { canvas := image.NewRGBA(image.Rect(0, 0, width, height)) draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) @@ -54,7 +53,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo } var allLakePixels []image.Point - randSrc := rand.New(rand.NewSource(time.Now().UnixNano())) + randSrc := rand.New(rand.NewSource(seed)) // 1. Divide the image into a grid gridDim := int(math.Ceil(math.Sqrt(float64(numLakes)))) @@ -202,3 +201,104 @@ func DarkenLakeAreas(heightmap image.Image, lakePixels []image.Point) image.Imag return composite } + +func GenerateTrees(img *image.RGBA, lakePixels []image.Point, minTreeSize, maxTreeSize, treeCoverage, treeClumpiness float64, seed int64) { + width := img.Bounds().Dx() + height := img.Bounds().Dy() + + // 1. Calculate number of trees to place from coverage %. + avgTreeSize := (minTreeSize + maxTreeSize) / 2 + if avgTreeSize <= 0 { + return + } + avgRadius := avgTreeSize / 2 + avgTreeArea := math.Pi * avgRadius * avgRadius + if avgTreeArea == 0 { + return + } + totalArea := float64(width * height) + targetTreePixels := totalArea * (treeCoverage / 100.0) + numTreesToPlace := int(targetTreePixels / avgTreeArea) + + // 2. Generate a new noise map for tree placement. + treeNoise := perlin.NewPerlin(2, 2, 3, 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 := treeNoise.Noise2D(float64(x)*treeNoiseZoom, float64(y)*treeNoiseZoom) + val = (val + 1) / 2 // Normalize to 0-1 + treeNoiseMap.SetGray(x, y, color.Gray{Y: uint8(val * 255)}) + } + } + threshold := uint8(255 * (treeClumpiness / 100.0)) + + isLake := make(map[image.Point]bool) + for _, p := range lakePixels { + isLake[p] = true + } + + treeCenters := make(map[image.Point]float64) + randSrc := rand.New(rand.NewSource(seed)) + + isValidCenter := func(p image.Point, r float64) bool { + if p.X-int(r) < 0 || p.X+int(r) >= width || p.Y-int(r) < 0 || p.Y+int(r) >= height { + return false + } + if isLake[p] { + return false + } + for center, r2 := range treeCenters { + dist := math.Sqrt(math.Pow(float64(p.X-center.X), 2) + math.Pow(float64(p.Y-center.Y), 2)) + if dist < (r2+r)*0.75 { // Allow overlap + return false + } + } + return true + } + + // 3. Place trees in valid locations. + maxConsecutiveFails := 10000 + consecutiveFails := 0 + for len(treeCenters) < numTreesToPlace && consecutiveFails < maxConsecutiveFails { + p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)} + + // Check against noise map threshold + if treeNoiseMap.GrayAt(p.X, p.Y).Y < threshold { + consecutiveFails++ + continue + } + + size := minTreeSize + randSrc.Float64()*(maxTreeSize-minTreeSize) + if size <= 0 { + continue + } + radius := size / 2 + + if isValidCenter(p, radius) { + treeCenters[p] = radius + consecutiveFails = 0 + } else { + consecutiveFails++ + } + } + + // 4. Draw the trees. + for p, r := range treeCenters { + // Use a simple pixel-by-pixel circle drawing method + for y := p.Y - int(r); y <= p.Y+int(r); y++ { + for x := p.X - int(r); x <= p.X+int(r); x++ { + pt := image.Point{X: x, Y: y} + if !pt.In(img.Bounds()) || isLake[pt] { + continue + } + + if (math.Pow(float64(x-p.X), 2) + math.Pow(float64(y-p.Y), 2)) <= r*r { + // Blend the tree color with the background + // For simplicity, we just set a solid color for now. + img.Set(x, y, color.RGBA{R: 0, G: 100, B: 0, A: 255}) + } + } + } + } +}