From 302a9aaf74a4498738940fa0d22d39d281c19d55 Mon Sep 17 00:00:00 2001 From: grimsace Date: Thu, 12 Mar 2026 12:22:09 -0500 Subject: [PATCH] reorganized the project --- buildings.go | 73 -- custom_widgets.go | 182 ---- fortifications.go | 85 +- main.go | 2227 -------------------------------------------- mask.go | 97 -- roads.go | 66 -- seed.go | 20 - tools.go | 412 +++++++++ ui.go | 2235 +++++++++++++++++++++++++++++++++++++++++++++ water.go | 42 +- 10 files changed, 2657 insertions(+), 2782 deletions(-) delete mode 100644 custom_widgets.go delete mode 100644 mask.go delete mode 100644 seed.go create mode 100644 tools.go create mode 100644 ui.go diff --git a/buildings.go b/buildings.go index 7a7f835..23f4562 100644 --- a/buildings.go +++ b/buildings.go @@ -15,10 +15,6 @@ const ( buildingSizePercentStep = 0.5 ) -func averageImageDimension(width, height int) float64 { - return (float64(width) + float64(height)) / 2.0 -} - func clampBuildingSizePercent(v float64) float64 { if v < minBuildingSizePercent { return minBuildingSizePercent @@ -353,65 +349,6 @@ func getProceduralBuildingPixels(center image.Point, size float64, settings *Set return finalPixels, true } -// scalePixels scales the building to the final size. -func scalePixels(pixels []image.Point, finalSize float64) []image.Point { - if len(pixels) == 0 { - return pixels - } - - minX, minY := pixels[0].X, pixels[0].Y - maxX, maxY := pixels[0].X, pixels[0].Y - for _, p := range pixels { - if p.X < minX { - minX = p.X - } - if p.Y < minY { - minY = p.Y - } - if p.X > maxX { - maxX = p.X - } - if p.Y > maxY { - maxY = p.Y - } - } - - // Calculate the current dimensions - currentWidth := float64(maxX - minX) - currentHeight := float64(maxY - minY) - - scale := finalSize / math.Max(currentWidth, currentHeight) - - // Calculate the center of the bounding box - centerX := float64(minX) + currentWidth/2 - centerY := float64(minY) + currentHeight/2 - - // Scale and translate the pixels - var scaledPixels []image.Point - pixelMap := make(map[image.Point]bool) // To avoid duplicate pixels - for _, p := range pixels { - // Translate to origin - translatedX := float64(p.X) - centerX - translatedY := float64(p.Y) - centerY - - // Scale - scaledX := translatedX * scale - scaledY := translatedY * scale - - // Translate back to the center - finalX := int(math.Round(scaledX + centerX)) - finalY := int(math.Round(scaledY + centerY)) - - newPoint := image.Point{X: finalX, Y: finalY} - if !pixelMap[newPoint] { - scaledPixels = append(scaledPixels, newPoint) - pixelMap[newPoint] = true - } - } - - return scaledPixels -} - // getComponentPixels generates the pixels for a single shape component without collision checks. func getComponentPixels(center image.Point, size float64, shape string, randSrc *rand.Rand) ([]image.Point, bool) { var pixels []image.Point @@ -564,16 +501,6 @@ func getBuildingPixels(center image.Point, size float64, shape string, waterMask return pixels, true } -// isPixelInSlice checks if a pixel is already in a slice of pixels. -func isPixelInSlice(pixel image.Point, pixelSlice []image.Point) bool { - for _, p := range pixelSlice { - if p == pixel { - return true - } - } - return false -} - var ( u8BufferPool = sync.Pool{ New: func() any { return make([]uint8, 0) }, diff --git a/custom_widgets.go b/custom_widgets.go deleted file mode 100644 index 4f7bfa1..0000000 --- a/custom_widgets.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "fmt" - "math" - "strconv" - "strings" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/data/binding" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" -) - -type tappableImage struct { - widget.BaseWidget - image *fyne.Container - onTapped func() -} - -func newTappableImage(img *fyne.Container, tapped func()) *tappableImage { - ti := &tappableImage{ - image: img, - onTapped: tapped, - } - ti.ExtendBaseWidget(ti) - return ti -} - -func (t *tappableImage) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(t.image) -} - -func (t *tappableImage) Tapped(*fyne.PointEvent) { - if t.onTapped != nil { - t.onTapped() - } -} - -// numericInputSlider combines a slider and text entry for numeric input -type numericInputSlider struct { - widget.BaseWidget - value binding.Float - min, max float64 - step float64 - slider *widget.Slider - entry *widget.Entry - format string - errorLabel *widget.Label - label *widget.Label -} - -type numericInputSliderRenderer struct { - slider *numericInputSlider - label *widget.Label - entry *widget.Entry - sliderWidget *widget.Slider - errorLabel *widget.Label - layout fyne.Layout - objects []fyne.CanvasObject -} - -func (r *numericInputSliderRenderer) MinSize() fyne.Size { - return r.layout.MinSize(r.objects) -} - -func (r *numericInputSliderRenderer) Layout(size fyne.Size) { - r.layout.Layout(r.objects, size) -} - -func (r *numericInputSliderRenderer) Objects() []fyne.CanvasObject { - return r.objects -} - -func (r *numericInputSliderRenderer) Refresh() { - r.label.SetText(r.slider.label.Text) -} - -func (r *numericInputSliderRenderer) Destroy() {} - -// newNumericInputSlider creates a new numericInputSlider widget -func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider { - s := &numericInputSlider{ - min: min, - max: max, - format: format, - step: 0, - } - s.ExtendBaseWidget(s) - - s.label = widget.NewLabel(labelText) - s.value = binding.NewFloat() - s.value.Set(initialValue) - - s.slider = widget.NewSlider(min, max) - s.slider.Bind(s.value) - - s.entry = widget.NewEntry() - s.value.AddListener(binding.NewDataListener(func() { - val, _ := s.value.Get() - s.entry.SetText(fmt.Sprintf(s.format, val)) - })) - - s.errorLabel = widget.NewLabel("") - s.errorLabel.Hide() - - return s -} - -func newNumericInputSliderWithStep(min, max, initialValue, step float64, format string, labelText string) *numericInputSlider { - s := newNumericInputSlider(min, max, initialValue, format, labelText) - if step > 0 { - s.step = step - s.slider.Step = step - rounded := min + math.Round((initialValue-min)/step)*step - s.value.Set(rounded) - } - return s -} - -// validate checks text entry for valid numeric input within the defined range -func (s *numericInputSlider) validate(text string, onError func(bool)) { - text = strings.TrimSpace(text) - text = strings.TrimSuffix(text, "px") - text = strings.TrimSuffix(text, "%") - text = strings.TrimSuffix(text, "°") - text = strings.TrimSpace(text) - val, err := strconv.ParseFloat(text, 64) - if err != nil { - s.errorLabel.SetText("Not a number") - s.errorLabel.Show() - onError(true) - return - } - - if val < s.min || val > s.max { - s.errorLabel.SetText(fmt.Sprintf( - "Out of range (%s-%s)", - strconv.FormatFloat(s.min, 'f', -1, 64), - strconv.FormatFloat(s.max, 'f', -1, 64), - )) - s.errorLabel.Show() - onError(true) - return - } - if s.step > 0 { - steps := math.Round((val - s.min) / s.step) - snapped := s.min + steps*s.step - if math.Abs(val-snapped) > 1e-9 { - s.errorLabel.SetText(fmt.Sprintf("Use increments of %s", strconv.FormatFloat(s.step, 'f', -1, 64))) - s.errorLabel.Show() - onError(true) - return - } - val = snapped - } - - s.errorLabel.Hide() - onError(false) - s.value.Set(val) -} - -// CreateRenderer renders the widget as required by Fyne toolkit -func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer { - r := &numericInputSliderRenderer{ - slider: s, - label: s.label, - entry: s.entry, - sliderWidget: s.slider, - errorLabel: s.errorLabel, - } - - r.layout = layout.NewGridLayout(1) - r.objects = []fyne.CanvasObject{ - container.NewGridWithColumns(2, r.label, r.entry), - r.sliderWidget, - r.errorLabel, - } - - return r -} diff --git a/fortifications.go b/fortifications.go index 55c6f89..007ba8e 100644 --- a/fortifications.go +++ b/fortifications.go @@ -91,13 +91,13 @@ func getTurretSizePixels(settings *Settings, width, height int) float64 { // GateInfo describes a single gate in a wall ring. type GateInfo struct { - WallID int - Center image.Point // midpoint of the gap - Normal [2]float64 // outward normal (perpendicular to wall, pointing outward) - LeftTurret image.Point // turret on the left side of the road - RightTurret image.Point // turret on the right side of the road - InnerEnd image.Point // road endpoint just inside the wall - OuterEnd image.Point // road endpoint just outside the wall + WallID int + Center image.Point // midpoint of the gap + Normal [2]float64 // outward normal (perpendicular to wall, pointing outward) + LeftTurret image.Point // turret on the left side of the road + RightTurret image.Point // turret on the right side of the road + InnerEnd image.Point // road endpoint just inside the wall + OuterEnd image.Point // road endpoint just outside the wall } type FortificationLayout struct { @@ -255,7 +255,7 @@ func computeGatesForLayout( gateCenter := bpts[i].p // Estimate wall tangent and normal at this point. - tx, ty, ok := fortEstimateWallTangent(gateCenter, layout.Mask) + tx, ty, ok := estimateWallTangent(gateCenter, layout.Mask) if !ok { continue } @@ -658,15 +658,6 @@ func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) { } } -func cloneMask(src *PixelMask) *PixelMask { - if src == nil { - return nil - } - dst := NewPixelMask(src.Width, src.Height) - copy(dst.Data, src.Data) - return dst -} - func drawWallMask(img *image.RGBA, wallMask *PixelMask) { if img == nil || wallMask == nil { return @@ -792,7 +783,7 @@ func GenerateTurrets( continue } for _, g := range gates { - tx, ty, ok := fortEstimateWallTangent(g, layout.Mask) + tx, ty, ok := estimateWallTangent(g, layout.Mask) if !ok { continue } @@ -855,18 +846,6 @@ func touchesWater(x, y int, waterMask *PixelMask) bool { return false } -func averagePoint(points []image.Point) image.Point { - if len(points) == 0 { - return image.Point{} - } - var sx, sy int - for _, p := range points { - sx += p.X - sy += p.Y - } - return image.Point{X: sx / len(points), Y: sy / len(points)} -} - func drawTurret(img *image.RGBA, mask *PixelMask, center image.Point, radius int, shape string, col color.RGBA) { for dy := -radius; dy <= radius; dy++ { for dx := -radius; dx <= radius; dx++ { @@ -1029,49 +1008,3 @@ func roadGateCentersForRoad(r *Road, layout *FortificationLayout) []image.Point } return out } - -func fortEstimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) { - if wallMask == nil { - return 0, 0, false - } - const r = 4 - var pts [][2]float64 - for dy := -r; dy <= r; dy++ { - y := mid.Y + dy - if y < 0 || y >= wallMask.Height { - continue - } - for dx := -r; dx <= r; dx++ { - x := mid.X + dx - if x < 0 || x >= wallMask.Width { - continue - } - if wallMask.GetXY(x, y) { - pts = append(pts, [2]float64{float64(x), float64(y)}) - } - } - } - if len(pts) < 3 { - return 0, 0, false - } - var mx, my float64 - for _, p := range pts { - mx += p[0] - my += p[1] - } - mx /= float64(len(pts)) - my /= float64(len(pts)) - var sxx, syy, sxy float64 - for _, p := range pts { - dx := p[0] - mx - dy := p[1] - my - sxx += dx * dx - syy += dy * dy - sxy += dx * dy - } - if sxx+syy < 0.001 { - return 0, 0, false - } - theta := 0.5 * math.Atan2(2*sxy, sxx-syy) - return math.Cos(theta), math.Sin(theta), true -} diff --git a/main.go b/main.go index 51398f2..ab3920b 100644 --- a/main.go +++ b/main.go @@ -1,42 +1,20 @@ package main import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" "embed" "encoding/json" - "fmt" "image" - "image/color" "image/draw" - "image/jpeg" - "image/png" - "io" "io/fs" "log" "os" "path" "path/filepath" "sort" - "strconv" "strings" "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/data/binding" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - - "github.com/chai2010/webp" ) -const generationStepTimeout = time.Minute - //go:embed Presets/*.json var embeddedPresets embed.FS @@ -209,2051 +187,6 @@ func savePresetToFile(path string, settings *Settings) error { return os.WriteFile(path, pretty, 0644) } -func main() { - // Initialize the Fyne application and window - a := app.NewWithID("com.example.rpgcitymaker") - w := a.NewWindow("RPG City Maker") - w.Resize(fyne.NewSize(800, 600)) - - // Load settings from file, or use defaults if loading fails - settings, err := LoadSettings() - if err != nil { - log.Println("Error loading settings:", err) - // Use default settings if loading fails - settings = &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5} - } - - // Create canvas objects for displaying the generated images - canvasImg := &canvas.Image{ - FillMode: canvas.ImageFillContain, - } - heightmapImg := &canvas.Image{ - FillMode: canvas.ImageFillContain, - } - bumpmapImg := &canvas.Image{ - FillMode: canvas.ImageFillContain, - } - - // Initialize slices to store generated map features - var lakes [][]image.Point - var buildings [][]image.Point - var waterMask *PixelMask - var riverMask *PixelMask - var treeMask *PixelMask - var buildingMask *PixelMask - var roadMask *PixelMask - var bridgeMask *PixelMask - var exitRoadMask *PixelMask - var wallMask *PixelMask - var turretMask *PixelMask - - // Set up application configuration directory - 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") - bumpmapPath := filepath.Join(appConfigDir, "bumpmap.png") - - // Save settings and images on window close - 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) - } - } - } - - if bumpmapImg.Image != nil { - bumpmapFile, err := os.Create(bumpmapPath) - if err != nil { - log.Println("Failed to create bumpmap file:", err) - } else { - defer bumpmapFile.Close() - if err := png.Encode(bumpmapFile, bumpmapImg.Image); err != nil { - log.Println("Failed to encode bumpmap image:", err) - } - } - } - }) - - // Load previously saved images - 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 - } - } - - bumpmapFile, err := os.Open(bumpmapPath) - if err == nil { - defer bumpmapFile.Close() - img, err := png.Decode(bumpmapFile) - if err == nil { - bumpmapImg.Image = img - } - } - - // Generate initial images if none are loaded - if canvasImg.Image == nil || heightmapImg.Image == nil { - - // Step 1: Generating Heightmap - seedProvider := NewSeedProvider(settings.Seed) - noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, seedProvider.Next()) - prevBuildings := settings.NumBuildings - cappedBuildings := capRequestedBuildingsToFit(settings, settings.Width, settings.Height) - if cappedBuildings < prevBuildings { - log.Printf("Building count capped from %d to %d based on image size and average building size.\n", prevBuildings, cappedBuildings) - } - - // Step 2: Generating Lakes (timeout: 1 minute) - var lakeImage image.Image = image.NewRGBA(image.Rect(0, 0, settings.Width, settings.Height)) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - img image.Image - lks [][]image.Point - } { - img, lks := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, seedProvider.Next(), settings.LakeEdgeRoughness, settings.LakeShape) - return struct { - img image.Image - lks [][]image.Point - }{img: img, lks: lks} - }); ok { - lakeImage = out.img - lakes = out.lks - } else { - log.Println("GenerateLakes timed out after 1 minute; continuing.") - lakes = nil - } - - // Step 3: Generating Rivers (timeout: 1 minute) - riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) - var finalImage *image.RGBA = riverBase - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - img image.Image - rmsk *PixelMask - } { - img, rmsk := GenerateRivers(settings.Width, settings.Height, settings.Rivers, settings.MinRiverWidth, settings.MaxRiverWidth, settings.RiverCurvyness, riverBase, lakes, seedProvider.Next(), noiseImg, settings.RiverWidthVariability, settings.RiverEdgeRoughness) - return struct { - img image.Image - rmsk *PixelMask - }{img: img, rmsk: rmsk} - }); ok { - riverMask = out.rmsk - finalImage = cloneToRGBA(out.img, settings.Width, settings.Height) - } else { - log.Println("GenerateRivers timed out after 1 minute; continuing.") - riverMask = NewPixelMask(settings.Width, settings.Height) - } - waterMask = BuildMaskFromLakes(settings.Width, settings.Height, lakes) - if riverMask != nil { - waterMask.Merge(riverMask) - } - // Step 4: Preparing Road Nodes - var roadNodes []*PointOfInterest - var roadTarget int - var edgeToEdgeOnly bool - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - pois []*PointOfInterest - target int - edgeToEdge bool - } { - pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) - return struct { - pois []*PointOfInterest - target int - edgeToEdge bool - }{pois: pois, target: target, edgeToEdge: edgeToEdge} - }); ok { - roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge - } else { - log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") - roadNodes = nil - roadTarget = 0 - edgeToEdgeOnly = false - } - - // Step 5: Generating Fortifications - var wallLayout *FortificationLayout - fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - layout *FortificationLayout - } { - layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) - return struct { - layout *FortificationLayout - }{layout: layout} - }); ok { - wallLayout = out.layout - if wallLayout != nil { - wallMask = wallLayout.Mask - } else { - wallMask = NewPixelMask(settings.Width, settings.Height) - } - finalImage = fortBase - } else { - log.Println("GenerateFortifications timed out after 1 minute; continuing.") - wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} - wallMask = wallLayout.Mask - } - - // Step 6: Generating Roads - var roadAnchors []image.Point - var roadList []*Road - roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - rd *PixelMask - br *PixelMask - ex *PixelMask - anc []image.Point - rl []*Road - } { - rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) - return struct { - rd *PixelMask - br *PixelMask - ex *PixelMask - anc []image.Point - rl []*Road - }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} - }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl - drawWallMask(roadBase, wallMask) - turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) - finalImage = roadBase - } else { - log.Println("GenerateRoads timed out after 1 minute; continuing.") - roadMask = NewPixelMask(settings.Width, settings.Height) - bridgeMask = NewPixelMask(settings.Width, settings.Height) - exitRoadMask = NewPixelMask(settings.Width, settings.Height) - turretMask = NewPixelMask(settings.Width, settings.Height) - roadAnchors = nil - } - - placementMask := cloneMask(roadMask) - if placementMask == nil { - placementMask = NewPixelMask(settings.Width, settings.Height) - } - if wallMask != nil { - placementMask.Merge(wallMask) - } - if turretMask != nil { - placementMask.Merge(turretMask) - } - - // Step 7: Generating Buildings - buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - blds [][]image.Point - bmsk *PixelMask - } { - blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) - return struct { - blds [][]image.Point - bmsk *PixelMask - }{blds: blds, bmsk: bmsk} - }); ok { - buildings, buildingMask = out.blds, out.bmsk - finalImage = buildingBase - } else { - log.Println("GenerateBuildings timed out after 1 minute; continuing.") - buildings = nil - buildingMask = NewPixelMask(settings.Width, settings.Height) - } - - // Step 8: Generating Trees - treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { - return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) - }); ok { - treeMask = out - finalImage = treeBase - } else { - log.Println("GenerateTrees timed out after 1 minute; continuing.") - treeMask = NewPixelMask(settings.Width, settings.Height) - } - - // Step 7: Darkening Water Areas - darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) - - // Step 8: Flattening Building Areas - flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) - - // Step 9: Flattening Road and Wall Areas - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) - - // Step 10: Applying Roughness - compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) - - // Step 11: Generating Bump Map - bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) - - heightmapImg.Image = compositeImg - bumpmapImg.Image = bumpMap - - canvasImg.Image = finalImage - - } - var generateBtn *widget.Button - errorStates := make(map[string]bool) - var applyingPreset bool - var clearPresetSelection func() - markPresetDirty := func() { - if applyingPreset { - return - } - if clearPresetSelection != nil { - clearPresetSelection() - } - } - - // ... (other code) - - // Main generation button and logic - generateBtn = widget.NewButton("Generate", func() { - // ... (generation logic) - }) - - // Function to update the generate button state - updateGenerateBtnState := func() { - for _, hasError := range errorStates { - if hasError { - generateBtn.Disable() - return - } - } - generateBtn.Enable() - } - - detailSlider := newNumericInputSlider(1, 16, settings.Detail, "%.0f", "Detail") - detailSlider.entry.OnChanged = func(s string) { - detailSlider.validate(s, func(hasError bool) { - errorStates["detail"] = hasError - updateGenerateBtnState() - }) - } - detailSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := detailSlider.value.Get() - settings.Detail = val - markPresetDirty() - })) - - roughnessSlider := newNumericInputSlider(0, 100, settings.Roughness, "%.0f%%", "Roughness") - roughnessSlider.entry.OnChanged = func(s string) { - roughnessSlider.validate(s, func(hasError bool) { - errorStates["roughness"] = hasError - updateGenerateBtnState() - }) - } - roughnessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := roughnessSlider.value.Get() - settings.Roughness = val - markPresetDirty() - })) - // Create UI elements for controlling lake generation settings - lakesSlider := newNumericInputSlider(0, 15, float64(settings.Lakes), "%.0f", "Lakes") - lakesSlider.entry.OnChanged = func(s string) { - lakesSlider.validate(s, func(hasError bool) { - errorStates["lakes"] = hasError - updateGenerateBtnState() - }) - } - lakesSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := lakesSlider.value.Get() - settings.Lakes = int(val) - markPresetDirty() - })) - - lakeSizeLowerSlider := newNumericInputSlider(1, 100, settings.LakeSizeLower, "%.0f%%", "Min Lake Size") - lakeSizeLowerSlider.entry.OnChanged = func(s string) { - lakeSizeLowerSlider.validate(s, func(hasError bool) { - errorStates["lakeSizeLower"] = hasError - updateGenerateBtnState() - }) - } - lakeSizeLowerSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := lakeSizeLowerSlider.value.Get() - settings.LakeSizeLower = val - markPresetDirty() - })) - - lakeSizeUpperSlider := newNumericInputSlider(1, 100, settings.LakeSizeUpper, "%.0f%%", "Max Lake Size") - lakeSizeUpperSlider.entry.OnChanged = func(s string) { - lakeSizeUpperSlider.validate(s, func(hasError bool) { - errorStates["lakeSizeUpper"] = hasError - updateGenerateBtnState() - }) - } - lakeSizeUpperSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := lakeSizeUpperSlider.value.Get() - settings.LakeSizeUpper = val - markPresetDirty() - })) - - lakeEdgeRoughnessSlider := newNumericInputSlider(0, 100, settings.LakeEdgeRoughness, "%.0f%%", "Lake Edge Roughness") - lakeEdgeRoughnessSlider.entry.OnChanged = func(s string) { - lakeEdgeRoughnessSlider.validate(s, func(hasError bool) { - errorStates["lakeEdgeRoughness"] = hasError - updateGenerateBtnState() - }) - } - lakeEdgeRoughnessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := lakeEdgeRoughnessSlider.value.Get() - settings.LakeEdgeRoughness = val - markPresetDirty() - })) - - lakeShapeLabel := widget.NewLabel("Lake Shape:") - lakeShapeSelect := widget.NewSelect([]string{"circle", "oval", "procedural"}, func(s string) { - settings.LakeShape = s - markPresetDirty() - }) - lakeShapeSelect.SetSelected(settings.LakeShape) - - riversSlider := newNumericInputSlider(0, 5, float64(settings.Rivers), "%.0f", "Rivers") - riversSlider.entry.OnChanged = func(s string) { - riversSlider.validate(s, func(hasError bool) { - errorStates["rivers"] = hasError - updateGenerateBtnState() - }) - } - riversSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := riversSlider.value.Get() - settings.Rivers = int(val) - markPresetDirty() - })) - - minRiverWidthSlider := newNumericInputSlider(1, 100, settings.MinRiverWidth, "%.0f%%", "Min River Width") - minRiverWidthSlider.entry.OnChanged = func(s string) { - minRiverWidthSlider.validate(s, func(hasError bool) { - errorStates["minRiverWidth"] = hasError - updateGenerateBtnState() - }) - } - minRiverWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minRiverWidthSlider.value.Get() - settings.MinRiverWidth = val - markPresetDirty() - })) - - maxRiverWidthSlider := newNumericInputSlider(1, 100, settings.MaxRiverWidth, "%.0f%%", "Max River Width") - maxRiverWidthSlider.entry.OnChanged = func(s string) { - maxRiverWidthSlider.validate(s, func(hasError bool) { - errorStates["maxRiverWidth"] = hasError - updateGenerateBtnState() - }) - } - maxRiverWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxRiverWidthSlider.value.Get() - settings.MaxRiverWidth = val - markPresetDirty() - })) - - riverCurvynessSlider := newNumericInputSlider(0, 100, settings.RiverCurvyness, "%.0f%%", "River Curvyness") - riverCurvynessSlider.entry.OnChanged = func(s string) { - riverCurvynessSlider.validate(s, func(hasError bool) { - errorStates["riverCurvyness"] = hasError - updateGenerateBtnState() - }) - } - riverCurvynessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := riverCurvynessSlider.value.Get() - settings.RiverCurvyness = val - markPresetDirty() - })) - - riverWidthVariabilitySlider := newNumericInputSlider(0, 100, settings.RiverWidthVariability, "%.0f%%", "River Width Variability") - riverWidthVariabilitySlider.entry.OnChanged = func(s string) { - riverWidthVariabilitySlider.validate(s, func(hasError bool) { - errorStates["riverWidthVariability"] = hasError - updateGenerateBtnState() - }) - } - riverWidthVariabilitySlider.value.AddListener(binding.NewDataListener(func() { - val, _ := riverWidthVariabilitySlider.value.Get() - settings.RiverWidthVariability = val - markPresetDirty() - })) - - riverEdgeRoughnessSlider := newNumericInputSlider(0, 100, settings.RiverEdgeRoughness, "%.0f%%", "River Edge Roughness") - riverEdgeRoughnessSlider.entry.OnChanged = func(s string) { - riverEdgeRoughnessSlider.validate(s, func(hasError bool) { - errorStates["riverEdgeRoughness"] = hasError - updateGenerateBtnState() - }) - } - riverEdgeRoughnessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := riverEdgeRoughnessSlider.value.Get() - settings.RiverEdgeRoughness = val - markPresetDirty() - })) - - minTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MinTreeSize, treeSizePercentStep, "%.1f%%", "Min Tree Size") - minTreeSizeSlider.entry.OnChanged = func(s string) { - minTreeSizeSlider.validate(s, func(hasError bool) { - errorStates["minTreeSize"] = hasError - updateGenerateBtnState() - }) - } - minTreeSizeSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minTreeSizeSlider.value.Get() - settings.MinTreeSize = val - markPresetDirty() - })) - - maxTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MaxTreeSize, treeSizePercentStep, "%.1f%%", "Max Tree Size") - maxTreeSizeSlider.entry.OnChanged = func(s string) { - maxTreeSizeSlider.validate(s, func(hasError bool) { - errorStates["maxTreeSize"] = hasError - updateGenerateBtnState() - }) - } - maxTreeSizeSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxTreeSizeSlider.value.Get() - settings.MaxTreeSize = val - markPresetDirty() - })) - - treeCoverageSlider := newNumericInputSlider(1, 100, settings.TreeCoverage, "%.0f%%", "Tree Coverage") - treeCoverageSlider.entry.OnChanged = func(s string) { - treeCoverageSlider.validate(s, func(hasError bool) { - errorStates["treeCoverage"] = hasError - updateGenerateBtnState() - }) - } - treeCoverageSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := treeCoverageSlider.value.Get() - settings.TreeCoverage = val - markPresetDirty() - })) - - treeClumpinessSlider := newNumericInputSlider(0, 100, settings.TreeClumpiness, "%.0f%%", "Tree Clumpiness") - treeClumpinessSlider.entry.OnChanged = func(s string) { - treeClumpinessSlider.validate(s, func(hasError bool) { - errorStates["treeClumpiness"] = hasError - updateGenerateBtnState() - }) - } - treeClumpinessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := treeClumpinessSlider.value.Get() - settings.TreeClumpiness = val - markPresetDirty() - })) - minRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MinRoadWidth, roadWidthPercentStep, "%.1f%%", "Min Road Width") - minRoadWidthSlider.entry.OnChanged = func(s string) { - minRoadWidthSlider.validate(s, func(hasError bool) { - errorStates["minRoadWidth"] = hasError - updateGenerateBtnState() - }) - } - minRoadWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minRoadWidthSlider.value.Get() - settings.MinRoadWidth = val - markPresetDirty() - })) - - maxRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MaxRoadWidth, roadWidthPercentStep, "%.1f%%", "Max Road Width") - maxRoadWidthSlider.entry.OnChanged = func(s string) { - maxRoadWidthSlider.validate(s, func(hasError bool) { - errorStates["maxRoadWidth"] = hasError - updateGenerateBtnState() - }) - } - maxRoadWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxRoadWidthSlider.value.Get() - settings.MaxRoadWidth = val - markPresetDirty() - })) - - buildingsPerRoadSlider := newNumericInputSlider(1, 20, float64(settings.BuildingsPerRoad), "%.0f", "Buildings Per Road") - buildingsPerRoadSlider.entry.OnChanged = func(s string) { - buildingsPerRoadSlider.validate(s, func(hasError bool) { - errorStates["buildingsPerRoad"] = hasError - updateGenerateBtnState() - }) - } - buildingsPerRoadSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := buildingsPerRoadSlider.value.Get() - settings.BuildingsPerRoad = int(val) - markPresetDirty() - })) - - roadExitsSlider := newNumericInputSlider(0, 100, float64(settings.RoadExits), "%.0f", "Road Exits") - roadExitsSlider.entry.OnChanged = func(s string) { - roadExitsSlider.validate(s, func(hasError bool) { - errorStates["roadExits"] = hasError - updateGenerateBtnState() - }) - } - roadExitsSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := roadExitsSlider.value.Get() - settings.RoadExits = int(val) - markPresetDirty() - })) - - roadCurvynessSlider := newNumericInputSlider(0, 100, settings.RoadCurvyness, "%.0f%%", "Road Curvyness") - roadCurvynessSlider.entry.OnChanged = func(s string) { - roadCurvynessSlider.validate(s, func(hasError bool) { - errorStates["roadCurvyness"] = hasError - updateGenerateBtnState() - }) - } - roadCurvynessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := roadCurvynessSlider.value.Get() - settings.RoadCurvyness = val - markPresetDirty() - })) - - roadDistributionSlider := newNumericInputSlider(0, 100, settings.RoadDistribution, "%.0f%%", "Distribution") - roadDistributionSlider.entry.OnChanged = func(s string) { - roadDistributionSlider.validate(s, func(hasError bool) { - errorStates["roadDistribution"] = hasError - updateGenerateBtnState() - }) - } - roadDistributionSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := roadDistributionSlider.value.Get() - settings.RoadDistribution = val - markPresetDirty() - })) - - minRoadAngleSlider := newNumericInputSlider(0, 180, settings.MinRoadAngle, "%.0f°", "Minimum Road Angle") - minRoadAngleSlider.entry.OnChanged = func(s string) { - minRoadAngleSlider.validate(s, func(hasError bool) { - errorStates["minRoadAngle"] = hasError - updateGenerateBtnState() - }) - } - minRoadAngleSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minRoadAngleSlider.value.Get() - settings.MinRoadAngle = val - markPresetDirty() - })) - - minWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MinWallWidth, wallWidthPercentStep, "%.1f%%", "Min Wall Width") - minWallWidthSlider.entry.OnChanged = func(s string) { - minWallWidthSlider.validate(s, func(hasError bool) { - errorStates["minWallWidth"] = hasError - updateGenerateBtnState() - }) - } - minWallWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minWallWidthSlider.value.Get() - settings.MinWallWidth = val - markPresetDirty() - })) - - maxWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MaxWallWidth, wallWidthPercentStep, "%.1f%%", "Max Wall Width") - maxWallWidthSlider.entry.OnChanged = func(s string) { - maxWallWidthSlider.validate(s, func(hasError bool) { - errorStates["maxWallWidth"] = hasError - updateGenerateBtnState() - }) - } - maxWallWidthSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxWallWidthSlider.value.Get() - settings.MaxWallWidth = val - markPresetDirty() - })) - - numWallsSlider := newNumericInputSlider(0, 5, float64(settings.NumWalls), "%.0f", "Number of Walls") - numWallsSlider.entry.OnChanged = func(s string) { - numWallsSlider.validate(s, func(hasError bool) { - errorStates["numWalls"] = hasError - updateGenerateBtnState() - }) - } - numWallsSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := numWallsSlider.value.Get() - settings.NumWalls = int(val) - markPresetDirty() - })) - - cityCoverageSlider := newNumericInputSlider(1, 100, settings.CityCoverage, "%.0f%%", "City Coverage") - cityCoverageSlider.entry.OnChanged = func(s string) { - cityCoverageSlider.validate(s, func(hasError bool) { - errorStates["cityCoverage"] = hasError - updateGenerateBtnState() - }) - } - cityCoverageSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := cityCoverageSlider.value.Get() - settings.CityCoverage = val - markPresetDirty() - })) - - wallCurvynessSlider := newNumericInputSlider(0, 100, settings.WallCurvyness, "%.0f%%", "Wall Curvyness") - wallCurvynessSlider.entry.OnChanged = func(s string) { - wallCurvynessSlider.validate(s, func(hasError bool) { - errorStates["wallCurvyness"] = hasError - updateGenerateBtnState() - }) - } - wallCurvynessSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := wallCurvynessSlider.value.Get() - settings.WallCurvyness = val - markPresetDirty() - })) - - showTurretsCheck := widget.NewCheck("Show Turrets", func(v bool) { - settings.ShowTurrets = v - markPresetDirty() - }) - showTurretsCheck.SetChecked(settings.ShowTurrets) - - turretSizeSlider := newNumericInputSliderWithStep(minTurretSizePercent, maxTurretSizePercent, settings.TurretSize, turretSizePercentStep, "%.1f%%", "Turret Size") - turretSizeSlider.entry.OnChanged = func(s string) { - turretSizeSlider.validate(s, func(hasError bool) { - errorStates["turretSize"] = hasError - updateGenerateBtnState() - }) - } - turretSizeSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := turretSizeSlider.value.Get() - settings.TurretSize = val - markPresetDirty() - })) - - turretShapeLabel := widget.NewLabel("Turret Shape:") - turretShapeSelect := widget.NewSelect([]string{"circular", "square"}, func(s string) { - settings.TurretShape = s - markPresetDirty() - }) - turretShapeSelect.SetSelected(settings.TurretShape) - - turretSpacingSlider := newNumericInputSlider(0, 100, settings.TurretSpacing, "%.0f%%", "Turret Spacing") - turretSpacingSlider.entry.OnChanged = func(s string) { - turretSpacingSlider.validate(s, func(hasError bool) { - errorStates["turretSpacing"] = hasError - updateGenerateBtnState() - }) - } - turretSpacingSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := turretSpacingSlider.value.Get() - settings.TurretSpacing = val - markPresetDirty() - })) - - gateSpacingSlider := newNumericInputSlider(0, 100, settings.GateSpacing, "%.0f%%", "Gate Spacing") - gateSpacingSlider.entry.OnChanged = func(s string) { - gateSpacingSlider.validate(s, func(hasError bool) { - errorStates["gateSpacing"] = hasError - updateGenerateBtnState() - }) - } - gateSpacingSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := gateSpacingSlider.value.Get() - settings.GateSpacing = val - markPresetDirty() - })) - - turretControls := container.NewVBox( - turretSizeSlider, - turretShapeLabel, - turretShapeSelect, - turretSpacingSlider, - ) - if !settings.ShowTurrets { - turretControls.Hide() - } - showTurretsCheck.OnChanged = func(v bool) { - settings.ShowTurrets = v - if v { - turretControls.Show() - } else { - turretControls.Hide() - } - markPresetDirty() - } - - // Create UI elements for error display and action buttons - var applySettingsToUI func(*Settings) - errorLabel := widget.NewLabel("") - errorLabel.Wrapping = fyne.TextWrapWord - errorLabel.Hide() - progressLabel := widget.NewLabel("") - progressBar := widget.NewProgressBar() - progressContainer := container.NewVBox(progressLabel, progressBar) - progressContainer.Hide() - exportCanvasBtn := widget.NewButton("Export Canvas", func() { - showSaveDialog(w, canvasImg.Image, settings) - }) - exportHeightmapBtn := widget.NewButton("Export Heightmap", func() { - showSaveDialog(w, heightmapImg.Image, settings) - }) - exportBumpmapBtn := widget.NewButton("Export Bump Map", func() { - showSaveDialog(w, bumpmapImg.Image, settings) - }) - exportMasksBtn := widget.NewButton("Export Masks", func() { - showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask) - }) - exportSettingsBtn := widget.NewButton("Export Settings", func() { - showSettingsExportDialog(w, settings) - }) - importSettingsBtn := widget.NewButton("Import Settings", func() { - showSettingsImportDialog(w, settings, applySettingsToUI) - }) - // Main generation button and logic - generateBtn = widget.NewButton("Generate", func() { - go func() { - var timedOutSteps []string - addTimeout := func(step string) { - timedOutSteps = append(timedOutSteps, step) - } - // Disable button and show progress bar during generation - fyne.Do(func() { - generateBtn.Disable() - progressContainer.Show() - }) - defer func() { - // Re-enable button and hide progress bar after generation - fyne.Do(func() { - generateBtn.Enable() - progressContainer.Hide() - }) - }() - seedProvider := NewSeedProvider(settings.Seed) - prevBuildings := settings.NumBuildings - cappedBuildings := capRequestedBuildingsToFit(settings, settings.Width, settings.Height) - if cappedBuildings < prevBuildings { - log.Printf("Building count capped from %d to %d based on image size and average building size.\n", prevBuildings, cappedBuildings) - } - // Step 1: Generating Heightmap - fyne.Do(func() { - progressLabel.SetText("Step 1 of 13: Generating Heightmap") - progressBar.SetValue(1.0 / 13.0) - }) - noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, seedProvider.Next()) - - // Step 2: Generating Lakes - - fyne.Do(func() { - progressLabel.SetText("Step 2 of 13: Generating Lakes") - progressBar.SetValue(2.0 / 13.0) - }) - var lakeImage image.Image = image.NewRGBA(image.Rect(0, 0, settings.Width, settings.Height)) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - img image.Image - lks [][]image.Point - } { - img, lks := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, seedProvider.Next(), settings.LakeEdgeRoughness, settings.LakeShape) - return struct { - img image.Image - lks [][]image.Point - }{img: img, lks: lks} - }); ok { - lakeImage = out.img - lakes = out.lks - } else { - log.Println("GenerateLakes timed out after 1 minute; continuing.") - addTimeout("Lakes") - lakes = nil - } - - // Step 3: Generating Rivers - - fyne.Do(func() { - progressLabel.SetText("Step 3 of 13: Generating Rivers") - progressBar.SetValue(3.0 / 13.0) - }) - riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) - finalImage := riverBase - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - img image.Image - rmsk *PixelMask - } { - img, rmsk := GenerateRivers(settings.Width, settings.Height, settings.Rivers, settings.MinRiverWidth, settings.MaxRiverWidth, settings.RiverCurvyness, riverBase, lakes, seedProvider.Next(), noiseImg, settings.RiverWidthVariability, settings.RiverEdgeRoughness) - return struct { - img image.Image - rmsk *PixelMask - }{img: img, rmsk: rmsk} - }); ok { - riverMask = out.rmsk - finalImage = cloneToRGBA(out.img, settings.Width, settings.Height) - } else { - log.Println("GenerateRivers timed out after 1 minute; continuing.") - addTimeout("Rivers") - riverMask = NewPixelMask(settings.Width, settings.Height) - } - waterMask = BuildMaskFromLakes(settings.Width, settings.Height, lakes) - if riverMask != nil { - waterMask.Merge(riverMask) - } - - // Step 4: Preparing Road Nodes - - fyne.Do(func() { - progressLabel.SetText("Step 4 of 13: Preparing Road Nodes") - progressBar.SetValue(4.0 / 13.0) - }) - var roadNodes []*PointOfInterest - var roadTarget int - var edgeToEdgeOnly bool - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - pois []*PointOfInterest - target int - edgeToEdge bool - } { - pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) - return struct { - pois []*PointOfInterest - target int - edgeToEdge bool - }{pois: pois, target: target, edgeToEdge: edgeToEdge} - }); ok { - roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge - } else { - log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") - addTimeout("Road Nodes") - roadNodes = nil - roadTarget = 0 - edgeToEdgeOnly = false - } - - // Step 5: Generating Fortifications - - fyne.Do(func() { - progressLabel.SetText("Step 5 of 13: Generating Fortifications") - progressBar.SetValue(5.0 / 13.0) - }) - var wallLayout *FortificationLayout - fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - layout *FortificationLayout - } { - layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) - return struct { - layout *FortificationLayout - }{layout: layout} - }); ok { - wallLayout = out.layout - if wallLayout != nil { - wallMask = wallLayout.Mask - } else { - wallMask = NewPixelMask(settings.Width, settings.Height) - } - finalImage = fortBase - } else { - log.Println("GenerateFortifications timed out after 1 minute; continuing.") - addTimeout("Fortifications") - wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} - wallMask = wallLayout.Mask - } - - // Step 6: Generating Roads - - fyne.Do(func() { - progressLabel.SetText("Step 6 of 13: Generating Roads") - progressBar.SetValue(6.0 / 13.0) - }) - var roadAnchors []image.Point - var roadList []*Road - roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - rd *PixelMask - br *PixelMask - ex *PixelMask - anc []image.Point - rl []*Road - } { - rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) - return struct { - rd *PixelMask - br *PixelMask - ex *PixelMask - anc []image.Point - rl []*Road - }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} - }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl - drawWallMask(roadBase, wallMask) - turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) - finalImage = roadBase - } else { - log.Println("GenerateRoads timed out after 1 minute; continuing.") - addTimeout("Roads") - roadMask = NewPixelMask(settings.Width, settings.Height) - bridgeMask = NewPixelMask(settings.Width, settings.Height) - exitRoadMask = NewPixelMask(settings.Width, settings.Height) - turretMask = NewPixelMask(settings.Width, settings.Height) - roadAnchors = nil - } - placementMask := cloneMask(roadMask) - if placementMask == nil { - placementMask = NewPixelMask(settings.Width, settings.Height) - } - if wallMask != nil { - placementMask.Merge(wallMask) - } - if turretMask != nil { - placementMask.Merge(turretMask) - } - - // Step 7: Generating Buildings - - fyne.Do(func() { - progressLabel.SetText("Step 7 of 13: Generating Buildings") - progressBar.SetValue(7.0 / 13.0) - }) - buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() struct { - blds [][]image.Point - bmsk *PixelMask - } { - blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) - return struct { - blds [][]image.Point - bmsk *PixelMask - }{blds: blds, bmsk: bmsk} - }); ok { - buildings, buildingMask = out.blds, out.bmsk - finalImage = buildingBase - } else { - log.Println("GenerateBuildings timed out after 1 minute; continuing.") - addTimeout("Buildings") - buildings = nil - buildingMask = NewPixelMask(settings.Width, settings.Height) - } - - // Step 8: Darkening Water Areas - - fyne.Do(func() { - progressLabel.SetText("Step 8 of 13: Darkening Water Areas") - progressBar.SetValue(8.0 / 13.0) - }) - darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) - - // Step 9: Flattening Building Areas - - fyne.Do(func() { - progressLabel.SetText("Step 9 of 13: Flattening Building Areas") - progressBar.SetValue(9.0 / 13.0) - }) - flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) - - fyne.Do(func() { - progressLabel.SetText("Step 10 of 13: Flattening Road and Wall Areas") - progressBar.SetValue(10.0 / 13.0) - }) - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) - - // Step 11: Applying Roughness - - fyne.Do(func() { - progressLabel.SetText("Step 11 of 13: Applying Roughness") - progressBar.SetValue(11.0 / 13.0) - }) - compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) - - // Step 12: Generating Trees - - fyne.Do(func() { - progressLabel.SetText("Step 12 of 13: Generating Trees") - progressBar.SetValue(12.0 / 13.0) - }) - treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) - if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { - return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) - }); ok { - treeMask = out - finalImage = treeBase - } else { - log.Println("GenerateTrees timed out after 1 minute; continuing.") - addTimeout("Trees") - treeMask = NewPixelMask(settings.Width, settings.Height) - } - - // Step 13: Generating Bump Map - - fyne.Do(func() { - progressLabel.SetText("Step 13 of 13: Generating Bump Map") - progressBar.SetValue(1.0) - }) - bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) - - // Step 11: Finalizing Images - - fyne.Do(func() { - heightmapImg.Image = compositeImg - heightmapImg.Refresh() - bumpmapImg.Image = bumpMap - bumpmapImg.Refresh() - canvasImg.Image = finalImage - canvasImg.Refresh() - if len(timedOutSteps) > 0 { - errorLabel.SetText("Generation timed out after 1 minute for: " + strings.Join(timedOutSteps, ", ") + ". Partial results were used.") - errorLabel.Show() - } else { - errorLabel.Hide() - } - }) - }() - }) - // Create UI elements for image dimensions and seed - 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)) - }) - // Create tabs for organizing settings - terrainTab := container.NewTabItem("Terrain", container.NewVBox( - detailSlider, - roughnessSlider, - - widget.NewLabel(""), // Spacer - - minTreeSizeSlider, - maxTreeSizeSlider, - treeCoverageSlider, - treeClumpinessSlider, - )) - - waterTab := container.NewTabItem("Water", container.NewVBox( - lakesSlider, - lakeSizeLowerSlider, - lakeSizeUpperSlider, - lakeEdgeRoughnessSlider, - lakeShapeLabel, - lakeShapeSelect, - - widget.NewLabel(""), // Spacer - - riversSlider, - minRiverWidthSlider, - maxRiverWidthSlider, - riverCurvynessSlider, - riverWidthVariabilitySlider, - riverEdgeRoughnessSlider, - )) - - roadsTab := container.NewTabItem("Roads", container.NewVBox( - minRoadWidthSlider, - maxRoadWidthSlider, - buildingsPerRoadSlider, - roadExitsSlider, - minRoadAngleSlider, - roadCurvynessSlider, - roadDistributionSlider, - )) - - fortificationsTab := container.NewTabItem("Fortifications", container.NewVBox( - minWallWidthSlider, - maxWallWidthSlider, - numWallsSlider, - cityCoverageSlider, - wallCurvynessSlider, - gateSpacingSlider, - showTurretsCheck, - turretControls, - )) - numBuildingsSlider := newNumericInputSlider(0, 10000, float64(settings.NumBuildings), "%.0f", "Number of Buildings") - numBuildingsSlider.entry.OnChanged = func(s string) { - numBuildingsSlider.validate(s, func(hasError bool) { - errorStates["numBuildings"] = hasError - updateGenerateBtnState() - }) - } - numBuildingsSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := numBuildingsSlider.value.Get() - settings.NumBuildings = int(val) - markPresetDirty() - })) - - minBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MinBuildingSize, buildingSizePercentStep, "%.1f%%", "Min Building Size") - minBuildingSizeSlider.entry.OnChanged = func(s string) { - minBuildingSizeSlider.validate(s, func(hasError bool) { - errorStates["minBuildingSize"] = hasError - updateGenerateBtnState() - }) - } - minBuildingSizeSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minBuildingSizeSlider.value.Get() - settings.MinBuildingSize = val - markPresetDirty() - })) - - maxBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MaxBuildingSize, buildingSizePercentStep, "%.1f%%", "Max Building Size") - maxBuildingSizeSlider.entry.OnChanged = func(s string) { - maxBuildingSizeSlider.validate(s, func(hasError bool) { - errorStates["maxBuildingSize"] = hasError - updateGenerateBtnState() - }) - } - maxBuildingSizeSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxBuildingSizeSlider.value.Get() - settings.MaxBuildingSize = val - markPresetDirty() - })) - - buildingDistributionSlider := newNumericInputSlider(0, 100, settings.BuildingDistribution, "%.0f%%", "Building Distribution") - buildingDistributionSlider.entry.OnChanged = func(s string) { - buildingDistributionSlider.validate(s, func(hasError bool) { - errorStates["buildingDistribution"] = hasError - updateGenerateBtnState() - }) - } - buildingDistributionSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := buildingDistributionSlider.value.Get() - settings.BuildingDistribution = val - markPresetDirty() - })) - - buildingShapeLabel := widget.NewLabel("Building Shape:") - buildingShapeSelect := widget.NewSelect([]string{"squares", "circles", "rectangles", "mixed", "procedural"}, func(s string) { - settings.BuildingShape = s - markPresetDirty() - }) - buildingShapeSelect.SetSelected(settings.BuildingShape) - - // Create sliders for building shape ratios (visible only when "mixed" is selected) - squareRatioSlider := widget.NewSlider(0, 100) - circleRatioSlider := widget.NewSlider(0, 100) - rectangleRatioSlider := widget.NewSlider(0, 100) - - squareRatioLabel := widget.NewLabel(fmt.Sprintf("Squares: %.0f%%", settings.BuildingShapeRatios["squares"])) - circleRatioLabel := widget.NewLabel(fmt.Sprintf("Circles: %.0f%%", settings.BuildingShapeRatios["circles"])) - rectangleRatioLabel := widget.NewLabel(fmt.Sprintf("Rectangles: %.0f%%", settings.BuildingShapeRatios["rectangles"])) - - squareRatioSlider.SetValue(settings.BuildingShapeRatios["squares"]) - circleRatioSlider.SetValue(settings.BuildingShapeRatios["circles"]) - rectangleRatioSlider.SetValue(settings.BuildingShapeRatios["rectangles"]) - - ratioContainer := container.NewVBox( - squareRatioLabel, - squareRatioSlider, - circleRatioLabel, - circleRatioSlider, - rectangleRatioLabel, - rectangleRatioSlider, - ) - - minBuildingComplexitySlider := newNumericInputSlider(1, 6, float64(settings.MinBuildingComplexity), "%.0f", "Min Building Complexity") - minBuildingComplexitySlider.entry.OnChanged = func(s string) { - minBuildingComplexitySlider.validate(s, func(hasError bool) { - errorStates["minBuildingComplexity"] = hasError - updateGenerateBtnState() - }) - } - minBuildingComplexitySlider.value.AddListener(binding.NewDataListener(func() { - val, _ := minBuildingComplexitySlider.value.Get() - settings.MinBuildingComplexity = int(val) - markPresetDirty() - })) - - maxBuildingComplexitySlider := newNumericInputSlider(1, 6, float64(settings.MaxBuildingComplexity), "%.0f", "Max Building Complexity") - maxBuildingComplexitySlider.entry.OnChanged = func(s string) { - maxBuildingComplexitySlider.validate(s, func(hasError bool) { - errorStates["maxBuildingComplexity"] = hasError - updateGenerateBtnState() - }) - } - maxBuildingComplexitySlider.value.AddListener(binding.NewDataListener(func() { - val, _ := maxBuildingComplexitySlider.value.Get() - settings.MaxBuildingComplexity = int(val) - markPresetDirty() - })) - - buildingComplexityRatioSlider := newNumericInputSlider(0, 100, settings.BuildingComplexityRatio, "%.0f%%", "Building Complexity Ratio") - buildingComplexityRatioSlider.entry.OnChanged = func(s string) { - buildingComplexityRatioSlider.validate(s, func(hasError bool) { - errorStates["buildingComplexityRatio"] = hasError - updateGenerateBtnState() - }) - } - buildingComplexityRatioSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := buildingComplexityRatioSlider.value.Get() - settings.BuildingComplexityRatio = val - markPresetDirty() - })) - - proceduralContainer := container.NewVBox( - minBuildingComplexitySlider, - maxBuildingComplexitySlider, - buildingComplexityRatioSlider, - ) - - updateRatioSliders := func() { - squareRatioSlider.SetValue(settings.BuildingShapeRatios["squares"]) - circleRatioSlider.SetValue(settings.BuildingShapeRatios["circles"]) - rectangleRatioSlider.SetValue(settings.BuildingShapeRatios["rectangles"]) - squareRatioLabel.SetText(fmt.Sprintf("Squares: %.0f%%", settings.BuildingShapeRatios["squares"])) - circleRatioLabel.SetText(fmt.Sprintf("Circles: %.0f%%", settings.BuildingShapeRatios["circles"])) - rectangleRatioLabel.SetText(fmt.Sprintf("Rectangles: %.0f%%", settings.BuildingShapeRatios["rectangles"])) - } - - squareRatioSlider.OnChanged = func(val float64) { - if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { - return - } - oldVal := settings.BuildingShapeRatios["squares"] - diff := val - oldVal - settings.BuildingShapeRatios["squares"] = val - settings.BuildingShapeRatios["circles"] -= diff / 2 - settings.BuildingShapeRatios["rectangles"] -= diff / 2 - - // Clamp values to 0-100 range - if settings.BuildingShapeRatios["circles"] < 0 { - settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["circles"] - settings.BuildingShapeRatios["circles"] = 0 - } - if settings.BuildingShapeRatios["rectangles"] < 0 { - settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["rectangles"] - settings.BuildingShapeRatios["rectangles"] = 0 - } - if settings.BuildingShapeRatios["circles"] > 100 { - settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["circles"] - 100 - settings.BuildingShapeRatios["circles"] = 100 - } - if settings.BuildingShapeRatios["rectangles"] > 100 { - settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["rectangles"] - 100 - settings.BuildingShapeRatios["rectangles"] = 100 - } - - updateRatioSliders() - markPresetDirty() - } - - circleRatioSlider.OnChanged = func(val float64) { - if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { - return - } - oldVal := settings.BuildingShapeRatios["circles"] - diff := val - oldVal - settings.BuildingShapeRatios["circles"] = val - settings.BuildingShapeRatios["squares"] -= diff / 2 - settings.BuildingShapeRatios["rectangles"] -= diff / 2 - // Clamp values to 0-100 range - if settings.BuildingShapeRatios["squares"] < 0 { - settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["squares"] - settings.BuildingShapeRatios["squares"] = 0 - } - if settings.BuildingShapeRatios["rectangles"] < 0 { - settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["rectangles"] - settings.BuildingShapeRatios["rectangles"] = 0 - } - if settings.BuildingShapeRatios["squares"] > 100 { - settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["squares"] - 100 - settings.BuildingShapeRatios["squares"] = 100 - } - if settings.BuildingShapeRatios["rectangles"] > 100 { - settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["rectangles"] - 100 - settings.BuildingShapeRatios["rectangles"] = 100 - } - - updateRatioSliders() - markPresetDirty() - } - - rectangleRatioSlider.OnChanged = func(val float64) { - if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { - return - } - oldVal := settings.BuildingShapeRatios["rectangles"] - diff := val - oldVal - settings.BuildingShapeRatios["rectangles"] = val - settings.BuildingShapeRatios["squares"] -= diff / 2 - settings.BuildingShapeRatios["circles"] -= diff / 2 - - // Clamp values to 0-100 range - if settings.BuildingShapeRatios["squares"] < 0 { - settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["squares"] - settings.BuildingShapeRatios["squares"] = 0 - } - if settings.BuildingShapeRatios["circles"] < 0 { - settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["circles"] - settings.BuildingShapeRatios["circles"] = 0 - } - if settings.BuildingShapeRatios["squares"] > 100 { - settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["squares"] - 100 - settings.BuildingShapeRatios["squares"] = 100 - } - if settings.BuildingShapeRatios["circles"] > 100 { - settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["circles"] - 100 - settings.BuildingShapeRatios["circles"] = 100 - } - - updateRatioSliders() - markPresetDirty() - } - - buildingShapeSelect.OnChanged = func(s string) { - settings.BuildingShape = s - if s == "mixed" { - proceduralContainer.Hide() - ratioContainer.Show() - } else if s == "procedural" { - proceduralContainer.Show() - ratioContainer.Show() - } else { - proceduralContainer.Hide() - ratioContainer.Hide() - } - markPresetDirty() - } - // Initial visibility check - if settings.BuildingShape == "mixed" { - proceduralContainer.Hide() - ratioContainer.Show() - } else if settings.BuildingShape == "procedural" { - proceduralContainer.Show() - ratioContainer.Show() - } else { - proceduralContainer.Hide() - ratioContainer.Hide() - } - - applySettingsToUI = func(imported *Settings) { - lastExportPath := settings.LastExportPath - imageViewState := settings.ImageViewState - - *settings = *imported - settings.LastExportPath = lastExportPath - settings.ImageViewState = imageViewState - if settings.BuildingShapeRatios == nil { - settings.BuildingShapeRatios = map[string]float64{ - "squares": 40, - "circles": 30, - "rectangles": 30, - } - } - - widthEntry.SetText(strconv.Itoa(settings.Width)) - heightEntry.SetText(strconv.Itoa(settings.Height)) - seedEntry.SetText(strconv.FormatInt(settings.Seed, 10)) - - detailSlider.value.Set(settings.Detail) - roughnessSlider.value.Set(settings.Roughness) - - lakesSlider.value.Set(float64(settings.Lakes)) - lakeSizeLowerSlider.value.Set(settings.LakeSizeLower) - lakeSizeUpperSlider.value.Set(settings.LakeSizeUpper) - lakeEdgeRoughnessSlider.value.Set(settings.LakeEdgeRoughness) - lakeShapeSelect.SetSelected(settings.LakeShape) - - riversSlider.value.Set(float64(settings.Rivers)) - minRiverWidthSlider.value.Set(settings.MinRiverWidth) - maxRiverWidthSlider.value.Set(settings.MaxRiverWidth) - riverCurvynessSlider.value.Set(settings.RiverCurvyness) - riverWidthVariabilitySlider.value.Set(settings.RiverWidthVariability) - riverEdgeRoughnessSlider.value.Set(settings.RiverEdgeRoughness) - - minTreeSizeSlider.value.Set(settings.MinTreeSize) - maxTreeSizeSlider.value.Set(settings.MaxTreeSize) - treeCoverageSlider.value.Set(settings.TreeCoverage) - treeClumpinessSlider.value.Set(settings.TreeClumpiness) - - minRoadWidthSlider.value.Set(settings.MinRoadWidth) - maxRoadWidthSlider.value.Set(settings.MaxRoadWidth) - buildingsPerRoadSlider.value.Set(float64(settings.BuildingsPerRoad)) - roadExitsSlider.value.Set(float64(settings.RoadExits)) - roadCurvynessSlider.value.Set(settings.RoadCurvyness) - roadDistributionSlider.value.Set(settings.RoadDistribution) - minRoadAngleSlider.value.Set(settings.MinRoadAngle) - - minWallWidthSlider.value.Set(settings.MinWallWidth) - maxWallWidthSlider.value.Set(settings.MaxWallWidth) - numWallsSlider.value.Set(float64(settings.NumWalls)) - cityCoverageSlider.value.Set(settings.CityCoverage) - wallCurvynessSlider.value.Set(settings.WallCurvyness) - gateSpacingSlider.value.Set(settings.GateSpacing) - showTurretsCheck.SetChecked(settings.ShowTurrets) - turretSizeSlider.value.Set(settings.TurretSize) - turretShapeSelect.SetSelected(settings.TurretShape) - turretSpacingSlider.value.Set(settings.TurretSpacing) - - numBuildingsSlider.value.Set(float64(settings.NumBuildings)) - minBuildingSizeSlider.value.Set(settings.MinBuildingSize) - maxBuildingSizeSlider.value.Set(settings.MaxBuildingSize) - buildingDistributionSlider.value.Set(settings.BuildingDistribution) - buildingShapeSelect.SetSelected(settings.BuildingShape) - updateRatioSliders() - minBuildingComplexitySlider.value.Set(float64(settings.MinBuildingComplexity)) - maxBuildingComplexitySlider.value.Set(float64(settings.MaxBuildingComplexity)) - buildingComplexityRatioSlider.value.Set(settings.BuildingComplexityRatio) - - for key := range errorStates { - errorStates[key] = false - } - errorLabel.Hide() - updateGenerateBtnState() - } - - presetDir := filepath.Join(appConfigDir, "presets") - var presetInfoByName map[string]presetInfo - var suppressPresetApply bool - var removePresetBtn *widget.Button - - presetSelect := widget.NewSelect([]string{"No Preset"}, func(name string) { - if suppressPresetApply { - return - } - if name == "" || name == "No Preset" { - if removePresetBtn != nil { - removePresetBtn.Disable() - } - return - } - info, ok := presetInfoByName[name] - if !ok { - return - } - applyingPreset = true - var imported *Settings - var err error - if info.Source == "embedded" { - imported, err = loadEmbeddedPreset(info.Path) - } else { - raw, readErr := os.ReadFile(info.Path) - if readErr != nil { - err = readErr - } else { - imported, err = decodeSettingsJSON(raw) - } - } - if err != nil { - applyingPreset = false - dialog.ShowError(err, w) - return - } - imported.Seed = settings.Seed - applySettingsToUI(imported) - time.AfterFunc(50*time.Millisecond, func() { - fyne.Do(func() { - applyingPreset = false - }) - }) - if removePresetBtn != nil { - removePresetBtn.Enable() - } - }) - presetSelect.SetSelected("No Preset") - clearPresetSelection = func() { - if presetSelect.Selected != "No Preset" { - presetSelect.SetSelected("No Preset") - } - } - - refreshPresetOptions := func(selectName string) { - names, info := buildPresetOptions(settings, presetDir) - presetInfoByName = info - suppressPresetApply = true - presetSelect.Options = names - presetSelect.Refresh() - - if selectName == "" { - selectName = presetSelect.Selected - } - if _, ok := presetInfoByName[selectName]; !ok { - selectName = "No Preset" - } - presetSelect.SetSelected(selectName) - if removePresetBtn != nil { - if selectName == "" || selectName == "No Preset" { - removePresetBtn.Disable() - } else { - removePresetBtn.Enable() - } - } - suppressPresetApply = false - } - - addPresetBtn := widget.NewButton("Add Preset", func() { - nameEntry := widget.NewEntry() - nameEntry.SetPlaceHolder("Preset Name") - - content := container.NewVBox( - widget.NewLabel("Preset Name:"), - nameEntry, - ) - - var addDialog *dialog.CustomDialog - saveBtn := widget.NewButton("Save", func() { - name := strings.TrimSpace(nameEntry.Text) - if name == "" || name == "No Preset" { - return - } - if strings.ContainsAny(name, "/\\") { - dialog.ShowError(fmt.Errorf("preset name cannot contain path separators"), w) - return - } - if _, exists := presetInfoByName[name]; exists { - dialog.ShowError(fmt.Errorf("preset name already exists"), w) - return - } - if err := os.MkdirAll(presetDir, 0755); err != nil { - dialog.ShowError(err, w) - return - } - filePath := filepath.Join(presetDir, name+".json") - if err := savePresetToFile(filePath, settings); err != nil { - dialog.ShowError(err, w) - return - } - refreshPresetOptions(name) - if addDialog != nil { - addDialog.Hide() - } - }) - saveBtn.Disable() - - nameEntry.OnChanged = func(text string) { - if strings.TrimSpace(text) == "" { - saveBtn.Disable() - } else { - saveBtn.Enable() - } - } - - addDialog = dialog.NewCustom("Add Preset", "Cancel", content, w) - addDialog.SetButtons([]fyne.CanvasObject{ - widget.NewButton("Cancel", func() { - addDialog.Hide() - }), - saveBtn, - }) - addDialog.Show() - }) - - removePresetBtn = widget.NewButton("Remove Preset", func() { - name := presetSelect.Selected - if name == "" || name == "No Preset" { - return - } - info, ok := presetInfoByName[name] - if !ok { - return - } - if info.Source == "embedded" { - found := false - for _, existing := range settings.HiddenPresets { - if existing == name { - found = true - break - } - } - if !found { - settings.HiddenPresets = append(settings.HiddenPresets, name) - if err := settings.Save(); err != nil { - log.Println("Error saving hidden preset list:", err) - } - } - } else { - if err := os.Remove(info.Path); err != nil { - dialog.ShowError(err, w) - return - } - } - refreshPresetOptions("No Preset") - }) - removePresetBtn.Disable() - refreshPresetOptions("No Preset") - - buildingsTab := container.NewTabItem("Buildings", container.NewVBox( - numBuildingsSlider, - minBuildingSizeSlider, - maxBuildingSizeSlider, - buildingDistributionSlider, - buildingShapeLabel, - buildingShapeSelect, - proceduralContainer, - ratioContainer, - )) - - imageTab := container.NewTabItem("Image", container.NewVBox( - widget.NewLabel("Preset:"), - presetSelect, - container.NewGridWithColumns(2, addPresetBtn, removePresetBtn), - widget.NewSeparator(), - widget.NewLabel("Width:"), - widthEntry, widget.NewLabel("Height:"), - heightEntry, - widget.NewLabel("Seed:"), - seedEntry, - randomizeBtn, - generateBtn, - errorLabel, - progressContainer, - widget.NewSeparator(), - exportCanvasBtn, - exportHeightmapBtn, - exportBumpmapBtn, - exportMasksBtn, - exportSettingsBtn, - importSettingsBtn, - )) - // Create the main layout using a horizontal split - tabs := container.NewAppTabs( - imageTab, - terrainTab, - waterTab, - roadsTab, - fortificationsTab, - buildingsTab, - ) - - left := container.NewVBox( - widget.NewLabel("RPG City Maker Reborn"), - tabs, - ) - - right := container.NewMax() - var tappableCanvas, tappableHeightmap, tappableBumpmap *tappableImage - - updateRightPanel := func() { - var top, bottom fyne.CanvasObject - switch settings.ImageViewState { - case 1: - top = tappableCanvas - bottom = container.NewGridWithColumns(2, tappableHeightmap, tappableBumpmap) - case 2: - top = tappableHeightmap - bottom = container.NewGridWithColumns(2, tappableCanvas, tappableBumpmap) - case 3: - top = tappableBumpmap - bottom = container.NewGridWithColumns(2, tappableCanvas, tappableHeightmap) - default: - right.Objects = []fyne.CanvasObject{container.NewGridWithRows(3, tappableCanvas, tappableHeightmap, tappableBumpmap)} - right.Refresh() - return - } - split := container.NewVSplit(top, bottom) - split.Offset = 0.8 - right.Objects = []fyne.CanvasObject{split} - right.Refresh() - } - - tappableCanvas = newTappableImage(container.NewMax(canvasImg), func() { - if settings.ImageViewState == 1 { - settings.ImageViewState = 0 - } else { - settings.ImageViewState = 1 - } - updateRightPanel() - }) - tappableHeightmap = newTappableImage(container.NewMax(heightmapImg), func() { - if settings.ImageViewState == 2 { - settings.ImageViewState = 0 - } else { - settings.ImageViewState = 2 - } - updateRightPanel() - }) - tappableBumpmap = newTappableImage(container.NewMax(bumpmapImg), func() { - if settings.ImageViewState == 3 { - settings.ImageViewState = 0 - } else { - settings.ImageViewState = 3 - } - updateRightPanel() - }) - - updateRightPanel() - - split := container.NewHSplit( - left, - right, - ) - split.SetOffset(0.3) - // Set the window content and start the application - w.SetContent(split) - w.ShowAndRun() -} - -func encodeImageToWriter(w io.Writer, img image.Image, format string) error { - switch format { - case "PNG": - return png.Encode(w, img) - case "JPG": - return jpeg.Encode(w, img, nil) - case "WEBP": - return webp.Encode(w, img, &webp.Options{Lossless: true}) - } - return fmt.Errorf("unsupported format: %s", format) -} - -// showMasksSaveDialog displays a dialog for saving the generated masks. -func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask *PixelMask) { - // Create UI elements for the save dialog - fileNameEntry := widget.NewEntry() - fileNameEntry.SetPlaceHolder("masks_folder") - - formatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, nil) - formatSelect.SetSelected("PNG") - - packageSelect := widget.NewSelect([]string{"Folder", "tar.gz", "zip"}, nil) - packageSelect.SetSelected("Folder") - - pathLabel := widget.NewLabel(settings.LastExportPath) - - browseBtn := widget.NewButton("Browse", func() { - dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { - if err != nil { - log.Println("Error opening folder dialog:", err) - return - } - if uri == nil { - return - } - settings.LastExportPath = uri.Path() - pathLabel.SetText(settings.LastExportPath) - }, win) - }) - - content := container.NewVBox( - widget.NewLabel("Save to:"), - container.NewBorder(nil, nil, nil, browseBtn, pathLabel), - widget.NewLabel("Folder Name:"), - fileNameEntry, - widget.NewLabel("Format:"), - formatSelect, - widget.NewLabel("Packaging:"), - packageSelect, - ) - - saveDialog := dialog.NewCustom("Export Masks", "Cancel", content, win) - - saveBtn := widget.NewButton("Save", func() { - folderName := fileNameEntry.Text - if folderName == "" { - return - } - imgFormat := strings.ToLower(formatSelect.Selected) - bounds := canvasImg.Bounds() - maskToGray := func(mask *PixelMask) image.Image { - out := image.NewGray(bounds) - if mask == nil { - return out - } - for y := 0; y < mask.Height; y++ { - row := y * mask.Width - for x := 0; x < mask.Width; x++ { - if mask.Data[row+x] != 0 { - out.SetGray(x, y, color.Gray{Y: 255}) - } - } - } - return out - } - buildLakeMask := func() image.Image { - lakeMask := image.NewGray(bounds) - for _, lake := range lakes { - for _, p := range lake { - lakeMask.SetGray(p.X, p.Y, color.Gray{Y: 255}) - } - } - return lakeMask - } - type exportItem struct { - name string - make func() image.Image - } - items := []exportItem{ - {name: "canvas." + imgFormat, make: func() image.Image { return canvasImg }}, - {name: "heightmap." + imgFormat, make: func() image.Image { return heightmapImg }}, - {name: "bump_map." + imgFormat, make: func() image.Image { return bumpmapImg }}, - {name: "lakes_mask." + imgFormat, make: buildLakeMask}, - {name: "rivers_mask." + imgFormat, make: func() image.Image { return maskToGray(riverMask) }}, - {name: "trees_mask." + imgFormat, make: func() image.Image { return maskToGray(treeMask) }}, - {name: "roads_mask." + imgFormat, make: func() image.Image { return maskToGray(roadMask) }}, - {name: "bridges_mask." + imgFormat, make: func() image.Image { return maskToGray(bridgeMask) }}, - {name: "walls_mask." + imgFormat, make: func() image.Image { return maskToGray(wallMask) }}, - {name: "turrets_mask." + imgFormat, make: func() image.Image { return maskToGray(turretMask) }}, - {name: "buildings_mask." + imgFormat, make: func() image.Image { return maskToGray(buildingMask) }}, - } - - // Save the images based on the selected packaging option - switch packageSelect.Selected { - case "Folder": - exportPath := filepath.Join(pathLabel.Text, folderName) - if err := os.MkdirAll(exportPath, 0755); err != nil { - log.Println("Error creating directory:", err) - return - } - settings.LastExportPath = exportPath - for _, item := range items { - saveImage(item.make(), filepath.Join(exportPath, item.name)) - } - case "tar.gz": - filePath := filepath.Join(pathLabel.Text, folderName+".tar.gz") - settings.LastExportPath = filepath.Dir(filePath) - file, err := os.Create(filePath) - if err != nil { - log.Println("Error creating archive:", err) - return - } - defer file.Close() - - gw := gzip.NewWriter(file) - defer gw.Close() - tw := tar.NewWriter(gw) - defer tw.Close() - var buf bytes.Buffer - for _, item := range items { - buf.Reset() - if err := encodeImageToWriter(&buf, item.make(), formatSelect.Selected); err != nil { - log.Printf("Error encoding image %s: %v\n", item.name, err) - continue - } - hdr := &tar.Header{ - Name: item.name, - Mode: 0644, - Size: int64(buf.Len()), - } - if err := tw.WriteHeader(hdr); err != nil { - log.Printf("Error writing tar header for %s: %v\n", item.name, err) - continue - } - if _, err := tw.Write(buf.Bytes()); err != nil { - log.Printf("Error writing tar data for %s: %v\n", item.name, err) - } - } - case "zip": - filePath := filepath.Join(pathLabel.Text, folderName+".zip") - settings.LastExportPath = filepath.Dir(filePath) - file, err := os.Create(filePath) - if err != nil { - log.Println("Error creating archive:", err) - return - } - defer file.Close() - - zw := zip.NewWriter(file) - defer zw.Close() - - for _, item := range items { - f, err := zw.Create(item.name) - if err != nil { - log.Printf("Error creating zip entry for %s: %v\n", item.name, err) - continue - } - if err := encodeImageToWriter(f, item.make(), formatSelect.Selected); err != nil { - log.Printf("Error writing zip data for %s: %v\n", item.name, err) - continue - } - } - } - - saveDialog.Hide() - }) - saveBtn.Disable() - - fileNameEntry.OnChanged = func(text string) { - if text == "" { - saveBtn.Disable() - } else { - saveBtn.Enable() - } - } - - saveDialog.SetButtons([]fyne.CanvasObject{ - widget.NewButton("Cancel", func() { - saveDialog.Hide() - }), - saveBtn, - }) - - saveDialog.Show() -} - -// saveImage saves an image to the specified path. -func saveImage(img image.Image, path string) { - file, err := os.Create(path) - if err != nil { - log.Println("Error creating file:", err) - return - } - defer file.Close() - - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".png": - err = png.Encode(file, img) - case ".jpg": - err = jpeg.Encode(file, img, nil) - case ".webp": - err = webp.Encode(file, img, &webp.Options{Lossless: true}) - } - if err != nil { - log.Println("Failed to encode image:", err) - } -} - func decodeSettingsJSON(raw []byte) (*Settings, error) { var imported Settings if err := json.Unmarshal(raw, &imported); err != nil { @@ -2278,163 +211,3 @@ func saveSettingsToFile(path string, settings *Settings) error { } return os.WriteFile(path, data, 0644) } - -func showSettingsExportDialog(win fyne.Window, settings *Settings) { - fileNameEntry := widget.NewEntry() - fileNameEntry.SetPlaceHolder("settings") - - pathLabel := widget.NewLabel(settings.LastExportPath) - - browseBtn := widget.NewButton("Browse", func() { - dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { - if err != nil { - log.Println("Error opening folder dialog:", err) - return - } - if uri == nil { - return - } - settings.LastExportPath = uri.Path() - pathLabel.SetText(settings.LastExportPath) - }, win) - }) - - content := container.NewVBox( - widget.NewLabel("Save to:"), - container.NewBorder(nil, nil, nil, browseBtn, pathLabel), - widget.NewLabel("Filename:"), - fileNameEntry, - ) - - saveDialog := dialog.NewCustom("Export Settings", "Cancel", content, win) - - saveBtn := widget.NewButton("Save", func() { - fileName := strings.TrimSpace(fileNameEntry.Text) - if fileName == "" { - return - } - if !strings.HasSuffix(strings.ToLower(fileName), ".json") { - fileName += ".json" - } - filePath := filepath.Join(pathLabel.Text, fileName) - if err := saveSettingsToFile(filePath, settings); err != nil { - dialog.ShowError(err, win) - return - } - settings.LastExportPath = filepath.Dir(filePath) - saveDialog.Hide() - }) - saveBtn.Disable() - - fileNameEntry.OnChanged = func(text string) { - if strings.TrimSpace(text) == "" { - saveBtn.Disable() - } else { - saveBtn.Enable() - } - } - - saveDialog.SetButtons([]fyne.CanvasObject{ - widget.NewButton("Cancel", func() { - saveDialog.Hide() - }), - saveBtn, - }) - - saveDialog.Show() -} - -func showSettingsImportDialog(win fyne.Window, settings *Settings, apply func(*Settings)) { - dialog.ShowFileOpen(func(rc fyne.URIReadCloser, err error) { - if err != nil { - dialog.ShowError(err, win) - return - } - if rc == nil { - return - } - defer rc.Close() - - raw, err := io.ReadAll(rc) - if err != nil { - dialog.ShowError(err, win) - return - } - imported, err := decodeSettingsJSON(raw) - if err != nil { - dialog.ShowError(err, win) - return - } - if uri := rc.URI(); uri != nil { - settings.LastExportPath = filepath.Dir(uri.Path()) - } - apply(imported) - }, win) -} - -// showSaveDialog displays a dialog for saving an image. -func showSaveDialog(win fyne.Window, img image.Image, settings *Settings) { - // Create UI elements for the save dialog - fileNameEntry := widget.NewEntry() - fileNameEntry.SetPlaceHolder("image") - - formatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, nil) - formatSelect.SetSelected("PNG") - - pathLabel := widget.NewLabel(settings.LastExportPath) - - browseBtn := widget.NewButton("Browse", func() { - dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { - if err != nil { - log.Println("Error opening folder dialog:", err) - return - } - if uri == nil { - return - } - settings.LastExportPath = uri.Path() - pathLabel.SetText(settings.LastExportPath) - }, win) - }) - - content := container.NewVBox( - widget.NewLabel("Save to:"), - container.NewBorder(nil, nil, nil, browseBtn, pathLabel), - widget.NewLabel("Filename:"), - fileNameEntry, - widget.NewLabel("Format:"), - formatSelect, - ) - - saveDialog := dialog.NewCustom("Export Image", "Cancel", content, win) - - saveBtn := widget.NewButton("Save", func() { - fileName := fileNameEntry.Text - if fileName == "" { - return - } - - filePath := filepath.Join(pathLabel.Text, fileName+"."+strings.ToLower(formatSelect.Selected)) - settings.LastExportPath = filepath.Dir(filePath) - saveImage(img, filePath) - saveDialog.Hide() - }) - saveBtn.Disable() - - fileNameEntry.OnChanged = func(text string) { - if text == "" { - saveBtn.Disable() - } else { - saveBtn.Enable() - } - } - - saveDialog.SetButtons([]fyne.CanvasObject{ - widget.NewButton("Cancel", func() { - saveDialog.Hide() - }), - saveBtn, - }) - - saveDialog.Show() -} diff --git a/mask.go b/mask.go deleted file mode 100644 index b8018c5..0000000 --- a/mask.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import "image" - -// PixelMask stores per-pixel occupancy using one byte per pixel. -type PixelMask struct { - Width int - Height int - Data []uint8 -} - -func NewPixelMask(width, height int) *PixelMask { - if width <= 0 || height <= 0 { - return &PixelMask{} - } - return &PixelMask{ - Width: width, - Height: height, - Data: make([]uint8, width*height), - } -} - -func (m *PixelMask) index(x, y int) int { - return y*m.Width + x -} - -func (m *PixelMask) InBounds(x, y int) bool { - return m != nil && x >= 0 && y >= 0 && x < m.Width && y < m.Height -} - -func (m *PixelMask) GetXY(x, y int) bool { - if !m.InBounds(x, y) { - return false - } - return m.Data[m.index(x, y)] != 0 -} - -func (m *PixelMask) SetXY(x, y int) { - if m.InBounds(x, y) { - m.Data[m.index(x, y)] = 1 - } -} - -func (m *PixelMask) ClearXY(x, y int) { - if m.InBounds(x, y) { - m.Data[m.index(x, y)] = 0 - } -} - -func (m *PixelMask) GetPoint(p image.Point) bool { - return m.GetXY(p.X, p.Y) -} - -func (m *PixelMask) SetPoint(p image.Point) { - m.SetXY(p.X, p.Y) -} - -func (m *PixelMask) Merge(other *PixelMask) { - if m == nil || other == nil || m.Width != other.Width || m.Height != other.Height { - return - } - for i := range m.Data { - if other.Data[i] != 0 { - m.Data[i] = 1 - } - } -} - -func (m *PixelMask) AddPoints(points []image.Point) { - for _, p := range points { - m.SetPoint(p) - } -} - -func (m *PixelMask) ToPoints() []image.Point { - if m == nil { - return nil - } - pts := make([]image.Point, 0) - for y := 0; y < m.Height; y++ { - row := y * m.Width - for x := 0; x < m.Width; x++ { - if m.Data[row+x] != 0 { - pts = append(pts, image.Point{X: x, Y: y}) - } - } - } - return pts -} - -func BuildMaskFromLakes(width, height int, lakes [][]image.Point) *PixelMask { - mask := NewPixelMask(width, height) - for _, lake := range lakes { - mask.AddPoints(lake) - } - return mask -} diff --git a/roads.go b/roads.go index 197fc7e..998c99c 100644 --- a/roads.go +++ b/roads.go @@ -1277,54 +1277,6 @@ func enforcePerpendicularWallCrossing(points []PathPoint, start, wallStart, wall return out } -func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) { - if wallMask == nil { - return 0, 0, false - } - const r = 4 - var pts [][2]float64 - for dy := -r; dy <= r; dy++ { - y := mid.Y + dy - if y < 0 || y >= wallMask.Height { - continue - } - for dx := -r; dx <= r; dx++ { - x := mid.X + dx - if x < 0 || x >= wallMask.Width { - continue - } - if wallMask.GetXY(x, y) { - pts = append(pts, [2]float64{float64(x), float64(y)}) - } - } - } - if len(pts) < 3 { - return 0, 0, false - } - - var mx, my float64 - for _, p := range pts { - mx += p[0] - my += p[1] - } - mx /= float64(len(pts)) - my /= float64(len(pts)) - - var sxx, syy, sxy float64 - for _, p := range pts { - dx := p[0] - mx - dy := p[1] - my - sxx += dx * dx - syy += dy * dy - sxy += dx * dy - } - if sxx+syy < 0.001 { - return 0, 0, false - } - theta := 0.5 * math.Atan2(2*sxy, sxx-syy) - return math.Cos(theta), math.Sin(theta), true -} - func crossingAngleToTangentDegrees(rx, ry, tx, ty float64) float64 { rn := math.Hypot(rx, ry) tn := math.Hypot(tx, ty) @@ -1958,21 +1910,3 @@ func ensureGateRoadConnections(gateRoads []*Road, allRoads []*Road, wallLayout * } return append(allRoads, connectors...) } - -func clamp(v, lo, hi float64) float64 { - if v < lo { - return lo - } - if v > hi { - return hi - } - return v -} - -// abs returns the absolute value of an integer. -func abs(x int) int { - if x < 0 { - return -x - } - return x -} diff --git a/seed.go b/seed.go deleted file mode 100644 index 21e8cbd..0000000 --- a/seed.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import "math/rand" - -// 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 -func NewSeedProvider(seed int64) *SeedProvider { - return &SeedProvider{ - rand: rand.New(rand.NewSource(seed)), - } -} - -// Next returns the next random seed in the sequence -func (sp *SeedProvider) Next() int64 { - return sp.rand.Int63() -} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..70a2bba --- /dev/null +++ b/tools.go @@ -0,0 +1,412 @@ +package main + +import ( + "fmt" + "image" + "math" + "math/rand" + "strconv" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" +) + +// PixelMask stores per-pixel occupancy for placement and collision checks. +type PixelMask struct { + Width int + Height int + Data []uint8 +} + +// NewPixelMask creates a mask for width and height and is used for feature occupancy. +func NewPixelMask(width, height int) *PixelMask { + if width <= 0 || height <= 0 { + return &PixelMask{} + } + return &PixelMask{ + Width: width, + Height: height, + Data: make([]uint8, width*height), + } +} + +// index returns the slice index for coordinates and is used by mask helpers. +func (m *PixelMask) index(x, y int) int { + return y*m.Width + x +} + +// InBounds reports whether x,y are inside the mask and is used to guard access. +func (m *PixelMask) InBounds(x, y int) bool { + return m != nil && x >= 0 && y >= 0 && x < m.Width && y < m.Height +} + +// GetXY returns occupancy at x,y and is used for collision checks. +func (m *PixelMask) GetXY(x, y int) bool { + if !m.InBounds(x, y) { + return false + } + return m.Data[m.index(x, y)] != 0 +} + +// SetXY marks x,y as occupied and is used when placing features. +func (m *PixelMask) SetXY(x, y int) { + if m.InBounds(x, y) { + m.Data[m.index(x, y)] = 1 + } +} + +// ClearXY clears occupancy at x,y and is used for mask edits. +func (m *PixelMask) ClearXY(x, y int) { + if m.InBounds(x, y) { + m.Data[m.index(x, y)] = 0 + } +} + +// GetPoint returns occupancy at a point and is used with image.Point helpers. +func (m *PixelMask) GetPoint(p image.Point) bool { + return m.GetXY(p.X, p.Y) +} + +// SetPoint marks a point as occupied and is used with image.Point helpers. +func (m *PixelMask) SetPoint(p image.Point) { + m.SetXY(p.X, p.Y) +} + +// Merge ORs another mask into this one and is used to combine feature masks. +func (m *PixelMask) Merge(other *PixelMask) { + if m == nil || other == nil || m.Width != other.Width || m.Height != other.Height { + return + } + for i := range m.Data { + if other.Data[i] != 0 { + m.Data[i] = 1 + } + } +} + +// AddPoints marks a slice of points as occupied and is used for lake/road masks. +func (m *PixelMask) AddPoints(points []image.Point) { + for _, p := range points { + m.SetPoint(p) + } +} + +// ToPoints returns occupied points and is used to extract anchors. +func (m *PixelMask) ToPoints() []image.Point { + if m == nil { + return nil + } + pts := make([]image.Point, 0) + for y := 0; y < m.Height; y++ { + row := y * m.Width + for x := 0; x < m.Width; x++ { + if m.Data[row+x] != 0 { + pts = append(pts, image.Point{X: x, Y: y}) + } + } + } + return pts +} + +// BuildMaskFromLakes builds a water mask from lake point sets for later merges. +func BuildMaskFromLakes(width, height int, lakes [][]image.Point) *PixelMask { + mask := NewPixelMask(width, height) + for _, lake := range lakes { + mask.AddPoints(lake) + } + return mask +} + +// SeedProvider provides a deterministic stream of seeds for generation steps. +type SeedProvider struct { + rand *rand.Rand +} + +// NewSeedProvider creates a SeedProvider for splitting a root seed into steps. +func NewSeedProvider(seed int64) *SeedProvider { + return &SeedProvider{ + rand: rand.New(rand.NewSource(seed)), + } +} + +// Next returns the next random seed and is used to drive independent generators. +func (sp *SeedProvider) Next() int64 { + return sp.rand.Int63() +} + +// averageImageDimension returns (width+height)/2 and is used for percent scaling. +func averageImageDimension(width, height int) float64 { + return (float64(width) + float64(height)) / 2.0 +} + +// clamp clamps v to [lo, hi] and is used for settings normalization. +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// clamp01 clamps v to [0,1] and is used for normalized weights. +func clamp01(v float64) float64 { + return clamp(v, 0, 1) +} + +// abs returns the absolute value of an int and is used in raster helpers. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// cloneMask copies a PixelMask and is used when mutating temporary masks. +func cloneMask(src *PixelMask) *PixelMask { + if src == nil { + return nil + } + dst := NewPixelMask(src.Width, src.Height) + copy(dst.Data, src.Data) + return dst +} + +// averagePoint returns the average of points and is used for centroids. +func averagePoint(points []image.Point) image.Point { + if len(points) == 0 { + return image.Point{} + } + var sx, sy int + for _, p := range points { + sx += p.X + sy += p.Y + } + return image.Point{X: sx / len(points), Y: sy / len(points)} +} + +// estimateWallTangent approximates the tangent at a wall pixel and is used to align roads and gates. +func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) { + if wallMask == nil { + return 0, 0, false + } + const r = 4 + var pts [][2]float64 + for dy := -r; dy <= r; dy++ { + y := mid.Y + dy + if y < 0 || y >= wallMask.Height { + continue + } + for dx := -r; dx <= r; dx++ { + x := mid.X + dx + if x < 0 || x >= wallMask.Width { + continue + } + if wallMask.GetXY(x, y) { + pts = append(pts, [2]float64{float64(x), float64(y)}) + } + } + } + if len(pts) < 3 { + return 0, 0, false + } + + var mx, my float64 + for _, p := range pts { + mx += p[0] + my += p[1] + } + mx /= float64(len(pts)) + my /= float64(len(pts)) + + var sxx, syy, sxy float64 + for _, p := range pts { + dx := p[0] - mx + dy := p[1] - my + sxx += dx * dx + syy += dy * dy + sxy += dx * dy + } + if sxx+syy < 0.001 { + return 0, 0, false + } + theta := 0.5 * math.Atan2(2*sxy, sxx-syy) + return math.Cos(theta), math.Sin(theta), true +} + +type tappableImage struct { + widget.BaseWidget + image *fyne.Container + onTapped func() +} + +// newTappableImage wraps a container so taps can trigger image interactions. +func newTappableImage(img *fyne.Container, tapped func()) *tappableImage { + ti := &tappableImage{ + image: img, + onTapped: tapped, + } + ti.ExtendBaseWidget(ti) + return ti +} + +// CreateRenderer builds the renderer for tappableImage and is used by Fyne. +func (t *tappableImage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(t.image) +} + +// Tapped invokes the handler and is used for click interactions. +func (t *tappableImage) Tapped(*fyne.PointEvent) { + if t.onTapped != nil { + t.onTapped() + } +} + +// numericInputSlider combines a slider and text entry for numeric input. +type numericInputSlider struct { + widget.BaseWidget + value binding.Float + min, max float64 + step float64 + slider *widget.Slider + entry *widget.Entry + format string + errorLabel *widget.Label + label *widget.Label +} + +type numericInputSliderRenderer struct { + slider *numericInputSlider + label *widget.Label + entry *widget.Entry + sliderWidget *widget.Slider + errorLabel *widget.Label + layout fyne.Layout + objects []fyne.CanvasObject +} + +func (r *numericInputSliderRenderer) MinSize() fyne.Size { + return r.layout.MinSize(r.objects) +} + +func (r *numericInputSliderRenderer) Layout(size fyne.Size) { + r.layout.Layout(r.objects, size) +} + +func (r *numericInputSliderRenderer) Objects() []fyne.CanvasObject { + return r.objects +} + +func (r *numericInputSliderRenderer) Refresh() { + r.label.SetText(r.slider.label.Text) +} + +func (r *numericInputSliderRenderer) Destroy() {} + +// newNumericInputSlider creates a slider+entry pair for numeric settings. +func newNumericInputSlider(min, max float64, initialValue float64, format string, labelText string) *numericInputSlider { + s := &numericInputSlider{ + min: min, + max: max, + format: format, + step: 0, + } + s.ExtendBaseWidget(s) + + s.label = widget.NewLabel(labelText) + s.value = binding.NewFloat() + s.value.Set(initialValue) + + s.slider = widget.NewSlider(min, max) + s.slider.Bind(s.value) + + s.entry = widget.NewEntry() + s.value.AddListener(binding.NewDataListener(func() { + val, _ := s.value.Get() + s.entry.SetText(fmt.Sprintf(s.format, val)) + })) + + s.errorLabel = widget.NewLabel("") + s.errorLabel.Hide() + + return s +} + +// newNumericInputSliderWithStep creates a numeric slider that snaps to increments. +func newNumericInputSliderWithStep(min, max, initialValue, step float64, format string, labelText string) *numericInputSlider { + s := newNumericInputSlider(min, max, initialValue, format, labelText) + if step > 0 { + s.step = step + s.slider.Step = step + rounded := min + math.Round((initialValue-min)/step)*step + s.value.Set(rounded) + } + return s +} + +// validate checks entry input and is used for slider text validation. +func (s *numericInputSlider) validate(text string, onError func(bool)) { + text = strings.TrimSpace(text) + text = strings.TrimSuffix(text, "px") + text = strings.TrimSuffix(text, "%") + text = strings.TrimSuffix(text, "°") + text = strings.TrimSpace(text) + val, err := strconv.ParseFloat(text, 64) + if err != nil { + s.errorLabel.SetText("Not a number") + s.errorLabel.Show() + onError(true) + return + } + + if val < s.min || val > s.max { + s.errorLabel.SetText(fmt.Sprintf( + "Out of range (%s-%s)", + strconv.FormatFloat(s.min, 'f', -1, 64), + strconv.FormatFloat(s.max, 'f', -1, 64), + )) + s.errorLabel.Show() + onError(true) + return + } + if s.step > 0 { + steps := math.Round((val - s.min) / s.step) + snapped := s.min + steps*s.step + if math.Abs(val-snapped) > 1e-9 { + s.errorLabel.SetText(fmt.Sprintf("Use increments of %s", strconv.FormatFloat(s.step, 'f', -1, 64))) + s.errorLabel.Show() + onError(true) + return + } + val = snapped + } + + s.errorLabel.Hide() + onError(false) + s.value.Set(val) +} + +// CreateRenderer builds the widget layout and is used by Fyne. +func (s *numericInputSlider) CreateRenderer() fyne.WidgetRenderer { + r := &numericInputSliderRenderer{ + slider: s, + label: s.label, + entry: s.entry, + sliderWidget: s.slider, + errorLabel: s.errorLabel, + } + + r.layout = layout.NewGridLayout(1) + r.objects = []fyne.CanvasObject{ + container.NewGridWithColumns(2, r.label, r.entry), + r.sliderWidget, + r.errorLabel, + } + + return r +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..0f1106a --- /dev/null +++ b/ui.go @@ -0,0 +1,2235 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" + + "github.com/chai2010/webp" +) + +const generationStepTimeout = time.Minute + +func main() { + // Initialize the Fyne application and window + a := app.NewWithID("com.example.rpgcitymaker") + w := a.NewWindow("RPG City Maker") + w.Resize(fyne.NewSize(800, 600)) + + // Load settings from file, or use defaults if loading fails + settings, err := LoadSettings() + if err != nil { + log.Println("Error loading settings:", err) + // Use default settings if loading fails + settings = &Settings{Detail: 1, Roughness: 0, Width: 300, Height: 300, Lakes: 0, LakeSizeLower: 1, LakeSizeUpper: 5} + } + + // Create canvas objects for displaying the generated images + canvasImg := &canvas.Image{ + FillMode: canvas.ImageFillContain, + } + heightmapImg := &canvas.Image{ + FillMode: canvas.ImageFillContain, + } + bumpmapImg := &canvas.Image{ + FillMode: canvas.ImageFillContain, + } + + // Initialize slices to store generated map features + var lakes [][]image.Point + var buildings [][]image.Point + var waterMask *PixelMask + var riverMask *PixelMask + var treeMask *PixelMask + var buildingMask *PixelMask + var roadMask *PixelMask + var bridgeMask *PixelMask + var exitRoadMask *PixelMask + var wallMask *PixelMask + var turretMask *PixelMask + + // Set up application configuration directory + 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") + bumpmapPath := filepath.Join(appConfigDir, "bumpmap.png") + + // Save settings and images on window close + 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) + } + } + } + + if bumpmapImg.Image != nil { + bumpmapFile, err := os.Create(bumpmapPath) + if err != nil { + log.Println("Failed to create bumpmap file:", err) + } else { + defer bumpmapFile.Close() + if err := png.Encode(bumpmapFile, bumpmapImg.Image); err != nil { + log.Println("Failed to encode bumpmap image:", err) + } + } + } + }) + + // Load previously saved images + 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 + } + } + + bumpmapFile, err := os.Open(bumpmapPath) + if err == nil { + defer bumpmapFile.Close() + img, err := png.Decode(bumpmapFile) + if err == nil { + bumpmapImg.Image = img + } + } + + // Generate initial images if none are loaded + if canvasImg.Image == nil || heightmapImg.Image == nil { + + // Step 1: Generating Heightmap + seedProvider := NewSeedProvider(settings.Seed) + noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, seedProvider.Next()) + prevBuildings := settings.NumBuildings + cappedBuildings := capRequestedBuildingsToFit(settings, settings.Width, settings.Height) + if cappedBuildings < prevBuildings { + log.Printf("Building count capped from %d to %d based on image size and average building size.\n", prevBuildings, cappedBuildings) + } + + // Step 2: Generating Lakes (timeout: 1 minute) + var lakeImage image.Image = image.NewRGBA(image.Rect(0, 0, settings.Width, settings.Height)) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + img image.Image + lks [][]image.Point + } { + img, lks := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, seedProvider.Next(), settings.LakeEdgeRoughness, settings.LakeShape) + return struct { + img image.Image + lks [][]image.Point + }{img: img, lks: lks} + }); ok { + lakeImage = out.img + lakes = out.lks + } else { + log.Println("GenerateLakes timed out after 1 minute; continuing.") + lakes = nil + } + + // Step 3: Generating Rivers (timeout: 1 minute) + riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) + var finalImage *image.RGBA = riverBase + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + img image.Image + rmsk *PixelMask + } { + img, rmsk := GenerateRivers(settings.Width, settings.Height, settings.Rivers, settings.MinRiverWidth, settings.MaxRiverWidth, settings.RiverCurvyness, riverBase, lakes, seedProvider.Next(), noiseImg, settings.RiverWidthVariability, settings.RiverEdgeRoughness) + return struct { + img image.Image + rmsk *PixelMask + }{img: img, rmsk: rmsk} + }); ok { + riverMask = out.rmsk + finalImage = cloneToRGBA(out.img, settings.Width, settings.Height) + } else { + log.Println("GenerateRivers timed out after 1 minute; continuing.") + riverMask = NewPixelMask(settings.Width, settings.Height) + } + waterMask = BuildMaskFromLakes(settings.Width, settings.Height, lakes) + if riverMask != nil { + waterMask.Merge(riverMask) + } + // Step 4: Preparing Road Nodes + var roadNodes []*PointOfInterest + var roadTarget int + var edgeToEdgeOnly bool + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + pois []*PointOfInterest + target int + edgeToEdge bool + } { + pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + return struct { + pois []*PointOfInterest + target int + edgeToEdge bool + }{pois: pois, target: target, edgeToEdge: edgeToEdge} + }); ok { + roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge + } else { + log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") + roadNodes = nil + roadTarget = 0 + edgeToEdgeOnly = false + } + + // Step 5: Generating Fortifications + var wallLayout *FortificationLayout + fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + layout *FortificationLayout + } { + layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) + return struct { + layout *FortificationLayout + }{layout: layout} + }); ok { + wallLayout = out.layout + if wallLayout != nil { + wallMask = wallLayout.Mask + } else { + wallMask = NewPixelMask(settings.Width, settings.Height) + } + finalImage = fortBase + } else { + log.Println("GenerateFortifications timed out after 1 minute; continuing.") + wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} + wallMask = wallLayout.Mask + } + + // Step 6: Generating Roads + var roadAnchors []image.Point + var roadList []*Road + roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + rd *PixelMask + br *PixelMask + ex *PixelMask + anc []image.Point + rl []*Road + } { + rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) + return struct { + rd *PixelMask + br *PixelMask + ex *PixelMask + anc []image.Point + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} + }); ok { + roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl + drawWallMask(roadBase, wallMask) + turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) + finalImage = roadBase + } else { + log.Println("GenerateRoads timed out after 1 minute; continuing.") + roadMask = NewPixelMask(settings.Width, settings.Height) + bridgeMask = NewPixelMask(settings.Width, settings.Height) + exitRoadMask = NewPixelMask(settings.Width, settings.Height) + turretMask = NewPixelMask(settings.Width, settings.Height) + roadAnchors = nil + } + + placementMask := cloneMask(roadMask) + if placementMask == nil { + placementMask = NewPixelMask(settings.Width, settings.Height) + } + if wallMask != nil { + placementMask.Merge(wallMask) + } + if turretMask != nil { + placementMask.Merge(turretMask) + } + + // Step 7: Generating Buildings + buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + blds [][]image.Point + bmsk *PixelMask + } { + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) + return struct { + blds [][]image.Point + bmsk *PixelMask + }{blds: blds, bmsk: bmsk} + }); ok { + buildings, buildingMask = out.blds, out.bmsk + finalImage = buildingBase + } else { + log.Println("GenerateBuildings timed out after 1 minute; continuing.") + buildings = nil + buildingMask = NewPixelMask(settings.Width, settings.Height) + } + + // Step 8: Generating Trees + treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + }); ok { + treeMask = out + finalImage = treeBase + } else { + log.Println("GenerateTrees timed out after 1 minute; continuing.") + treeMask = NewPixelMask(settings.Width, settings.Height) + } + + // Step 7: Darkening Water Areas + darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) + + // Step 8: Flattening Building Areas + flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) + + // Step 9: Flattening Road and Wall Areas + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) + + // Step 10: Applying Roughness + compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) + + // Step 11: Generating Bump Map + bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) + + heightmapImg.Image = compositeImg + bumpmapImg.Image = bumpMap + + canvasImg.Image = finalImage + + } + var generateBtn *widget.Button + errorStates := make(map[string]bool) + var applyingPreset bool + var clearPresetSelection func() + markPresetDirty := func() { + if applyingPreset { + return + } + if clearPresetSelection != nil { + clearPresetSelection() + } + } + + // ... (other code) + + // Main generation button and logic + generateBtn = widget.NewButton("Generate", func() { + // ... (generation logic) + }) + + // Function to update the generate button state + updateGenerateBtnState := func() { + for _, hasError := range errorStates { + if hasError { + generateBtn.Disable() + return + } + } + generateBtn.Enable() + } + + detailSlider := newNumericInputSlider(1, 16, settings.Detail, "%.0f", "Detail") + detailSlider.entry.OnChanged = func(s string) { + detailSlider.validate(s, func(hasError bool) { + errorStates["detail"] = hasError + updateGenerateBtnState() + }) + } + detailSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := detailSlider.value.Get() + settings.Detail = val + markPresetDirty() + })) + + roughnessSlider := newNumericInputSlider(0, 100, settings.Roughness, "%.0f%%", "Roughness") + roughnessSlider.entry.OnChanged = func(s string) { + roughnessSlider.validate(s, func(hasError bool) { + errorStates["roughness"] = hasError + updateGenerateBtnState() + }) + } + roughnessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := roughnessSlider.value.Get() + settings.Roughness = val + markPresetDirty() + })) + // Create UI elements for controlling lake generation settings + lakesSlider := newNumericInputSlider(0, 15, float64(settings.Lakes), "%.0f", "Lakes") + lakesSlider.entry.OnChanged = func(s string) { + lakesSlider.validate(s, func(hasError bool) { + errorStates["lakes"] = hasError + updateGenerateBtnState() + }) + } + lakesSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := lakesSlider.value.Get() + settings.Lakes = int(val) + markPresetDirty() + })) + + lakeSizeLowerSlider := newNumericInputSlider(1, 100, settings.LakeSizeLower, "%.0f%%", "Min Lake Size") + lakeSizeLowerSlider.entry.OnChanged = func(s string) { + lakeSizeLowerSlider.validate(s, func(hasError bool) { + errorStates["lakeSizeLower"] = hasError + updateGenerateBtnState() + }) + } + lakeSizeLowerSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := lakeSizeLowerSlider.value.Get() + settings.LakeSizeLower = val + markPresetDirty() + })) + + lakeSizeUpperSlider := newNumericInputSlider(1, 100, settings.LakeSizeUpper, "%.0f%%", "Max Lake Size") + lakeSizeUpperSlider.entry.OnChanged = func(s string) { + lakeSizeUpperSlider.validate(s, func(hasError bool) { + errorStates["lakeSizeUpper"] = hasError + updateGenerateBtnState() + }) + } + lakeSizeUpperSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := lakeSizeUpperSlider.value.Get() + settings.LakeSizeUpper = val + markPresetDirty() + })) + + lakeEdgeRoughnessSlider := newNumericInputSlider(0, 100, settings.LakeEdgeRoughness, "%.0f%%", "Lake Edge Roughness") + lakeEdgeRoughnessSlider.entry.OnChanged = func(s string) { + lakeEdgeRoughnessSlider.validate(s, func(hasError bool) { + errorStates["lakeEdgeRoughness"] = hasError + updateGenerateBtnState() + }) + } + lakeEdgeRoughnessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := lakeEdgeRoughnessSlider.value.Get() + settings.LakeEdgeRoughness = val + markPresetDirty() + })) + + lakeShapeLabel := widget.NewLabel("Lake Shape:") + lakeShapeSelect := widget.NewSelect([]string{"circle", "oval", "procedural"}, func(s string) { + settings.LakeShape = s + markPresetDirty() + }) + lakeShapeSelect.SetSelected(settings.LakeShape) + + riversSlider := newNumericInputSlider(0, 5, float64(settings.Rivers), "%.0f", "Rivers") + riversSlider.entry.OnChanged = func(s string) { + riversSlider.validate(s, func(hasError bool) { + errorStates["rivers"] = hasError + updateGenerateBtnState() + }) + } + riversSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := riversSlider.value.Get() + settings.Rivers = int(val) + markPresetDirty() + })) + + minRiverWidthSlider := newNumericInputSlider(1, 100, settings.MinRiverWidth, "%.0f%%", "Min River Width") + minRiverWidthSlider.entry.OnChanged = func(s string) { + minRiverWidthSlider.validate(s, func(hasError bool) { + errorStates["minRiverWidth"] = hasError + updateGenerateBtnState() + }) + } + minRiverWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minRiverWidthSlider.value.Get() + settings.MinRiverWidth = val + markPresetDirty() + })) + + maxRiverWidthSlider := newNumericInputSlider(1, 100, settings.MaxRiverWidth, "%.0f%%", "Max River Width") + maxRiverWidthSlider.entry.OnChanged = func(s string) { + maxRiverWidthSlider.validate(s, func(hasError bool) { + errorStates["maxRiverWidth"] = hasError + updateGenerateBtnState() + }) + } + maxRiverWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxRiverWidthSlider.value.Get() + settings.MaxRiverWidth = val + markPresetDirty() + })) + + riverCurvynessSlider := newNumericInputSlider(0, 100, settings.RiverCurvyness, "%.0f%%", "River Curvyness") + riverCurvynessSlider.entry.OnChanged = func(s string) { + riverCurvynessSlider.validate(s, func(hasError bool) { + errorStates["riverCurvyness"] = hasError + updateGenerateBtnState() + }) + } + riverCurvynessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := riverCurvynessSlider.value.Get() + settings.RiverCurvyness = val + markPresetDirty() + })) + + riverWidthVariabilitySlider := newNumericInputSlider(0, 100, settings.RiverWidthVariability, "%.0f%%", "River Width Variability") + riverWidthVariabilitySlider.entry.OnChanged = func(s string) { + riverWidthVariabilitySlider.validate(s, func(hasError bool) { + errorStates["riverWidthVariability"] = hasError + updateGenerateBtnState() + }) + } + riverWidthVariabilitySlider.value.AddListener(binding.NewDataListener(func() { + val, _ := riverWidthVariabilitySlider.value.Get() + settings.RiverWidthVariability = val + markPresetDirty() + })) + + riverEdgeRoughnessSlider := newNumericInputSlider(0, 100, settings.RiverEdgeRoughness, "%.0f%%", "River Edge Roughness") + riverEdgeRoughnessSlider.entry.OnChanged = func(s string) { + riverEdgeRoughnessSlider.validate(s, func(hasError bool) { + errorStates["riverEdgeRoughness"] = hasError + updateGenerateBtnState() + }) + } + riverEdgeRoughnessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := riverEdgeRoughnessSlider.value.Get() + settings.RiverEdgeRoughness = val + markPresetDirty() + })) + + minTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MinTreeSize, treeSizePercentStep, "%.1f%%", "Min Tree Size") + minTreeSizeSlider.entry.OnChanged = func(s string) { + minTreeSizeSlider.validate(s, func(hasError bool) { + errorStates["minTreeSize"] = hasError + updateGenerateBtnState() + }) + } + minTreeSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minTreeSizeSlider.value.Get() + settings.MinTreeSize = val + markPresetDirty() + })) + + maxTreeSizeSlider := newNumericInputSliderWithStep(minTreeSizePercent, maxTreeSizePercent, settings.MaxTreeSize, treeSizePercentStep, "%.1f%%", "Max Tree Size") + maxTreeSizeSlider.entry.OnChanged = func(s string) { + maxTreeSizeSlider.validate(s, func(hasError bool) { + errorStates["maxTreeSize"] = hasError + updateGenerateBtnState() + }) + } + maxTreeSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxTreeSizeSlider.value.Get() + settings.MaxTreeSize = val + markPresetDirty() + })) + + treeCoverageSlider := newNumericInputSlider(1, 100, settings.TreeCoverage, "%.0f%%", "Tree Coverage") + treeCoverageSlider.entry.OnChanged = func(s string) { + treeCoverageSlider.validate(s, func(hasError bool) { + errorStates["treeCoverage"] = hasError + updateGenerateBtnState() + }) + } + treeCoverageSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := treeCoverageSlider.value.Get() + settings.TreeCoverage = val + markPresetDirty() + })) + + treeClumpinessSlider := newNumericInputSlider(0, 100, settings.TreeClumpiness, "%.0f%%", "Tree Clumpiness") + treeClumpinessSlider.entry.OnChanged = func(s string) { + treeClumpinessSlider.validate(s, func(hasError bool) { + errorStates["treeClumpiness"] = hasError + updateGenerateBtnState() + }) + } + treeClumpinessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := treeClumpinessSlider.value.Get() + settings.TreeClumpiness = val + markPresetDirty() + })) + minRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MinRoadWidth, roadWidthPercentStep, "%.1f%%", "Min Road Width") + minRoadWidthSlider.entry.OnChanged = func(s string) { + minRoadWidthSlider.validate(s, func(hasError bool) { + errorStates["minRoadWidth"] = hasError + updateGenerateBtnState() + }) + } + minRoadWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minRoadWidthSlider.value.Get() + settings.MinRoadWidth = val + markPresetDirty() + })) + + maxRoadWidthSlider := newNumericInputSliderWithStep(minRoadWidthPercent, maxRoadWidthPercent, settings.MaxRoadWidth, roadWidthPercentStep, "%.1f%%", "Max Road Width") + maxRoadWidthSlider.entry.OnChanged = func(s string) { + maxRoadWidthSlider.validate(s, func(hasError bool) { + errorStates["maxRoadWidth"] = hasError + updateGenerateBtnState() + }) + } + maxRoadWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxRoadWidthSlider.value.Get() + settings.MaxRoadWidth = val + markPresetDirty() + })) + + buildingsPerRoadSlider := newNumericInputSlider(1, 20, float64(settings.BuildingsPerRoad), "%.0f", "Buildings Per Road") + buildingsPerRoadSlider.entry.OnChanged = func(s string) { + buildingsPerRoadSlider.validate(s, func(hasError bool) { + errorStates["buildingsPerRoad"] = hasError + updateGenerateBtnState() + }) + } + buildingsPerRoadSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := buildingsPerRoadSlider.value.Get() + settings.BuildingsPerRoad = int(val) + markPresetDirty() + })) + + roadExitsSlider := newNumericInputSlider(0, 100, float64(settings.RoadExits), "%.0f", "Road Exits") + roadExitsSlider.entry.OnChanged = func(s string) { + roadExitsSlider.validate(s, func(hasError bool) { + errorStates["roadExits"] = hasError + updateGenerateBtnState() + }) + } + roadExitsSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := roadExitsSlider.value.Get() + settings.RoadExits = int(val) + markPresetDirty() + })) + + roadCurvynessSlider := newNumericInputSlider(0, 100, settings.RoadCurvyness, "%.0f%%", "Road Curvyness") + roadCurvynessSlider.entry.OnChanged = func(s string) { + roadCurvynessSlider.validate(s, func(hasError bool) { + errorStates["roadCurvyness"] = hasError + updateGenerateBtnState() + }) + } + roadCurvynessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := roadCurvynessSlider.value.Get() + settings.RoadCurvyness = val + markPresetDirty() + })) + + roadDistributionSlider := newNumericInputSlider(0, 100, settings.RoadDistribution, "%.0f%%", "Distribution") + roadDistributionSlider.entry.OnChanged = func(s string) { + roadDistributionSlider.validate(s, func(hasError bool) { + errorStates["roadDistribution"] = hasError + updateGenerateBtnState() + }) + } + roadDistributionSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := roadDistributionSlider.value.Get() + settings.RoadDistribution = val + markPresetDirty() + })) + + minRoadAngleSlider := newNumericInputSlider(0, 180, settings.MinRoadAngle, "%.0f°", "Minimum Road Angle") + minRoadAngleSlider.entry.OnChanged = func(s string) { + minRoadAngleSlider.validate(s, func(hasError bool) { + errorStates["minRoadAngle"] = hasError + updateGenerateBtnState() + }) + } + minRoadAngleSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minRoadAngleSlider.value.Get() + settings.MinRoadAngle = val + markPresetDirty() + })) + + minWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MinWallWidth, wallWidthPercentStep, "%.1f%%", "Min Wall Width") + minWallWidthSlider.entry.OnChanged = func(s string) { + minWallWidthSlider.validate(s, func(hasError bool) { + errorStates["minWallWidth"] = hasError + updateGenerateBtnState() + }) + } + minWallWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minWallWidthSlider.value.Get() + settings.MinWallWidth = val + markPresetDirty() + })) + + maxWallWidthSlider := newNumericInputSliderWithStep(minWallWidthPercent, maxWallWidthPercent, settings.MaxWallWidth, wallWidthPercentStep, "%.1f%%", "Max Wall Width") + maxWallWidthSlider.entry.OnChanged = func(s string) { + maxWallWidthSlider.validate(s, func(hasError bool) { + errorStates["maxWallWidth"] = hasError + updateGenerateBtnState() + }) + } + maxWallWidthSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxWallWidthSlider.value.Get() + settings.MaxWallWidth = val + markPresetDirty() + })) + + numWallsSlider := newNumericInputSlider(0, 5, float64(settings.NumWalls), "%.0f", "Number of Walls") + numWallsSlider.entry.OnChanged = func(s string) { + numWallsSlider.validate(s, func(hasError bool) { + errorStates["numWalls"] = hasError + updateGenerateBtnState() + }) + } + numWallsSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := numWallsSlider.value.Get() + settings.NumWalls = int(val) + markPresetDirty() + })) + + cityCoverageSlider := newNumericInputSlider(1, 100, settings.CityCoverage, "%.0f%%", "City Coverage") + cityCoverageSlider.entry.OnChanged = func(s string) { + cityCoverageSlider.validate(s, func(hasError bool) { + errorStates["cityCoverage"] = hasError + updateGenerateBtnState() + }) + } + cityCoverageSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := cityCoverageSlider.value.Get() + settings.CityCoverage = val + markPresetDirty() + })) + + wallCurvynessSlider := newNumericInputSlider(0, 100, settings.WallCurvyness, "%.0f%%", "Wall Curvyness") + wallCurvynessSlider.entry.OnChanged = func(s string) { + wallCurvynessSlider.validate(s, func(hasError bool) { + errorStates["wallCurvyness"] = hasError + updateGenerateBtnState() + }) + } + wallCurvynessSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := wallCurvynessSlider.value.Get() + settings.WallCurvyness = val + markPresetDirty() + })) + + showTurretsCheck := widget.NewCheck("Show Turrets", func(v bool) { + settings.ShowTurrets = v + markPresetDirty() + }) + showTurretsCheck.SetChecked(settings.ShowTurrets) + + turretSizeSlider := newNumericInputSliderWithStep(minTurretSizePercent, maxTurretSizePercent, settings.TurretSize, turretSizePercentStep, "%.1f%%", "Turret Size") + turretSizeSlider.entry.OnChanged = func(s string) { + turretSizeSlider.validate(s, func(hasError bool) { + errorStates["turretSize"] = hasError + updateGenerateBtnState() + }) + } + turretSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := turretSizeSlider.value.Get() + settings.TurretSize = val + markPresetDirty() + })) + + turretShapeLabel := widget.NewLabel("Turret Shape:") + turretShapeSelect := widget.NewSelect([]string{"circular", "square"}, func(s string) { + settings.TurretShape = s + markPresetDirty() + }) + turretShapeSelect.SetSelected(settings.TurretShape) + + turretSpacingSlider := newNumericInputSlider(0, 100, settings.TurretSpacing, "%.0f%%", "Turret Spacing") + turretSpacingSlider.entry.OnChanged = func(s string) { + turretSpacingSlider.validate(s, func(hasError bool) { + errorStates["turretSpacing"] = hasError + updateGenerateBtnState() + }) + } + turretSpacingSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := turretSpacingSlider.value.Get() + settings.TurretSpacing = val + markPresetDirty() + })) + + gateSpacingSlider := newNumericInputSlider(0, 100, settings.GateSpacing, "%.0f%%", "Gate Spacing") + gateSpacingSlider.entry.OnChanged = func(s string) { + gateSpacingSlider.validate(s, func(hasError bool) { + errorStates["gateSpacing"] = hasError + updateGenerateBtnState() + }) + } + gateSpacingSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := gateSpacingSlider.value.Get() + settings.GateSpacing = val + markPresetDirty() + })) + + turretControls := container.NewVBox( + turretSizeSlider, + turretShapeLabel, + turretShapeSelect, + turretSpacingSlider, + ) + if !settings.ShowTurrets { + turretControls.Hide() + } + showTurretsCheck.OnChanged = func(v bool) { + settings.ShowTurrets = v + if v { + turretControls.Show() + } else { + turretControls.Hide() + } + markPresetDirty() + } + + // Create UI elements for error display and action buttons + var applySettingsToUI func(*Settings) + errorLabel := widget.NewLabel("") + errorLabel.Wrapping = fyne.TextWrapWord + errorLabel.Hide() + progressLabel := widget.NewLabel("") + progressBar := widget.NewProgressBar() + progressContainer := container.NewVBox(progressLabel, progressBar) + progressContainer.Hide() + exportCanvasBtn := widget.NewButton("Export Canvas", func() { + showSaveDialog(w, canvasImg.Image, settings) + }) + exportHeightmapBtn := widget.NewButton("Export Heightmap", func() { + showSaveDialog(w, heightmapImg.Image, settings) + }) + exportBumpmapBtn := widget.NewButton("Export Bump Map", func() { + showSaveDialog(w, bumpmapImg.Image, settings) + }) + exportMasksBtn := widget.NewButton("Export Masks", func() { + showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask) + }) + exportSettingsBtn := widget.NewButton("Export Settings", func() { + showSettingsExportDialog(w, settings) + }) + importSettingsBtn := widget.NewButton("Import Settings", func() { + showSettingsImportDialog(w, settings, applySettingsToUI) + }) + // Main generation button and logic + generateBtn = widget.NewButton("Generate", func() { + go func() { + var timedOutSteps []string + addTimeout := func(step string) { + timedOutSteps = append(timedOutSteps, step) + } + // Disable button and show progress bar during generation + fyne.Do(func() { + generateBtn.Disable() + progressContainer.Show() + }) + defer func() { + // Re-enable button and hide progress bar after generation + fyne.Do(func() { + generateBtn.Enable() + progressContainer.Hide() + }) + }() + seedProvider := NewSeedProvider(settings.Seed) + prevBuildings := settings.NumBuildings + cappedBuildings := capRequestedBuildingsToFit(settings, settings.Width, settings.Height) + if cappedBuildings < prevBuildings { + log.Printf("Building count capped from %d to %d based on image size and average building size.\n", prevBuildings, cappedBuildings) + } + // Step 1: Generating Heightmap + fyne.Do(func() { + progressLabel.SetText("Step 1 of 13: Generating Heightmap") + progressBar.SetValue(1.0 / 13.0) + }) + noiseImg := GenerateHeightmap(settings.Width, settings.Height, int(settings.Detail), 100.0, seedProvider.Next()) + + // Step 2: Generating Lakes + + fyne.Do(func() { + progressLabel.SetText("Step 2 of 13: Generating Lakes") + progressBar.SetValue(2.0 / 13.0) + }) + var lakeImage image.Image = image.NewRGBA(image.Rect(0, 0, settings.Width, settings.Height)) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + img image.Image + lks [][]image.Point + } { + img, lks := GenerateLakes(settings.Width, settings.Height, settings.Lakes, settings.LakeSizeLower, settings.LakeSizeUpper, seedProvider.Next(), settings.LakeEdgeRoughness, settings.LakeShape) + return struct { + img image.Image + lks [][]image.Point + }{img: img, lks: lks} + }); ok { + lakeImage = out.img + lakes = out.lks + } else { + log.Println("GenerateLakes timed out after 1 minute; continuing.") + addTimeout("Lakes") + lakes = nil + } + + // Step 3: Generating Rivers + + fyne.Do(func() { + progressLabel.SetText("Step 3 of 13: Generating Rivers") + progressBar.SetValue(3.0 / 13.0) + }) + riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) + finalImage := riverBase + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + img image.Image + rmsk *PixelMask + } { + img, rmsk := GenerateRivers(settings.Width, settings.Height, settings.Rivers, settings.MinRiverWidth, settings.MaxRiverWidth, settings.RiverCurvyness, riverBase, lakes, seedProvider.Next(), noiseImg, settings.RiverWidthVariability, settings.RiverEdgeRoughness) + return struct { + img image.Image + rmsk *PixelMask + }{img: img, rmsk: rmsk} + }); ok { + riverMask = out.rmsk + finalImage = cloneToRGBA(out.img, settings.Width, settings.Height) + } else { + log.Println("GenerateRivers timed out after 1 minute; continuing.") + addTimeout("Rivers") + riverMask = NewPixelMask(settings.Width, settings.Height) + } + waterMask = BuildMaskFromLakes(settings.Width, settings.Height, lakes) + if riverMask != nil { + waterMask.Merge(riverMask) + } + + // Step 4: Preparing Road Nodes + + fyne.Do(func() { + progressLabel.SetText("Step 4 of 13: Preparing Road Nodes") + progressBar.SetValue(4.0 / 13.0) + }) + var roadNodes []*PointOfInterest + var roadTarget int + var edgeToEdgeOnly bool + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + pois []*PointOfInterest + target int + edgeToEdge bool + } { + pois, target, edgeToEdge := PrepareRoadNodes(settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + return struct { + pois []*PointOfInterest + target int + edgeToEdge bool + }{pois: pois, target: target, edgeToEdge: edgeToEdge} + }); ok { + roadNodes, roadTarget, edgeToEdgeOnly = out.pois, out.target, out.edgeToEdge + } else { + log.Println("PrepareRoadNodes timed out after 1 minute; continuing.") + addTimeout("Road Nodes") + roadNodes = nil + roadTarget = 0 + edgeToEdgeOnly = false + } + + // Step 5: Generating Fortifications + + fyne.Do(func() { + progressLabel.SetText("Step 5 of 13: Generating Fortifications") + progressBar.SetValue(5.0 / 13.0) + }) + var wallLayout *FortificationLayout + fortBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + layout *FortificationLayout + } { + layout, _ := GenerateFortifications(fortBase, settings.Width, settings.Height, settings, waterMask, roadNodes, seedProvider.Next()) + return struct { + layout *FortificationLayout + }{layout: layout} + }); ok { + wallLayout = out.layout + if wallLayout != nil { + wallMask = wallLayout.Mask + } else { + wallMask = NewPixelMask(settings.Width, settings.Height) + } + finalImage = fortBase + } else { + log.Println("GenerateFortifications timed out after 1 minute; continuing.") + addTimeout("Fortifications") + wallLayout = &FortificationLayout{Mask: NewPixelMask(settings.Width, settings.Height)} + wallMask = wallLayout.Mask + } + + // Step 6: Generating Roads + + fyne.Do(func() { + progressLabel.SetText("Step 6 of 13: Generating Roads") + progressBar.SetValue(6.0 / 13.0) + }) + var roadAnchors []image.Point + var roadList []*Road + roadBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + rd *PixelMask + br *PixelMask + ex *PixelMask + anc []image.Point + rl []*Road + } { + rd, br, ex, anc, rl := GenerateRoadsWithPOIs(roadBase, settings.Width, settings.Height, settings, waterMask, wallLayout, roadNodes, roadTarget, edgeToEdgeOnly, seedProvider.Next()) + return struct { + rd *PixelMask + br *PixelMask + ex *PixelMask + anc []image.Point + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} + }); ok { + roadMask, bridgeMask, exitRoadMask, roadAnchors, roadList = out.rd, out.br, out.ex, out.anc, out.rl + drawWallMask(roadBase, wallMask) + turretMask = GenerateTurrets(roadBase, settings.Width, settings.Height, settings, wallLayout, waterMask, roadMask, roadList) + finalImage = roadBase + } else { + log.Println("GenerateRoads timed out after 1 minute; continuing.") + addTimeout("Roads") + roadMask = NewPixelMask(settings.Width, settings.Height) + bridgeMask = NewPixelMask(settings.Width, settings.Height) + exitRoadMask = NewPixelMask(settings.Width, settings.Height) + turretMask = NewPixelMask(settings.Width, settings.Height) + roadAnchors = nil + } + placementMask := cloneMask(roadMask) + if placementMask == nil { + placementMask = NewPixelMask(settings.Width, settings.Height) + } + if wallMask != nil { + placementMask.Merge(wallMask) + } + if turretMask != nil { + placementMask.Merge(turretMask) + } + + // Step 7: Generating Buildings + + fyne.Do(func() { + progressLabel.SetText("Step 7 of 13: Generating Buildings") + progressBar.SetValue(7.0 / 13.0) + }) + buildingBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() struct { + blds [][]image.Point + bmsk *PixelMask + } { + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) + return struct { + blds [][]image.Point + bmsk *PixelMask + }{blds: blds, bmsk: bmsk} + }); ok { + buildings, buildingMask = out.blds, out.bmsk + finalImage = buildingBase + } else { + log.Println("GenerateBuildings timed out after 1 minute; continuing.") + addTimeout("Buildings") + buildings = nil + buildingMask = NewPixelMask(settings.Width, settings.Height) + } + + // Step 8: Darkening Water Areas + + fyne.Do(func() { + progressLabel.SetText("Step 8 of 13: Darkening Water Areas") + progressBar.SetValue(8.0 / 13.0) + }) + darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) + + // Step 9: Flattening Building Areas + + fyne.Do(func() { + progressLabel.SetText("Step 9 of 13: Flattening Building Areas") + progressBar.SetValue(9.0 / 13.0) + }) + flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) + + fyne.Do(func() { + progressLabel.SetText("Step 10 of 13: Flattening Road and Wall Areas") + progressBar.SetValue(10.0 / 13.0) + }) + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) + + // Step 11: Applying Roughness + + fyne.Do(func() { + progressLabel.SetText("Step 11 of 13: Applying Roughness") + progressBar.SetValue(11.0 / 13.0) + }) + compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) + + // Step 12: Generating Trees + + fyne.Do(func() { + progressLabel.SetText("Step 12 of 13: Generating Trees") + progressBar.SetValue(12.0 / 13.0) + }) + treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) + if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + }); ok { + treeMask = out + finalImage = treeBase + } else { + log.Println("GenerateTrees timed out after 1 minute; continuing.") + addTimeout("Trees") + treeMask = NewPixelMask(settings.Width, settings.Height) + } + + // Step 13: Generating Bump Map + + fyne.Do(func() { + progressLabel.SetText("Step 13 of 13: Generating Bump Map") + progressBar.SetValue(1.0) + }) + bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) + + // Step 11: Finalizing Images + + fyne.Do(func() { + heightmapImg.Image = compositeImg + heightmapImg.Refresh() + bumpmapImg.Image = bumpMap + bumpmapImg.Refresh() + canvasImg.Image = finalImage + canvasImg.Refresh() + if len(timedOutSteps) > 0 { + errorLabel.SetText("Generation timed out after 1 minute for: " + strings.Join(timedOutSteps, ", ") + ". Partial results were used.") + errorLabel.Show() + } else { + errorLabel.Hide() + } + }) + }() + }) + // Create UI elements for image dimensions and seed + 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)) + }) + // Create tabs for organizing settings + terrainTab := container.NewTabItem("Terrain", container.NewVBox( + detailSlider, + roughnessSlider, + + widget.NewLabel(""), // Spacer + + minTreeSizeSlider, + maxTreeSizeSlider, + treeCoverageSlider, + treeClumpinessSlider, + )) + + waterTab := container.NewTabItem("Water", container.NewVBox( + lakesSlider, + lakeSizeLowerSlider, + lakeSizeUpperSlider, + lakeEdgeRoughnessSlider, + lakeShapeLabel, + lakeShapeSelect, + + widget.NewLabel(""), // Spacer + + riversSlider, + minRiverWidthSlider, + maxRiverWidthSlider, + riverCurvynessSlider, + riverWidthVariabilitySlider, + riverEdgeRoughnessSlider, + )) + + roadsTab := container.NewTabItem("Roads", container.NewVBox( + minRoadWidthSlider, + maxRoadWidthSlider, + buildingsPerRoadSlider, + roadExitsSlider, + minRoadAngleSlider, + roadCurvynessSlider, + roadDistributionSlider, + )) + + fortificationsTab := container.NewTabItem("Fortifications", container.NewVBox( + minWallWidthSlider, + maxWallWidthSlider, + numWallsSlider, + cityCoverageSlider, + wallCurvynessSlider, + gateSpacingSlider, + showTurretsCheck, + turretControls, + )) + numBuildingsSlider := newNumericInputSlider(0, 10000, float64(settings.NumBuildings), "%.0f", "Number of Buildings") + numBuildingsSlider.entry.OnChanged = func(s string) { + numBuildingsSlider.validate(s, func(hasError bool) { + errorStates["numBuildings"] = hasError + updateGenerateBtnState() + }) + } + numBuildingsSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := numBuildingsSlider.value.Get() + settings.NumBuildings = int(val) + markPresetDirty() + })) + + minBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MinBuildingSize, buildingSizePercentStep, "%.1f%%", "Min Building Size") + minBuildingSizeSlider.entry.OnChanged = func(s string) { + minBuildingSizeSlider.validate(s, func(hasError bool) { + errorStates["minBuildingSize"] = hasError + updateGenerateBtnState() + }) + } + minBuildingSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minBuildingSizeSlider.value.Get() + settings.MinBuildingSize = val + markPresetDirty() + })) + + maxBuildingSizeSlider := newNumericInputSliderWithStep(minBuildingSizePercent, maxBuildingSizePercent, settings.MaxBuildingSize, buildingSizePercentStep, "%.1f%%", "Max Building Size") + maxBuildingSizeSlider.entry.OnChanged = func(s string) { + maxBuildingSizeSlider.validate(s, func(hasError bool) { + errorStates["maxBuildingSize"] = hasError + updateGenerateBtnState() + }) + } + maxBuildingSizeSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxBuildingSizeSlider.value.Get() + settings.MaxBuildingSize = val + markPresetDirty() + })) + + buildingDistributionSlider := newNumericInputSlider(0, 100, settings.BuildingDistribution, "%.0f%%", "Building Distribution") + buildingDistributionSlider.entry.OnChanged = func(s string) { + buildingDistributionSlider.validate(s, func(hasError bool) { + errorStates["buildingDistribution"] = hasError + updateGenerateBtnState() + }) + } + buildingDistributionSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := buildingDistributionSlider.value.Get() + settings.BuildingDistribution = val + markPresetDirty() + })) + + buildingShapeLabel := widget.NewLabel("Building Shape:") + buildingShapeSelect := widget.NewSelect([]string{"squares", "circles", "rectangles", "mixed", "procedural"}, func(s string) { + settings.BuildingShape = s + markPresetDirty() + }) + buildingShapeSelect.SetSelected(settings.BuildingShape) + + // Create sliders for building shape ratios (visible only when "mixed" is selected) + squareRatioSlider := widget.NewSlider(0, 100) + circleRatioSlider := widget.NewSlider(0, 100) + rectangleRatioSlider := widget.NewSlider(0, 100) + + squareRatioLabel := widget.NewLabel(fmt.Sprintf("Squares: %.0f%%", settings.BuildingShapeRatios["squares"])) + circleRatioLabel := widget.NewLabel(fmt.Sprintf("Circles: %.0f%%", settings.BuildingShapeRatios["circles"])) + rectangleRatioLabel := widget.NewLabel(fmt.Sprintf("Rectangles: %.0f%%", settings.BuildingShapeRatios["rectangles"])) + + squareRatioSlider.SetValue(settings.BuildingShapeRatios["squares"]) + circleRatioSlider.SetValue(settings.BuildingShapeRatios["circles"]) + rectangleRatioSlider.SetValue(settings.BuildingShapeRatios["rectangles"]) + + ratioContainer := container.NewVBox( + squareRatioLabel, + squareRatioSlider, + circleRatioLabel, + circleRatioSlider, + rectangleRatioLabel, + rectangleRatioSlider, + ) + + minBuildingComplexitySlider := newNumericInputSlider(1, 6, float64(settings.MinBuildingComplexity), "%.0f", "Min Building Complexity") + minBuildingComplexitySlider.entry.OnChanged = func(s string) { + minBuildingComplexitySlider.validate(s, func(hasError bool) { + errorStates["minBuildingComplexity"] = hasError + updateGenerateBtnState() + }) + } + minBuildingComplexitySlider.value.AddListener(binding.NewDataListener(func() { + val, _ := minBuildingComplexitySlider.value.Get() + settings.MinBuildingComplexity = int(val) + markPresetDirty() + })) + + maxBuildingComplexitySlider := newNumericInputSlider(1, 6, float64(settings.MaxBuildingComplexity), "%.0f", "Max Building Complexity") + maxBuildingComplexitySlider.entry.OnChanged = func(s string) { + maxBuildingComplexitySlider.validate(s, func(hasError bool) { + errorStates["maxBuildingComplexity"] = hasError + updateGenerateBtnState() + }) + } + maxBuildingComplexitySlider.value.AddListener(binding.NewDataListener(func() { + val, _ := maxBuildingComplexitySlider.value.Get() + settings.MaxBuildingComplexity = int(val) + markPresetDirty() + })) + + buildingComplexityRatioSlider := newNumericInputSlider(0, 100, settings.BuildingComplexityRatio, "%.0f%%", "Building Complexity Ratio") + buildingComplexityRatioSlider.entry.OnChanged = func(s string) { + buildingComplexityRatioSlider.validate(s, func(hasError bool) { + errorStates["buildingComplexityRatio"] = hasError + updateGenerateBtnState() + }) + } + buildingComplexityRatioSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := buildingComplexityRatioSlider.value.Get() + settings.BuildingComplexityRatio = val + markPresetDirty() + })) + + proceduralContainer := container.NewVBox( + minBuildingComplexitySlider, + maxBuildingComplexitySlider, + buildingComplexityRatioSlider, + ) + + updateRatioSliders := func() { + squareRatioSlider.SetValue(settings.BuildingShapeRatios["squares"]) + circleRatioSlider.SetValue(settings.BuildingShapeRatios["circles"]) + rectangleRatioSlider.SetValue(settings.BuildingShapeRatios["rectangles"]) + squareRatioLabel.SetText(fmt.Sprintf("Squares: %.0f%%", settings.BuildingShapeRatios["squares"])) + circleRatioLabel.SetText(fmt.Sprintf("Circles: %.0f%%", settings.BuildingShapeRatios["circles"])) + rectangleRatioLabel.SetText(fmt.Sprintf("Rectangles: %.0f%%", settings.BuildingShapeRatios["rectangles"])) + } + + squareRatioSlider.OnChanged = func(val float64) { + if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { + return + } + oldVal := settings.BuildingShapeRatios["squares"] + diff := val - oldVal + settings.BuildingShapeRatios["squares"] = val + settings.BuildingShapeRatios["circles"] -= diff / 2 + settings.BuildingShapeRatios["rectangles"] -= diff / 2 + + // Clamp values to 0-100 range + if settings.BuildingShapeRatios["circles"] < 0 { + settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["circles"] + settings.BuildingShapeRatios["circles"] = 0 + } + if settings.BuildingShapeRatios["rectangles"] < 0 { + settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["rectangles"] + settings.BuildingShapeRatios["rectangles"] = 0 + } + if settings.BuildingShapeRatios["circles"] > 100 { + settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["circles"] - 100 + settings.BuildingShapeRatios["circles"] = 100 + } + if settings.BuildingShapeRatios["rectangles"] > 100 { + settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["rectangles"] - 100 + settings.BuildingShapeRatios["rectangles"] = 100 + } + + updateRatioSliders() + markPresetDirty() + } + + circleRatioSlider.OnChanged = func(val float64) { + if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { + return + } + oldVal := settings.BuildingShapeRatios["circles"] + diff := val - oldVal + settings.BuildingShapeRatios["circles"] = val + settings.BuildingShapeRatios["squares"] -= diff / 2 + settings.BuildingShapeRatios["rectangles"] -= diff / 2 + // Clamp values to 0-100 range + if settings.BuildingShapeRatios["squares"] < 0 { + settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["squares"] + settings.BuildingShapeRatios["squares"] = 0 + } + if settings.BuildingShapeRatios["rectangles"] < 0 { + settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["rectangles"] + settings.BuildingShapeRatios["rectangles"] = 0 + } + if settings.BuildingShapeRatios["squares"] > 100 { + settings.BuildingShapeRatios["rectangles"] += settings.BuildingShapeRatios["squares"] - 100 + settings.BuildingShapeRatios["squares"] = 100 + } + if settings.BuildingShapeRatios["rectangles"] > 100 { + settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["rectangles"] - 100 + settings.BuildingShapeRatios["rectangles"] = 100 + } + + updateRatioSliders() + markPresetDirty() + } + + rectangleRatioSlider.OnChanged = func(val float64) { + if settings.BuildingShape != "mixed" && settings.BuildingShape != "procedural" { + return + } + oldVal := settings.BuildingShapeRatios["rectangles"] + diff := val - oldVal + settings.BuildingShapeRatios["rectangles"] = val + settings.BuildingShapeRatios["squares"] -= diff / 2 + settings.BuildingShapeRatios["circles"] -= diff / 2 + + // Clamp values to 0-100 range + if settings.BuildingShapeRatios["squares"] < 0 { + settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["squares"] + settings.BuildingShapeRatios["squares"] = 0 + } + if settings.BuildingShapeRatios["circles"] < 0 { + settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["circles"] + settings.BuildingShapeRatios["circles"] = 0 + } + if settings.BuildingShapeRatios["squares"] > 100 { + settings.BuildingShapeRatios["circles"] += settings.BuildingShapeRatios["squares"] - 100 + settings.BuildingShapeRatios["squares"] = 100 + } + if settings.BuildingShapeRatios["circles"] > 100 { + settings.BuildingShapeRatios["squares"] += settings.BuildingShapeRatios["circles"] - 100 + settings.BuildingShapeRatios["circles"] = 100 + } + + updateRatioSliders() + markPresetDirty() + } + + buildingShapeSelect.OnChanged = func(s string) { + settings.BuildingShape = s + if s == "mixed" { + proceduralContainer.Hide() + ratioContainer.Show() + } else if s == "procedural" { + proceduralContainer.Show() + ratioContainer.Show() + } else { + proceduralContainer.Hide() + ratioContainer.Hide() + } + markPresetDirty() + } + // Initial visibility check + if settings.BuildingShape == "mixed" { + proceduralContainer.Hide() + ratioContainer.Show() + } else if settings.BuildingShape == "procedural" { + proceduralContainer.Show() + ratioContainer.Show() + } else { + proceduralContainer.Hide() + ratioContainer.Hide() + } + + applySettingsToUI = func(imported *Settings) { + lastExportPath := settings.LastExportPath + imageViewState := settings.ImageViewState + + *settings = *imported + settings.LastExportPath = lastExportPath + settings.ImageViewState = imageViewState + if settings.BuildingShapeRatios == nil { + settings.BuildingShapeRatios = map[string]float64{ + "squares": 40, + "circles": 30, + "rectangles": 30, + } + } + + widthEntry.SetText(strconv.Itoa(settings.Width)) + heightEntry.SetText(strconv.Itoa(settings.Height)) + seedEntry.SetText(strconv.FormatInt(settings.Seed, 10)) + + detailSlider.value.Set(settings.Detail) + roughnessSlider.value.Set(settings.Roughness) + + lakesSlider.value.Set(float64(settings.Lakes)) + lakeSizeLowerSlider.value.Set(settings.LakeSizeLower) + lakeSizeUpperSlider.value.Set(settings.LakeSizeUpper) + lakeEdgeRoughnessSlider.value.Set(settings.LakeEdgeRoughness) + lakeShapeSelect.SetSelected(settings.LakeShape) + + riversSlider.value.Set(float64(settings.Rivers)) + minRiverWidthSlider.value.Set(settings.MinRiverWidth) + maxRiverWidthSlider.value.Set(settings.MaxRiverWidth) + riverCurvynessSlider.value.Set(settings.RiverCurvyness) + riverWidthVariabilitySlider.value.Set(settings.RiverWidthVariability) + riverEdgeRoughnessSlider.value.Set(settings.RiverEdgeRoughness) + + minTreeSizeSlider.value.Set(settings.MinTreeSize) + maxTreeSizeSlider.value.Set(settings.MaxTreeSize) + treeCoverageSlider.value.Set(settings.TreeCoverage) + treeClumpinessSlider.value.Set(settings.TreeClumpiness) + + minRoadWidthSlider.value.Set(settings.MinRoadWidth) + maxRoadWidthSlider.value.Set(settings.MaxRoadWidth) + buildingsPerRoadSlider.value.Set(float64(settings.BuildingsPerRoad)) + roadExitsSlider.value.Set(float64(settings.RoadExits)) + roadCurvynessSlider.value.Set(settings.RoadCurvyness) + roadDistributionSlider.value.Set(settings.RoadDistribution) + minRoadAngleSlider.value.Set(settings.MinRoadAngle) + + minWallWidthSlider.value.Set(settings.MinWallWidth) + maxWallWidthSlider.value.Set(settings.MaxWallWidth) + numWallsSlider.value.Set(float64(settings.NumWalls)) + cityCoverageSlider.value.Set(settings.CityCoverage) + wallCurvynessSlider.value.Set(settings.WallCurvyness) + gateSpacingSlider.value.Set(settings.GateSpacing) + showTurretsCheck.SetChecked(settings.ShowTurrets) + turretSizeSlider.value.Set(settings.TurretSize) + turretShapeSelect.SetSelected(settings.TurretShape) + turretSpacingSlider.value.Set(settings.TurretSpacing) + + numBuildingsSlider.value.Set(float64(settings.NumBuildings)) + minBuildingSizeSlider.value.Set(settings.MinBuildingSize) + maxBuildingSizeSlider.value.Set(settings.MaxBuildingSize) + buildingDistributionSlider.value.Set(settings.BuildingDistribution) + buildingShapeSelect.SetSelected(settings.BuildingShape) + updateRatioSliders() + minBuildingComplexitySlider.value.Set(float64(settings.MinBuildingComplexity)) + maxBuildingComplexitySlider.value.Set(float64(settings.MaxBuildingComplexity)) + buildingComplexityRatioSlider.value.Set(settings.BuildingComplexityRatio) + + for key := range errorStates { + errorStates[key] = false + } + errorLabel.Hide() + updateGenerateBtnState() + } + + presetDir := filepath.Join(appConfigDir, "presets") + var presetInfoByName map[string]presetInfo + var suppressPresetApply bool + var removePresetBtn *widget.Button + + presetSelect := widget.NewSelect([]string{"No Preset"}, func(name string) { + if suppressPresetApply { + return + } + if name == "" || name == "No Preset" { + if removePresetBtn != nil { + removePresetBtn.Disable() + } + return + } + info, ok := presetInfoByName[name] + if !ok { + return + } + applyingPreset = true + var imported *Settings + var err error + if info.Source == "embedded" { + imported, err = loadEmbeddedPreset(info.Path) + } else { + raw, readErr := os.ReadFile(info.Path) + if readErr != nil { + err = readErr + } else { + imported, err = decodeSettingsJSON(raw) + } + } + if err != nil { + applyingPreset = false + dialog.ShowError(err, w) + return + } + imported.Seed = settings.Seed + applySettingsToUI(imported) + time.AfterFunc(50*time.Millisecond, func() { + fyne.Do(func() { + applyingPreset = false + }) + }) + if removePresetBtn != nil { + removePresetBtn.Enable() + } + }) + presetSelect.SetSelected("No Preset") + clearPresetSelection = func() { + if presetSelect.Selected != "No Preset" { + presetSelect.SetSelected("No Preset") + } + } + + refreshPresetOptions := func(selectName string) { + names, info := buildPresetOptions(settings, presetDir) + presetInfoByName = info + suppressPresetApply = true + presetSelect.Options = names + presetSelect.Refresh() + + if selectName == "" { + selectName = presetSelect.Selected + } + if _, ok := presetInfoByName[selectName]; !ok { + selectName = "No Preset" + } + presetSelect.SetSelected(selectName) + if removePresetBtn != nil { + if selectName == "" || selectName == "No Preset" { + removePresetBtn.Disable() + } else { + removePresetBtn.Enable() + } + } + suppressPresetApply = false + } + + addPresetBtn := widget.NewButton("Add Preset", func() { + nameEntry := widget.NewEntry() + nameEntry.SetPlaceHolder("Preset Name") + + content := container.NewVBox( + widget.NewLabel("Preset Name:"), + nameEntry, + ) + + var addDialog *dialog.CustomDialog + saveBtn := widget.NewButton("Save", func() { + name := strings.TrimSpace(nameEntry.Text) + if name == "" || name == "No Preset" { + return + } + if strings.ContainsAny(name, "/\\") { + dialog.ShowError(fmt.Errorf("preset name cannot contain path separators"), w) + return + } + if _, exists := presetInfoByName[name]; exists { + dialog.ShowError(fmt.Errorf("preset name already exists"), w) + return + } + if err := os.MkdirAll(presetDir, 0755); err != nil { + dialog.ShowError(err, w) + return + } + filePath := filepath.Join(presetDir, name+".json") + if err := savePresetToFile(filePath, settings); err != nil { + dialog.ShowError(err, w) + return + } + refreshPresetOptions(name) + if addDialog != nil { + addDialog.Hide() + } + }) + saveBtn.Disable() + + nameEntry.OnChanged = func(text string) { + if strings.TrimSpace(text) == "" { + saveBtn.Disable() + } else { + saveBtn.Enable() + } + } + + addDialog = dialog.NewCustom("Add Preset", "Cancel", content, w) + addDialog.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Cancel", func() { + addDialog.Hide() + }), + saveBtn, + }) + addDialog.Show() + }) + + removePresetBtn = widget.NewButton("Remove Preset", func() { + name := presetSelect.Selected + if name == "" || name == "No Preset" { + return + } + info, ok := presetInfoByName[name] + if !ok { + return + } + if info.Source == "embedded" { + found := false + for _, existing := range settings.HiddenPresets { + if existing == name { + found = true + break + } + } + if !found { + settings.HiddenPresets = append(settings.HiddenPresets, name) + if err := settings.Save(); err != nil { + log.Println("Error saving hidden preset list:", err) + } + } + } else { + if err := os.Remove(info.Path); err != nil { + dialog.ShowError(err, w) + return + } + } + refreshPresetOptions("No Preset") + }) + removePresetBtn.Disable() + refreshPresetOptions("No Preset") + + buildingsTab := container.NewTabItem("Buildings", container.NewVBox( + numBuildingsSlider, + minBuildingSizeSlider, + maxBuildingSizeSlider, + buildingDistributionSlider, + buildingShapeLabel, + buildingShapeSelect, + proceduralContainer, + ratioContainer, + )) + + imageTab := container.NewTabItem("Image", container.NewVBox( + widget.NewLabel("Preset:"), + presetSelect, + container.NewGridWithColumns(2, addPresetBtn, removePresetBtn), + widget.NewSeparator(), + widget.NewLabel("Width:"), + widthEntry, widget.NewLabel("Height:"), + heightEntry, + widget.NewLabel("Seed:"), + seedEntry, + randomizeBtn, + generateBtn, + errorLabel, + progressContainer, + widget.NewSeparator(), + exportCanvasBtn, + exportHeightmapBtn, + exportBumpmapBtn, + exportMasksBtn, + exportSettingsBtn, + importSettingsBtn, + )) + // Create the main layout using a horizontal split + tabs := container.NewAppTabs( + imageTab, + terrainTab, + waterTab, + roadsTab, + fortificationsTab, + buildingsTab, + ) + + left := container.NewVBox( + widget.NewLabel("RPG City Maker Reborn"), + tabs, + ) + + right := container.NewMax() + var tappableCanvas, tappableHeightmap, tappableBumpmap *tappableImage + + updateRightPanel := func() { + var top, bottom fyne.CanvasObject + switch settings.ImageViewState { + case 1: + top = tappableCanvas + bottom = container.NewGridWithColumns(2, tappableHeightmap, tappableBumpmap) + case 2: + top = tappableHeightmap + bottom = container.NewGridWithColumns(2, tappableCanvas, tappableBumpmap) + case 3: + top = tappableBumpmap + bottom = container.NewGridWithColumns(2, tappableCanvas, tappableHeightmap) + default: + right.Objects = []fyne.CanvasObject{container.NewGridWithRows(3, tappableCanvas, tappableHeightmap, tappableBumpmap)} + right.Refresh() + return + } + split := container.NewVSplit(top, bottom) + split.Offset = 0.8 + right.Objects = []fyne.CanvasObject{split} + right.Refresh() + } + + tappableCanvas = newTappableImage(container.NewMax(canvasImg), func() { + if settings.ImageViewState == 1 { + settings.ImageViewState = 0 + } else { + settings.ImageViewState = 1 + } + updateRightPanel() + }) + tappableHeightmap = newTappableImage(container.NewMax(heightmapImg), func() { + if settings.ImageViewState == 2 { + settings.ImageViewState = 0 + } else { + settings.ImageViewState = 2 + } + updateRightPanel() + }) + tappableBumpmap = newTappableImage(container.NewMax(bumpmapImg), func() { + if settings.ImageViewState == 3 { + settings.ImageViewState = 0 + } else { + settings.ImageViewState = 3 + } + updateRightPanel() + }) + + updateRightPanel() + + split := container.NewHSplit( + left, + right, + ) + split.SetOffset(0.3) + // Set the window content and start the application + w.SetContent(split) + w.ShowAndRun() +} +func encodeImageToWriter(w io.Writer, img image.Image, format string) error { + switch format { + case "PNG": + return png.Encode(w, img) + case "JPG": + return jpeg.Encode(w, img, nil) + case "WEBP": + return webp.Encode(w, img, &webp.Options{Lossless: true}) + } + return fmt.Errorf("unsupported format: %s", format) +} + +// showMasksSaveDialog displays a dialog for saving the generated masks. +func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg image.Image, settings *Settings, lakes [][]image.Point, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask *PixelMask) { + // Create UI elements for the save dialog + fileNameEntry := widget.NewEntry() + fileNameEntry.SetPlaceHolder("masks_folder") + + formatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, nil) + formatSelect.SetSelected("PNG") + + packageSelect := widget.NewSelect([]string{"Folder", "tar.gz", "zip"}, nil) + packageSelect.SetSelected("Folder") + + pathLabel := widget.NewLabel(settings.LastExportPath) + + browseBtn := widget.NewButton("Browse", func() { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil { + log.Println("Error opening folder dialog:", err) + return + } + if uri == nil { + return + } + settings.LastExportPath = uri.Path() + pathLabel.SetText(settings.LastExportPath) + }, win) + }) + + content := container.NewVBox( + widget.NewLabel("Save to:"), + container.NewBorder(nil, nil, nil, browseBtn, pathLabel), + widget.NewLabel("Folder Name:"), + fileNameEntry, + widget.NewLabel("Format:"), + formatSelect, + widget.NewLabel("Packaging:"), + packageSelect, + ) + + saveDialog := dialog.NewCustom("Export Masks", "Cancel", content, win) + + saveBtn := widget.NewButton("Save", func() { + folderName := fileNameEntry.Text + if folderName == "" { + return + } + imgFormat := strings.ToLower(formatSelect.Selected) + bounds := canvasImg.Bounds() + maskToGray := func(mask *PixelMask) image.Image { + out := image.NewGray(bounds) + if mask == nil { + return out + } + for y := 0; y < mask.Height; y++ { + row := y * mask.Width + for x := 0; x < mask.Width; x++ { + if mask.Data[row+x] != 0 { + out.SetGray(x, y, color.Gray{Y: 255}) + } + } + } + return out + } + buildLakeMask := func() image.Image { + lakeMask := image.NewGray(bounds) + for _, lake := range lakes { + for _, p := range lake { + lakeMask.SetGray(p.X, p.Y, color.Gray{Y: 255}) + } + } + return lakeMask + } + type exportItem struct { + name string + make func() image.Image + } + items := []exportItem{ + {name: "canvas." + imgFormat, make: func() image.Image { return canvasImg }}, + {name: "heightmap." + imgFormat, make: func() image.Image { return heightmapImg }}, + {name: "bump_map." + imgFormat, make: func() image.Image { return bumpmapImg }}, + {name: "lakes_mask." + imgFormat, make: buildLakeMask}, + {name: "rivers_mask." + imgFormat, make: func() image.Image { return maskToGray(riverMask) }}, + {name: "trees_mask." + imgFormat, make: func() image.Image { return maskToGray(treeMask) }}, + {name: "roads_mask." + imgFormat, make: func() image.Image { return maskToGray(roadMask) }}, + {name: "bridges_mask." + imgFormat, make: func() image.Image { return maskToGray(bridgeMask) }}, + {name: "walls_mask." + imgFormat, make: func() image.Image { return maskToGray(wallMask) }}, + {name: "turrets_mask." + imgFormat, make: func() image.Image { return maskToGray(turretMask) }}, + {name: "buildings_mask." + imgFormat, make: func() image.Image { return maskToGray(buildingMask) }}, + } + + // Save the images based on the selected packaging option + switch packageSelect.Selected { + case "Folder": + exportPath := filepath.Join(pathLabel.Text, folderName) + if err := os.MkdirAll(exportPath, 0755); err != nil { + log.Println("Error creating directory:", err) + return + } + settings.LastExportPath = exportPath + for _, item := range items { + saveImage(item.make(), filepath.Join(exportPath, item.name)) + } + case "tar.gz": + filePath := filepath.Join(pathLabel.Text, folderName+".tar.gz") + settings.LastExportPath = filepath.Dir(filePath) + file, err := os.Create(filePath) + if err != nil { + log.Println("Error creating archive:", err) + return + } + defer file.Close() + + gw := gzip.NewWriter(file) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + var buf bytes.Buffer + for _, item := range items { + buf.Reset() + if err := encodeImageToWriter(&buf, item.make(), formatSelect.Selected); err != nil { + log.Printf("Error encoding image %s: %v\n", item.name, err) + continue + } + hdr := &tar.Header{ + Name: item.name, + Mode: 0644, + Size: int64(buf.Len()), + } + if err := tw.WriteHeader(hdr); err != nil { + log.Printf("Error writing tar header for %s: %v\n", item.name, err) + continue + } + if _, err := tw.Write(buf.Bytes()); err != nil { + log.Printf("Error writing tar data for %s: %v\n", item.name, err) + } + } + case "zip": + filePath := filepath.Join(pathLabel.Text, folderName+".zip") + settings.LastExportPath = filepath.Dir(filePath) + file, err := os.Create(filePath) + if err != nil { + log.Println("Error creating archive:", err) + return + } + defer file.Close() + + zw := zip.NewWriter(file) + defer zw.Close() + + for _, item := range items { + f, err := zw.Create(item.name) + if err != nil { + log.Printf("Error creating zip entry for %s: %v\n", item.name, err) + continue + } + if err := encodeImageToWriter(f, item.make(), formatSelect.Selected); err != nil { + log.Printf("Error writing zip data for %s: %v\n", item.name, err) + continue + } + } + } + + saveDialog.Hide() + }) + saveBtn.Disable() + + fileNameEntry.OnChanged = func(text string) { + if text == "" { + saveBtn.Disable() + } else { + saveBtn.Enable() + } + } + + saveDialog.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Cancel", func() { + saveDialog.Hide() + }), + saveBtn, + }) + + saveDialog.Show() +} + +// saveImage saves an image to the specified path. +func saveImage(img image.Image, path string) { + file, err := os.Create(path) + if err != nil { + log.Println("Error creating file:", err) + return + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".png": + err = png.Encode(file, img) + case ".jpg": + err = jpeg.Encode(file, img, nil) + case ".webp": + err = webp.Encode(file, img, &webp.Options{Lossless: true}) + } + if err != nil { + log.Println("Failed to encode image:", err) + } +} +func showSettingsExportDialog(win fyne.Window, settings *Settings) { + fileNameEntry := widget.NewEntry() + fileNameEntry.SetPlaceHolder("settings") + + pathLabel := widget.NewLabel(settings.LastExportPath) + + browseBtn := widget.NewButton("Browse", func() { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil { + log.Println("Error opening folder dialog:", err) + return + } + if uri == nil { + return + } + settings.LastExportPath = uri.Path() + pathLabel.SetText(settings.LastExportPath) + }, win) + }) + + content := container.NewVBox( + widget.NewLabel("Save to:"), + container.NewBorder(nil, nil, nil, browseBtn, pathLabel), + widget.NewLabel("Filename:"), + fileNameEntry, + ) + + saveDialog := dialog.NewCustom("Export Settings", "Cancel", content, win) + + saveBtn := widget.NewButton("Save", func() { + fileName := strings.TrimSpace(fileNameEntry.Text) + if fileName == "" { + return + } + if !strings.HasSuffix(strings.ToLower(fileName), ".json") { + fileName += ".json" + } + filePath := filepath.Join(pathLabel.Text, fileName) + if err := saveSettingsToFile(filePath, settings); err != nil { + dialog.ShowError(err, win) + return + } + settings.LastExportPath = filepath.Dir(filePath) + saveDialog.Hide() + }) + saveBtn.Disable() + + fileNameEntry.OnChanged = func(text string) { + if strings.TrimSpace(text) == "" { + saveBtn.Disable() + } else { + saveBtn.Enable() + } + } + + saveDialog.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Cancel", func() { + saveDialog.Hide() + }), + saveBtn, + }) + + saveDialog.Show() +} + +func showSettingsImportDialog(win fyne.Window, settings *Settings, apply func(*Settings)) { + dialog.ShowFileOpen(func(rc fyne.URIReadCloser, err error) { + if err != nil { + dialog.ShowError(err, win) + return + } + if rc == nil { + return + } + defer rc.Close() + + raw, err := io.ReadAll(rc) + if err != nil { + dialog.ShowError(err, win) + return + } + imported, err := decodeSettingsJSON(raw) + if err != nil { + dialog.ShowError(err, win) + return + } + if uri := rc.URI(); uri != nil { + settings.LastExportPath = filepath.Dir(uri.Path()) + } + apply(imported) + }, win) +} + +// showSaveDialog displays a dialog for saving an image. +func showSaveDialog(win fyne.Window, img image.Image, settings *Settings) { + // Create UI elements for the save dialog + fileNameEntry := widget.NewEntry() + fileNameEntry.SetPlaceHolder("image") + + formatSelect := widget.NewSelect([]string{"PNG", "JPG", "WEBP"}, nil) + formatSelect.SetSelected("PNG") + + pathLabel := widget.NewLabel(settings.LastExportPath) + + browseBtn := widget.NewButton("Browse", func() { + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil { + log.Println("Error opening folder dialog:", err) + return + } + if uri == nil { + return + } + settings.LastExportPath = uri.Path() + pathLabel.SetText(settings.LastExportPath) + }, win) + }) + + content := container.NewVBox( + widget.NewLabel("Save to:"), + container.NewBorder(nil, nil, nil, browseBtn, pathLabel), + widget.NewLabel("Filename:"), + fileNameEntry, + widget.NewLabel("Format:"), + formatSelect, + ) + + saveDialog := dialog.NewCustom("Export Image", "Cancel", content, win) + + saveBtn := widget.NewButton("Save", func() { + fileName := fileNameEntry.Text + if fileName == "" { + return + } + + filePath := filepath.Join(pathLabel.Text, fileName+"."+strings.ToLower(formatSelect.Selected)) + settings.LastExportPath = filepath.Dir(filePath) + saveImage(img, filePath) + saveDialog.Hide() + }) + saveBtn.Disable() + + fileNameEntry.OnChanged = func(text string) { + if text == "" { + saveBtn.Disable() + } else { + saveBtn.Enable() + } + } + + saveDialog.SetButtons([]fyne.CanvasObject{ + widget.NewButton("Cancel", func() { + saveDialog.Hide() + }), + saveBtn, + }) + + saveDialog.Show() +} diff --git a/water.go b/water.go index 0696c2c..b2a5a82 100644 --- a/water.go +++ b/water.go @@ -874,30 +874,6 @@ func removeSpeckles(mask *[]uint8, bw, bh, minNeighbors int) { *mask = arr } -// 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++ { - for x := 0; x < bw; x++ { - if mask[y*bw+x] == 1 { - pts = append(pts, image.Point{X: x + minX, Y: y + minY}) - } - } - } - return pts -} - -// clamp01 clamps value to 0..1 range -func clamp01(v float64) float64 { - if v < 0 { - return 0 - } - if v > 1 { - return 1 - } - return v -} - // 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, *PixelMask) { if numRivers == 0 { @@ -959,7 +935,7 @@ func GenerateRivers(width, height, numRivers int, minWidth, maxWidth, curvyness if isWater.GetPoint(p) { if lakeIndex, isLake := lakePixelMap[p]; isLake { // End river at lake center if it intersects - lakeCenter := findCenter(lakes[lakeIndex]) + lakeCenter := averagePoint(lakes[lakeIndex]) r.End = lakeCenter } else { // End river at intersection with another river @@ -1086,22 +1062,6 @@ func calculateRiverPath(start, end image.Point, curvyness, avgDim float64, randS return bresenhamRiver(controlPoints) } -// findCenter calculates the center point of a set of pixels -func findCenter(pixels []image.Point) image.Point { - if len(pixels) == 0 { - return image.Point{} - } - var sumX, sumY int - for _, p := range pixels { - sumX += p.X - sumY += p.Y - } - return image.Point{ - X: sumX / len(pixels), - Y: sumY / len(pixels), - } -} - // getPointOnEdge returns a random point on the specified map edge func getPointOnEdge(width, height, edge int, randSrc *rand.Rand) image.Point { switch edge {