diff --git a/custom_widgets.go b/custom_widgets.go new file mode 100644 index 0000000..6e1a66b --- /dev/null +++ b/custom_widgets.go @@ -0,0 +1,87 @@ +package main + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" +) + +// TextOverlayProgressBar is a custom widget that displays a progress bar with text overlay. +type TextOverlayProgressBar struct { + widget.BaseWidget + progressBar *widget.ProgressBar + label *widget.Label +} + +// NewTextOverlayProgressBar creates a new TextOverlayProgressBar. +func NewTextOverlayProgressBar() *TextOverlayProgressBar { + p := &TextOverlayProgressBar{ + progressBar: widget.NewProgressBar(), + label: widget.NewLabel(""), + } + p.ExtendBaseWidget(p) + return p +} + +// SetValue sets the progress value. +func (p *TextOverlayProgressBar) SetValue(v float64) { + p.progressBar.SetValue(v) +} + +// SetText sets the text to be displayed on the progress bar. +func (p *TextOverlayProgressBar) SetText(text string) { + p.label.SetText(text) +} + +// CreateRenderer returns a new renderer for the widget. +func (p *TextOverlayProgressBar) CreateRenderer() fyne.WidgetRenderer { + return &textOverlayProgressBarRenderer{ + progressBar: p.progressBar, + label: p.label, + objects: []fyne.CanvasObject{p.progressBar, p.label}, + } +} + +type textOverlayProgressBarRenderer struct { + progressBar *widget.ProgressBar + label *widget.Label + objects []fyne.CanvasObject +} + +func (r *textOverlayProgressBarRenderer) Layout(size fyne.Size) { + r.progressBar.Resize(size) + r.label.Resize(size) + r.label.Move(fyne.NewPos(0, 0)) +} + +func (r *textOverlayProgressBarRenderer) MinSize() fyne.Size { + return r.progressBar.MinSize() +} + +func (r *textOverlayProgressBarRenderer) Refresh() { + r.progressBar.Refresh() + r.label.Refresh() +} + +func (r *textOverlayProgressBarRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *textOverlayProgressBarRenderer) Destroy() {} + +// CustomTheme is a custom theme to make the progress bar thinner. +type CustomTheme struct { + fyne.Theme +} + +// NewCustomTheme creates a new CustomTheme. +func NewCustomTheme(theme fyne.Theme) *CustomTheme { + return &CustomTheme{Theme: theme} +} + +// Size returns the size for a given themeable item. +func (t *CustomTheme) Size(name fyne.ThemeSizeName) float32 { + if name == "progressBar.height" { + return 10 + } + return t.Theme.Size(name) +} diff --git a/go.mod b/go.mod index 166bc70..d83bf76 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/ojrac/opensimplex-go v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect diff --git a/go.sum b/go.sum index 57301d0..1e464af 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/ojrac/opensimplex-go v1.0.2 h1:l4vs0D+JCakcu5OV0kJ99oEaWJfggSc9jiLpxaWvSzs= +github.com/ojrac/opensimplex-go v1.0.2/go.mod h1:NwbXFFbXcdGgIFdiA7/REME+7n/lOf1TuEbLiZYOWnM= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/main.go b/main.go index 0a2dea8..b2b8498 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( func main() { a := app.New() + a.Settings().SetTheme(&CustomTheme{a.Settings().Theme()}) w := a.NewWindow("RPG City Maker") w.Resize(fyne.NewSize(800, 600)) @@ -202,17 +203,59 @@ func main() { errorLabel.TextSize = 12 errorLabel.Hide() - generateBtn := widget.NewButton("Generate", func() { - 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 = finalImage - canvasImg.Refresh() + var generateBtn *widget.Button + progressBar := NewTextOverlayProgressBar() + + generateBtn = widget.NewButton("Generate", func() { + go func() { + generateBtn.Disable() + defer generateBtn.Enable() + + steps := 6 + currentStep := 0 + + // Step 1: Generating Heightmap + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Generating Heightmap", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, settings.Seed) + + // Step 2: Generating Lakes + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Generating Lakes", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + lakeImage, lakePixels := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, noiseImg, settings.Seed) + finalImage := lakeImage.(*image.RGBA) + + // Step 3: Generating Trees + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Generating Trees", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + GenerateTrees(finalImage, lakePixels, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, settings.Seed) + + // Step 4: Darkening Lake Areas + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Darkening Lake Areas", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + darkenedHeightmap := DarkenLakeAreas(noiseImg, lakePixels) + + // Step 5: Applying Roughness + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Applying Roughness", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + compositeImg := ApplyRoughness(darkenedHeightmap, settings.Roughness) + + // Step 6: Finalizing Images + currentStep++ + progressBar.SetText(fmt.Sprintf("Step %d/%d: Finalizing Images", currentStep, steps)) + progressBar.SetValue(float64(currentStep) / float64(steps)) + heightmapImg.Image = compositeImg + heightmapImg.Refresh() + canvasImg.Image = finalImage + canvasImg.Refresh() + + progressBar.SetText("Generation Complete!") + }() }) widthEntry := widget.NewEntry() @@ -295,6 +338,7 @@ func main() { randomizeBtn, generateBtn, errorLabel, + progressBar, )) tabs := container.NewAppTabs( diff --git a/terrain.go b/terrain.go index 9709182..f7844db 100644 --- a/terrain.go +++ b/terrain.go @@ -8,7 +8,7 @@ import ( "math" "math/rand" - "github.com/aquilax/go-perlin" + "github.com/ojrac/opensimplex-go" ) // lakePixel represents a potential pixel to be added to a lake during growth @@ -76,7 +76,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo }) totalArea := float64(width * height) - p := perlin.NewPerlin(2.0, 2.0, 1, randSrc.Int63()) + noiseGen := opensimplex.New(seed) // 3. Generate a lake in a subset of the chunks for i := 0; i < numLakes; i++ { @@ -128,7 +128,7 @@ func GenerateLakes(width, height, numLakes int, lakeSizeLower, lakeSizeUpper flo getScore := func(pt image.Point) float64 { dx, dy := pt.X-startPt.X, pt.Y-startPt.Y dist := math.Sqrt(float64(dx*dx + dy*dy)) - noise := p.Noise2D(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq) + noise := noiseGen.Eval2(seedX+float64(dx)*noiseFreq, seedY+float64(dy)*noiseFreq) distPenalty := math.Pow(dist/radius, 3.0) luma, _, _, _ := heightmap.At(pt.X, pt.Y).RGBA() heightmapVal := float64(luma) / 65535.0 @@ -219,72 +219,60 @@ func GenerateTrees(img *image.RGBA, lakePixels []image.Point, minTreeSize, maxTr totalArea := float64(width * height) targetTreePixels := totalArea * (treeCoverage / 100.0) numTreesToPlace := int(targetTreePixels / avgTreeArea) + if numTreesToPlace == 0 { + return + } - // 2. Generate a new noise map for tree placement. - treeNoise := perlin.NewPerlin(2, 2, 3, seed) + // 2. Generate a simplex noise map for 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 := treeNoise.Noise2D(float64(x)*treeNoiseZoom, float64(y)*treeNoiseZoom) + val := noise.Eval2(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)) + threshold := uint8(255 * (1 - (treeCoverage / 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. Determine initial clump trees + numClumpTrees := int(treeClumpiness) + if numClumpTrees > numTreesToPlace { + numClumpTrees = numTreesToPlace } - // 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 + initialPoints := make([]image.Point, 0, numClumpTrees) + for i := 0; i < numClumpTrees; i++ { + for j := 0; j < 100; j++ { // try 100 times to find a valid spot + p := image.Point{X: randSrc.Intn(width), Y: randSrc.Intn(height)} + if treeNoiseMap.GrayAt(p.X, p.Y).Y >= threshold && !isLake[p] { + initialPoints = append(initialPoints, p) + break + } } + } + // 4. Place remaining trees using Bridson's Algorithm + 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] + }, seed) + + // 5. Draw the trees. + for _, p := range allPoints { 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 { + r := size / 2 // 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++ { @@ -302,3 +290,71 @@ func GenerateTrees(img *image.RGBA, lakePixels []image.Point, minTreeSize, maxTr } } } + +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 + activeList := append([]image.Point(nil), initialPoints...) + + cellSize := minRadius / math.Sqrt(2) + gridWidth := int(math.Ceil(float64(width)/cellSize)) + 1 + gridHeight := int(math.Ceil(float64(height)/cellSize)) + 1 + grid := make([][]image.Point, gridWidth) + for i := range grid { + grid[i] = make([]image.Point, gridHeight) + } + + for _, p := range points { + gridX, gridY := int(float64(p.X)/cellSize), int(float64(p.Y)/cellSize) + grid[gridX][gridY] = p + } + + for len(activeList) > 0 { + listIndex := randSrc.Intn(len(activeList)) + p := activeList[listIndex] + found := false + for i := 0; i < k; i++ { + angle := randSrc.Float64() * 2 * math.Pi + radius := minRadius + randSrc.Float64()*minRadius + x, y := float64(p.X)+radius*math.Cos(angle), float64(p.Y)+radius*math.Sin(angle) + newPoint := image.Point{X: int(x), Y: int(y)} + + if newPoint.X < 0 || newPoint.X >= width || newPoint.Y < 0 || newPoint.Y >= height { + continue + } + + if !isValid(newPoint) { + continue + } + + gridX, gridY := int(x/cellSize), int(y/cellSize) + valid := true + for m := -1; m <= 1; m++ { + for n := -1; n <= 1; n++ { + checkX, checkY := gridX+m, gridY+n + if checkX >= 0 && checkX < gridWidth && checkY >= 0 && checkY < gridHeight && grid[checkX][checkY] != (image.Point{}) { + dist := math.Sqrt(math.Pow(float64(grid[checkX][checkY].X-newPoint.X), 2) + math.Pow(float64(grid[checkX][checkY].Y-newPoint.Y), 2)) + if dist < minRadius { + valid = false + break + } + } + } + if !valid { + break + } + } + + if valid { + points = append(points, newPoint) + activeList = append(activeList, newPoint) + grid[gridX][gridY] = newPoint + found = true + } + } + if !found { + activeList = append(activeList[:listIndex], activeList[listIndex+1:]...) + } + } + return points +}