diff --git a/fortifications.go b/fortifications.go new file mode 100644 index 0000000..55c6f89 --- /dev/null +++ b/fortifications.go @@ -0,0 +1,1077 @@ +package main + +import ( + "image" + "image/color" + "math" + "math/rand" + "sort" +) + +const ( + minWallWidthPercent = minBuildingSizePercent + maxWallWidthPercent = maxBuildingSizePercent + wallWidthPercentStep = buildingSizePercentStep + + minTurretSizePercent = 0.2 + maxTurretSizePercent = maxWallWidthPercent + turretSizePercentStep = 0.1 +) + +func clampWallWidthPercent(v float64) float64 { + if v < minWallWidthPercent { + return minWallWidthPercent + } + if v > maxWallWidthPercent { + return maxWallWidthPercent + } + return v +} + +func snapWallWidthPercent(v float64) float64 { + v = clampWallWidthPercent(v) + steps := math.Round((v - minWallWidthPercent) / wallWidthPercentStep) + return clampWallWidthPercent(minWallWidthPercent + steps*wallWidthPercentStep) +} + +func normalizeWallWidthPercentRange(minPercent, maxPercent float64) (float64, float64) { + minPercent = snapWallWidthPercent(minPercent) + maxPercent = snapWallWidthPercent(maxPercent) + if minPercent > maxPercent { + minPercent, maxPercent = maxPercent, minPercent + } + return minPercent, maxPercent +} + +func getWallWidthRangePixels(settings *Settings, width, height int) (float64, float64) { + minPercent, maxPercent := normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) + avgDim := averageImageDimension(width, height) + if avgDim < 1 { + avgDim = 1 + } + minPx := (minPercent / 100.0) * avgDim + maxPx := (maxPercent / 100.0) * avgDim + if minPx < 1 { + minPx = 1 + } + if maxPx < 1 { + maxPx = 1 + } + return minPx, maxPx +} + +func clampTurretSizePercent(v float64) float64 { + if v < minTurretSizePercent { + return minTurretSizePercent + } + if v > maxTurretSizePercent { + return maxTurretSizePercent + } + return v +} + +func snapTurretSizePercent(v float64) float64 { + v = clampTurretSizePercent(v) + steps := math.Round((v - minTurretSizePercent) / turretSizePercentStep) + return clampTurretSizePercent(minTurretSizePercent + steps*turretSizePercentStep) +} + +func getTurretSizePixels(settings *Settings, width, height int) float64 { + sizePercent := snapTurretSizePercent(settings.TurretSize) + avgDim := averageImageDimension(width, height) + if avgDim < 1 { + avgDim = 1 + } + sizePx := (sizePercent / 100.0) * avgDim + if sizePx < 1 { + sizePx = 1 + } + return sizePx +} + +// 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 +} + +type FortificationLayout struct { + Mask *PixelMask + WallIDByPixel []int + Coverages []float64 + Gates []GateInfo +} + +func GenerateFortifications( + img *image.RGBA, + width, height int, + settings *Settings, + waterMask *PixelMask, + roadNodes []*PointOfInterest, + seed int64, +) (*FortificationLayout, [][]image.Point) { + layout := &FortificationLayout{ + Mask: NewPixelMask(width, height), + WallIDByPixel: make([]int, width*height), + } + if settings.NumWalls <= 0 || settings.CityCoverage <= 0 { + return layout, nil + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + if img == nil { + img = image.NewRGBA(image.Rect(0, 0, width, height)) + } + + randSrc := rand.New(rand.NewSource(seed)) + minWidthPx, maxWidthPx := getWallWidthRangePixels(settings, width, height) + wallColor := color.RGBA{R: 0, G: 0, B: 0, A: 255} + + walls := make([][]image.Point, 0, settings.NumWalls) + outerCoverage := clamp(settings.CityCoverage, 1, 100) + totalWalls := max(1, settings.NumWalls) + prevCoverage := 101.0 + layout.Coverages = make([]float64, 0, totalWalls) + + for i := 0; i < totalWalls; i++ { + baseCoverage := outerCoverage * float64(totalWalls-i) / float64(totalWalls) + coverage := baseCoverage + if i > 0 { + coverage += randSrc.Float64()*10.0 - 5.0 + } + coverage = clamp(coverage, 1, 100) + if coverage >= prevCoverage { + coverage = prevCoverage - 1 + if coverage < 1 { + coverage = 1 + } + } + prevCoverage = coverage + layout.Coverages = append(layout.Coverages, coverage) + + nodes := estimateWallNodeCount(coverage) + wallPath := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes) + if len(wallPath) < 3 { + continue + } + + wallWidthPx := minWidthPx + if maxWidthPx > minWidthPx { + wallWidthPx = minWidthPx + randSrc.Float64()*(maxWidthPx-minWidthPx) + } + wallWidth := int(math.Round(wallWidthPx)) + if wallWidth < 1 { + wallWidth = 1 + } + + pixels := drawWallLoopWithWaterGaps(img, wallPath, wallColor, wallWidth, layout.Mask, waterMask, layout.WallIDByPixel, i+1) + if len(pixels) > 0 { + walls = append(walls, pixels) + } + } + + computeGatesForLayout(img, layout, walls, settings, width, height, waterMask) + + return layout, walls +} + +// computeGatesForLayout computes gate positions for all wall rings. +// Gates are placed at regular intervals along each wall (GateSpacing % of circumference). +// Each gate consists of: left turret, gap (3x road width), right turret. +// The gap is cleared from the wall mask so roads can pass through. +func computeGatesForLayout( + img *image.RGBA, + layout *FortificationLayout, + walls [][]image.Point, + settings *Settings, + width, height int, + waterMask *PixelMask, +) { + if layout == nil || settings.GateSpacing <= 0 || len(walls) == 0 { + return + } + + _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) + roadWidth := maxRoadPx + if roadWidth < 1 { + roadWidth = 1 + } + gapHalf := roadWidth * 1.5 // gap is 3x road width total, so 1.5 each side + + sizePx := getTurretSizePixels(settings, width, height) + turretRadius := int(math.Round(sizePx / 2.0)) + if turretRadius < 1 { + turretRadius = 1 + } + shape := settings.TurretShape + if shape != "square" { + shape = "circular" + } + gateColor := color.RGBA{R: 220, G: 25, B: 25, A: 255} + bgColor := color.RGBA{R: 0, G: 0, B: 0, A: 0} // transparent to clear wall pixels + + for wallIdx, wallPixels := range walls { + wallID := wallIdx + 1 + if len(wallPixels) == 0 { + continue + } + + // Collect boundary pixels for this wall, sorted by angle around centroid. + centroid := averagePoint(wallPixels) + type boundaryPt struct { + p image.Point + angle float64 + } + bpts := make([]boundaryPt, 0, len(wallPixels)) + for _, p := range wallPixels { + if !isBoundaryWallPixel(p.X, p.Y, layout.Mask) { + continue + } + a := math.Atan2(float64(p.Y-centroid.Y), float64(p.X-centroid.X)) + bpts = append(bpts, boundaryPt{p, a}) + } + if len(bpts) < 8 { + continue + } + sort.Slice(bpts, func(i, j int) bool { return bpts[i].angle < bpts[j].angle }) + + // Determine step between gates as fraction of boundary pixel count. + spacing := clamp(settings.GateSpacing, 1, 100) + step := int(math.Round((spacing / 100.0) * float64(len(bpts)))) + if step < 1 { + step = 1 + } + if step > len(bpts) { + continue // spacing > 100%, no gate + } + + for i := 0; i < len(bpts); i += step { + gateCenter := bpts[i].p + + // Estimate wall tangent and normal at this point. + tx, ty, ok := fortEstimateWallTangent(gateCenter, layout.Mask) + if !ok { + continue + } + // Normal = perpendicular to tangent, pointing outward from centroid. + nx, ny := -ty, tx + cx := float64(gateCenter.X) - float64(centroid.X) + cy := float64(gateCenter.Y) - float64(centroid.Y) + if cx*nx+cy*ny < 0 { + nx, ny = -nx, -ny + } + + // Clear the gap in the wall mask (3x road width centered on gateCenter). + gapInt := int(math.Ceil(gapHalf)) + for dy := -gapInt * 3; dy <= gapInt*3; dy++ { + for dx := -gapInt * 3; dx <= gapInt*3; dx++ { + // Only erase pixels that are close to the perpendicular axis (along wall normal). + // Project (dx,dy) onto tangent — must be within gapHalf. + tanProj := math.Abs(float64(dx)*tx + float64(dy)*ty) + if tanProj > gapHalf { + continue + } + xx := gateCenter.X + dx + yy := gateCenter.Y + dy + if !layout.Mask.InBounds(xx, yy) { + continue + } + if waterMask != nil && waterMask.GetXY(xx, yy) { + continue + } + if layout.WallIDByPixel[yy*width+xx] == wallID { + layout.Mask.ClearXY(xx, yy) + layout.WallIDByPixel[yy*width+xx] = 0 + if img != nil { + img.Set(xx, yy, bgColor) + } + } + } + } + + // Place turrets on both sides of the gap. + leftCenter := image.Point{ + X: int(math.Round(float64(gateCenter.X) + tx*gapHalf)), + Y: int(math.Round(float64(gateCenter.Y) + ty*gapHalf)), + } + rightCenter := image.Point{ + X: int(math.Round(float64(gateCenter.X) - tx*gapHalf)), + Y: int(math.Round(float64(gateCenter.Y) - ty*gapHalf)), + } + // Snap to wall center line. + if lc, ok := snapPointToWallCenter(leftCenter, layout.Mask, turretRadius*6); ok { + leftCenter = lc + } + if rc, ok := snapPointToWallCenter(rightCenter, layout.Mask, turretRadius*6); ok { + rightCenter = rc + } + + turretMaskTemp := NewPixelMask(width, height) + drawTurret(img, turretMaskTemp, leftCenter, turretRadius, shape, gateColor) + drawTurret(img, turretMaskTemp, rightCenter, turretRadius, shape, gateColor) + + // The road must pass through the midpoint between the two turrets. + // After snapping, leftCenter and rightCenter may have drifted from gateCenter, + // so rebase the road axis on their actual midpoint. + turretMidX := float64(leftCenter.X+rightCenter.X) / 2.0 + turretMidY := float64(leftCenter.Y+rightCenter.Y) / 2.0 + + // Compute inner/outer road endpoints just past the wall, projected from turret midpoint. + reach := float64(turretRadius) + roadWidth + 2 + innerEnd := image.Point{ + X: int(math.Round(turretMidX - nx*reach)), + Y: int(math.Round(turretMidY - ny*reach)), + } + outerEnd := image.Point{ + X: int(math.Round(turretMidX + nx*reach)), + Y: int(math.Round(turretMidY + ny*reach)), + } + // Clamp to image bounds. + clampPt := func(p image.Point) image.Point { + if p.X < 0 { + p.X = 0 + } + if p.X >= width { + p.X = width - 1 + } + if p.Y < 0 { + p.Y = 0 + } + if p.Y >= height { + p.Y = height - 1 + } + return p + } + innerEnd = clampPt(innerEnd) + outerEnd = clampPt(outerEnd) + + // Validate: innerEnd should be closer to centroid than outerEnd. + // If not, the normal is pointing the wrong way — flip inner/outer. + innerDistToCentroid := math.Hypot(float64(innerEnd.X-centroid.X), float64(innerEnd.Y-centroid.Y)) + outerDistToCentroid := math.Hypot(float64(outerEnd.X-centroid.X), float64(outerEnd.Y-centroid.Y)) + if innerDistToCentroid > outerDistToCentroid { + innerEnd, outerEnd = outerEnd, innerEnd + } + + // Reject gate if both ends landed on the same side of the wall + // (i.e. both are inside or outside — the road would double back). + // Check: innerEnd must not be in wall, outerEnd must not be in wall, + // and they must be on opposite sides (one closer to centroid, one farther). + // A strong sign of a doubling-back gate: inner and outer are very close together + // relative to the wall thickness, or the road segment crosses no wall pixels. + innerInWall := layout.Mask.GetXY(innerEnd.X, innerEnd.Y) + outerInWall := layout.Mask.GetXY(outerEnd.X, outerEnd.Y) + if innerInWall || outerInWall { + // At least one end is still inside the wall — not a clean crossing. + // Extend reach until both are clear. + for extraReach := reach + 1; extraReach <= reach+float64(turretRadius)*4+roadWidth*4; extraReach += 1 { + candidateInner := clampPt(image.Point{ + X: int(math.Round(float64(gateCenter.X) - nx*extraReach)), + Y: int(math.Round(float64(gateCenter.Y) - ny*extraReach)), + }) + candidateOuter := clampPt(image.Point{ + X: int(math.Round(float64(gateCenter.X) + nx*extraReach)), + Y: int(math.Round(float64(gateCenter.Y) + ny*extraReach)), + }) + if !layout.Mask.GetXY(candidateInner.X, candidateInner.Y) && + !layout.Mask.GetXY(candidateOuter.X, candidateOuter.Y) { + innerEnd = candidateInner + outerEnd = candidateOuter + // Re-check orientation. + id := math.Hypot(float64(innerEnd.X-centroid.X), float64(innerEnd.Y-centroid.Y)) + od := math.Hypot(float64(outerEnd.X-centroid.X), float64(outerEnd.Y-centroid.Y)) + if id > od { + innerEnd, outerEnd = outerEnd, innerEnd + } + break + } + } + } + + // Final rejection: if the straight line from innerEnd to outerEnd doesn't + // cross any wall pixels, this gate will produce a doubling-back road. + // Count wall pixels along the path. + gateLine := bresenhamPoints(innerEnd, outerEnd) + wallCrossings := 0 + for _, gp := range gateLine { + if layout.Mask.GetXY(gp.X, gp.Y) { + wallCrossings++ + } + } + if wallCrossings == 0 { + // The road wouldn't cross the wall at all — skip this gate. + continue + } + + layout.Gates = append(layout.Gates, GateInfo{ + WallID: wallID, + Center: gateCenter, + Normal: [2]float64{nx, ny}, + LeftTurret: leftCenter, + RightTurret: rightCenter, + InnerEnd: innerEnd, + OuterEnd: outerEnd, + }) + } + } +} + +func estimateWallNodeCount(coverage float64) int { + n := int(math.Round(20 + coverage*0.7)) + if n < 20 { + n = 20 + } + if n > 96 { + n = 96 + } + return n +} + +func generateWallLoop(width, height int, coverage, curvyness float64, nodes int, randSrc *rand.Rand, roadNodes []*PointOfInterest) []image.Point { + if width <= 0 || height <= 0 || nodes < 3 { + return nil + } + + centerX, centerY, baseRadiusX, baseRadiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes) + curveScale := clamp(curvyness, 0, 100) / 100.0 + warpAmp := 0.20 * curveScale + phaseA := randSrc.Float64() * 2 * math.Pi + phaseB := randSrc.Float64() * 2 * math.Pi + + out := make([]image.Point, 0, nodes+1) + for i := 0; i < nodes; i++ { + t := (2 * math.Pi * float64(i)) / float64(nodes) + warp := 1.0 + warpAmp*(0.6*math.Sin(3*t+phaseA)+0.4*math.Sin(5*t+phaseB)) + if warp < 0.7 { + warp = 0.7 + } + rx := baseRadiusX * warp + ry := baseRadiusY * warp + x := int(math.Round(centerX + rx*math.Cos(t))) + y := int(math.Round(centerY + ry*math.Sin(t))) + if x < 0 { + x = 0 + } + if x >= width { + x = width - 1 + } + if y < 0 { + y = 0 + } + if y >= height { + y = height - 1 + } + out = append(out, image.Point{X: x, Y: y}) + } + if len(out) > 0 { + out = append(out, out[0]) + } + return out +} + +func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []*PointOfInterest) (centerX, centerY, radiusX, radiusY float64) { + centerX = float64(width-1) * 0.5 + centerY = float64(height-1) * 0.5 + coverageRadius := math.Sqrt(clamp(coverage, 1, 100) / 100.0) + radiusX = centerX * coverageRadius + radiusY = centerY * coverageRadius + + if len(roadNodes) == 0 { + return centerX, centerY, radiusX, radiusY + } + + sumX, sumY := 0.0, 0.0 + for _, n := range roadNodes { + sumX += float64(n.X) + sumY += float64(n.Y) + } + centerX = sumX / float64(len(roadNodes)) + centerY = sumY / float64(len(roadNodes)) + + dists := make([]float64, 0, len(roadNodes)) + var sx, sy float64 + for _, n := range roadNodes { + dx := float64(n.X) - centerX + dy := float64(n.Y) - centerY + dists = append(dists, math.Hypot(dx, dy)) + sx += dx * dx + sy += dy * dy + } + sort.Float64s(dists) + q := clamp(coverage, 1, 100) / 100.0 + idx := int(math.Ceil(q*float64(len(dists)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(dists) { + idx = len(dists) - 1 + } + baseRadius := dists[idx] + if baseRadius < 10 { + baseRadius = 10 + } + + stdX := math.Sqrt(sx / float64(len(roadNodes))) + stdY := math.Sqrt(sy / float64(len(roadNodes))) + aspect := 1.0 + if stdY > 0.001 { + aspect = stdX / stdY + } + aspect = clamp(aspect, 0.65, 1.55) + radiusX = baseRadius * aspect + radiusY = baseRadius / aspect + + maxRadiusX := math.Max(5, math.Min(centerX, float64(width-1)-centerX)) + maxRadiusY := math.Max(5, math.Min(centerY, float64(height-1)-centerY)) + radiusX = clamp(radiusX, 5, maxRadiusX) + radiusY = clamp(radiusY, 5, maxRadiusY) + + return centerX, centerY, radiusX, radiusY +} + +func drawWallLoopWithWaterGaps( + img *image.RGBA, + loop []image.Point, + col color.RGBA, + width int, + wallMask *PixelMask, + waterMask *PixelMask, + wallIDByPixel []int, + wallID int, +) []image.Point { + if len(loop) < 2 || wallMask == nil { + return nil + } + seen := make(map[int]bool) + pixels := make([]image.Point, 0, len(loop)*8) + radius := max(1, width/2) + + for i := 0; i < len(loop)-1; i++ { + a := loop[i] + b := loop[i+1] + drawSegmentSelective(a.X, a.Y, b.X, b.Y, func(x, y int) { + if !wallMask.InBounds(x, y) { + return + } + if waterMask != nil && waterMask.GetXY(x, y) { + return + } + for dy := -radius; dy <= radius; dy++ { + yy := y + dy + if yy < 0 || yy >= wallMask.Height { + continue + } + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy > radius*radius { + continue + } + xx := x + dx + if xx < 0 || xx >= wallMask.Width { + continue + } + if waterMask != nil && waterMask.GetXY(xx, yy) { + continue + } + wallMask.SetXY(xx, yy) + if len(wallIDByPixel) == wallMask.Width*wallMask.Height { + wallIDByPixel[yy*wallMask.Width+xx] = wallID + } + img.Set(xx, yy, col) + idx := yy*wallMask.Width + xx + if !seen[idx] { + seen[idx] = true + pixels = append(pixels, image.Point{X: xx, Y: yy}) + } + } + } + }) + } + + return pixels +} + +// bresenhamPoints returns all pixels on a line from a to b using Bresenham's algorithm. +func bresenhamPoints(a, b image.Point) []image.Point { + pts := make([]image.Point, 0, max(abs(b.X-a.X), abs(b.Y-a.Y))+1) + x0, y0, x1, y1 := a.X, a.Y, b.X, b.Y + dx := abs(x1 - x0) + dy := abs(y1 - y0) + sx := -1 + if x0 < x1 { + sx = 1 + } + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx - dy + for { + pts = append(pts, image.Point{X: x0, Y: y0}) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x0 += sx + } + if e2 < dx { + err += dx + y0 += sy + } + } + return pts +} + +func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) { + dx := abs(x1 - x0) + dy := abs(y1 - y0) + sx := -1 + if x0 < x1 { + sx = 1 + } + sy := -1 + if y0 < y1 { + sy = 1 + } + err := dx - dy + for { + plot(x0, y0) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x0 += sx + } + if e2 < dx { + err += dx + y0 += sy + } + } +} + +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 + } + black := color.RGBA{R: 0, G: 0, B: 0, A: 255} + for y := 0; y < wallMask.Height; y++ { + row := y * wallMask.Width + for x := 0; x < wallMask.Width; x++ { + if wallMask.Data[row+x] != 0 { + img.Set(x, y, black) + } + } + } +} + +func GenerateTurrets( + img *image.RGBA, + width, height int, + settings *Settings, + layout *FortificationLayout, + waterMask, roadMask *PixelMask, + roads []*Road, +) *PixelMask { + mask := NewPixelMask(width, height) + if !settings.ShowTurrets || layout == nil || layout.Mask == nil || len(layout.WallIDByPixel) != width*height { + return mask + } + if img == nil { + img = image.NewRGBA(image.Rect(0, 0, width, height)) + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + if roadMask == nil { + roadMask = NewPixelMask(width, height) + } + + sizePx := getTurretSizePixels(settings, width, height) + radius := int(math.Round(sizePx / 2.0)) + if radius < 1 { + radius = 1 + } + shape := settings.TurretShape + if shape != "square" { + shape = "circular" + } + colorRed := color.RGBA{R: 220, G: 25, B: 25, A: 255} + + wallPoints := make(map[int][]image.Point) + waterMeetPoints := make(map[int][]image.Point) + for y := 0; y < height; y++ { + row := y * width + for x := 0; x < width; x++ { + wid := layout.WallIDByPixel[row+x] + if wid <= 0 { + continue + } + if !isBoundaryWallPixel(x, y, layout.Mask) { + continue + } + p := image.Point{X: x, Y: y} + wallPoints[wid] = append(wallPoints[wid], p) + if touchesWater(x, y, waterMask) { + waterMeetPoints[wid] = append(waterMeetPoints[wid], p) + } + } + } + + occupied := make(map[int]bool) + addTurret := func(center image.Point) { + snapped, ok := snapPointToWallCenter(center, layout.Mask, max(3, radius*4)) + if !ok { + return + } + if nearbyTurretExists(mask, snapped, max(2, radius)) { + return + } + key := snapped.Y*width + snapped.X + if occupied[key] { + return + } + occupied[key] = true + drawTurret(img, mask, snapped, radius, shape, colorRed) + } + + // Base spacing turrets along each wall ring. + for wid, pts := range wallPoints { + if len(pts) == 0 { + continue + } + centroid := averagePoint(pts) + sort.Slice(pts, func(i, j int) bool { + ai := math.Atan2(float64(pts[i].Y-centroid.Y), float64(pts[i].X-centroid.X)) + aj := math.Atan2(float64(pts[j].Y-centroid.Y), float64(pts[j].X-centroid.X)) + return ai < aj + }) + // Spacing is "distance along wall as % of wall circumference", independent of turret size. + spacingPct := clamp(settings.TurretSpacing, 0, 100) + step := int(math.Round((spacingPct / 100.0) * float64(len(pts)))) + if step < 1 { + step = 1 + } + if step > len(pts) { + step = len(pts) + } + for i := 0; i < len(pts); i += step { + addTurret(pts[i]) + } + + // Always place turrets where wall meets water. + for _, p := range waterMeetPoints[wid] { + addTurret(p) + } + } + + // Gate turrets: one on each side of each road crossing, spacing = 3x road width. + for _, r := range roads { + if r == nil || len(r.Points) < 2 { + continue + } + gates := roadGateCentersForRoad(r, layout) + if len(gates) == 0 { + continue + } + for _, g := range gates { + tx, ty, ok := fortEstimateWallTangent(g, layout.Mask) + if !ok { + continue + } + offset := 1.5 * float64(max(1, r.Width)) + left := image.Point{ + X: int(math.Round(float64(g.X) + tx*offset)), + Y: int(math.Round(float64(g.Y) + ty*offset)), + } + right := image.Point{ + X: int(math.Round(float64(g.X) - tx*offset)), + Y: int(math.Round(float64(g.Y) - ty*offset)), + } + addTurret(left) + addTurret(right) + } + } + + // Always redraw gate turrets from layout.Gates last so they appear on top of roads. + // (Gate turrets were first drawn during fortification generation but roads paint over them.) + if len(layout.Gates) > 0 { + for _, gate := range layout.Gates { + drawTurret(img, mask, gate.LeftTurret, radius, shape, colorRed) + drawTurret(img, mask, gate.RightTurret, radius, shape, colorRed) + } + } + + return mask +} + +func isBoundaryWallPixel(x, y int, wallMask *PixelMask) bool { + if wallMask == nil || !wallMask.GetXY(x, y) { + return false + } + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + nx, ny := x+dx, y+dy + if !wallMask.InBounds(nx, ny) || !wallMask.GetXY(nx, ny) { + return true + } + } + } + return false +} + +func touchesWater(x, y int, waterMask *PixelMask) bool { + if waterMask == nil { + return false + } + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + nx, ny := x+dx, y+dy + if waterMask.GetXY(nx, ny) { + return true + } + } + } + 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++ { + if shape == "circular" && dx*dx+dy*dy > radius*radius { + continue + } + x, y := center.X+dx, center.Y+dy + if !mask.InBounds(x, y) { + continue + } + mask.SetXY(x, y) + img.Set(x, y, col) + } + } +} + +func nearbyTurretExists(mask *PixelMask, center image.Point, radius int) bool { + if mask == nil { + return false + } + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy > radius*radius { + continue + } + if mask.GetXY(center.X+dx, center.Y+dy) { + return true + } + } + } + return false +} + +// snapPointToWallCenter finds the medial center of the wall at the given hint point. +// It finds the nearest boundary pixel, then walks inward (toward the wall interior) +// to find the midpoint between the two opposite boundary edges — the wall's center line. +// Falls back to snapPointToWall if the wall is too thin to measure. +func snapPointToWallCenter(hint image.Point, wallMask *PixelMask, maxRadius int) (image.Point, bool) { + if wallMask == nil { + return image.Point{}, false + } + + // First, snap hint to a wall pixel at all. + start, ok := snapPointToWall(hint, wallMask, maxRadius) + if !ok { + return image.Point{}, false + } + + // Walk in 8 directions from start to find the two farthest boundary pixels; + // their midpoint is the wall center. + type ray struct{ dx, dy float64 } + rays := []ray{ + {1, 0}, {-1, 0}, {0, 1}, {0, -1}, + {1, 1}, {-1, 1}, {1, -1}, {-1, -1}, + } + + // For each direction, walk until we exit the wall, record the last wall pixel. + wallEdges := make([]image.Point, 0, 8) + for _, r := range rays { + prev := start + for s := 1; s <= maxRadius*2; s++ { + nx := int(math.Round(float64(start.X) + r.dx*float64(s))) + ny := int(math.Round(float64(start.Y) + r.dy*float64(s))) + if !wallMask.InBounds(nx, ny) { + break + } + if !wallMask.GetXY(nx, ny) { + // prev was last wall pixel in this direction + wallEdges = append(wallEdges, prev) + break + } + prev = image.Point{X: nx, Y: ny} + } + } + + if len(wallEdges) < 2 { + return start, true // wall too thin, just use the snapped point + } + + // Average all edge points — this approximates the medial center well enough. + sx, sy := 0, 0 + for _, e := range wallEdges { + sx += e.X + sy += e.Y + } + cx := sx / len(wallEdges) + cy := sy / len(wallEdges) + center := image.Point{X: cx, Y: cy} + + // Make sure the result is actually inside the wall mask. + if wallMask.GetXY(cx, cy) { + return center, true + } + // Snap it back if it drifted outside (can happen on very thin walls). + return snapPointToWall(center, wallMask, max(3, maxRadius/2)) +} + +func snapPointToWall(center image.Point, wallMask *PixelMask, maxRadius int) (image.Point, bool) { + if wallMask == nil { + return image.Point{}, false + } + if wallMask.GetXY(center.X, center.Y) { + return center, true + } + if maxRadius < 1 { + maxRadius = 1 + } + best := image.Point{} + bestD2 := math.MaxInt + found := false + for r := 1; r <= maxRadius; r++ { + minX := center.X - r + maxX := center.X + r + minY := center.Y - r + maxY := center.Y + r + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + if x != minX && x != maxX && y != minY && y != maxY { + continue + } + if !wallMask.GetXY(x, y) { + continue + } + dx := x - center.X + dy := y - center.Y + d2 := dx*dx + dy*dy + if d2 < bestD2 { + bestD2 = d2 + best = image.Point{X: x, Y: y} + found = true + } + } + } + if found { + return best, true + } + } + return image.Point{}, false +} + +func roadGateCentersForRoad(r *Road, layout *FortificationLayout) []image.Point { + out := make([]image.Point, 0, 2) + if r == nil || layout == nil || layout.Mask == nil || len(r.Points) < 2 { + return out + } + prevID := 0 + if layout.Mask.InBounds(r.Points[0].Point.X, r.Points[0].Point.Y) { + prevID = layout.WallIDByPixel[r.Points[0].Point.Y*layout.Mask.Width+r.Points[0].Point.X] + } + for i := 1; i < len(r.Points); i++ { + p := r.Points[i].Point + currID := 0 + if layout.Mask.InBounds(p.X, p.Y) { + currID = layout.WallIDByPixel[p.Y*layout.Mask.Width+p.X] + } + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + out = append(out, p) + } + prevID = currID + } + 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/go.mod b/go.mod index 4ac0080..51a2261 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module rpg_city_maker_reborn -go 1.25.6 +go 1.24.0 require ( fyne.io/fyne/v2 v2.7.2 diff --git a/main.go b/main.go index d3ab2db..f62632f 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,8 @@ func main() { var roadMask *PixelMask var bridgeMask *PixelMask var exitRoadMask *PixelMask + var wallMask *PixelMask + var turretMask *PixelMask // Set up application configuration directory configDir, err := os.UserConfigDir() @@ -231,40 +233,105 @@ func main() { if riverMask != nil { waterMask.Merge(riverMask) } - // Step 4: Generating Roads + // 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 := GenerateRoads(roadBase, settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + 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 - }{rd: rd, br: br, ex: ex, anc: anc} + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + 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 } - // Step 5: Generating Buildings + 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, roadMask, exitRoadMask, seedProvider.Next()) + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) return struct { blds [][]image.Point bmsk *PixelMask @@ -278,10 +345,10 @@ func main() { buildingMask = NewPixelMask(settings.Width, settings.Height) } - // Step 6: Generating Trees + // Step 8: Generating Trees treeBase := cloneToRGBA(finalImage, settings.Width, settings.Height) if out, ok := runWithTimeout(generationStepTimeout, func() *PixelMask { - return GenerateTrees(treeBase, waterMask, roadMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out finalImage = treeBase @@ -296,8 +363,8 @@ func main() { // Step 8: Flattening Building Areas flattenedBuildingHeightmap := FlattenBuildingAreas(darkenedHeightmap.(*image.RGBA), buildings, settings.Width, settings.Height) - // Step 9: Flattening Road Areas - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, roadMask) + // Step 9: Flattening Road and Wall Areas + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) // Step 10: Applying Roughness compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) @@ -613,6 +680,131 @@ func main() { settings.MinRoadAngle = val })) + 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 + })) + + 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 + })) + + 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) + })) + + 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 + })) + + 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 + })) + + showTurretsCheck := widget.NewCheck("Show Turrets", func(v bool) { + settings.ShowTurrets = v + }) + 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 + })) + + turretShapeLabel := widget.NewLabel("Turret Shape:") + turretShapeSelect := widget.NewSelect([]string{"circular", "square"}, func(s string) { + settings.TurretShape = s + }) + 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 + })) + + 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 + })) + + 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() + } + } + // Create UI elements for error display and action buttons errorLabel := widget.NewLabel("") errorLabel.Wrapping = fyne.TextWrapWord @@ -631,7 +823,7 @@ func main() { 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, buildingMask) + showMasksSaveDialog(w, canvasImg.Image, heightmapImg.Image, bumpmapImg.Image, settings, lakes, riverMask, treeMask, roadMask, bridgeMask, wallMask, turretMask, buildingMask) }) // Main generation button and logic generateBtn = widget.NewButton("Generate", func() { @@ -660,16 +852,16 @@ func main() { } // Step 1: Generating Heightmap fyne.Do(func() { - progressLabel.SetText("Step 1 of 11: Generating Heightmap") - progressBar.SetValue(1.0 / 11.0) + 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 11: Generating Lakes") - progressBar.SetValue(2.0 / 11.0) + 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 { @@ -693,8 +885,8 @@ func main() { // Step 3: Generating Rivers fyne.Do(func() { - progressLabel.SetText("Step 3 of 11: Generating Rivers") - progressBar.SetValue(3.0 / 11.0) + progressLabel.SetText("Step 3 of 13: Generating Rivers") + progressBar.SetValue(3.0 / 13.0) }) riverBase := cloneToRGBA(lakeImage, settings.Width, settings.Height) finalImage := riverBase @@ -720,29 +912,94 @@ func main() { waterMask.Merge(riverMask) } - // Step 4: Generating Roads + // Step 4: Preparing Road Nodes fyne.Do(func() { - progressLabel.SetText("Step 4 of 11: Generating Roads") - progressBar.SetValue(4.0 / 11.0) + 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 := GenerateRoads(roadBase, settings.Width, settings.Height, settings, waterMask, seedProvider.Next()) + 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 - }{rd: rd, br: br, ex: ex, anc: anc} + rl []*Road + }{rd: rd, br: br, ex: ex, anc: anc, rl: rl} }); ok { - roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + 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.") @@ -750,21 +1007,32 @@ func main() { 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 5: Generating Buildings + // Step 7: Generating Buildings fyne.Do(func() { - progressLabel.SetText("Step 5 of 11: Generating Buildings") - progressBar.SetValue(5.0 / 11.0) + 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, roadMask, exitRoadMask, seedProvider.Next()) + blds, bmsk := GenerateBuildings(buildingBase, settings.Width, settings.Height, settings, roadAnchors, waterMask, placementMask, exitRoadMask, seedProvider.Next()) return struct { blds [][]image.Point bmsk *PixelMask @@ -779,45 +1047,45 @@ func main() { buildingMask = NewPixelMask(settings.Width, settings.Height) } - // Step 6: Darkening Water Areas + // Step 8: Darkening Water Areas fyne.Do(func() { - progressLabel.SetText("Step 6 of 11: Darkening Water Areas") - progressBar.SetValue(6.0 / 11.0) + progressLabel.SetText("Step 8 of 13: Darkening Water Areas") + progressBar.SetValue(8.0 / 13.0) }) darkenedHeightmap := DarkenLakeAreas(noiseImg, waterMask) - // Step 7: Flattening Building Areas + // Step 9: Flattening Building Areas fyne.Do(func() { - progressLabel.SetText("Step 7 of 11: Flattening Building Areas") - progressBar.SetValue(7.0 / 11.0) + 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 8 of 11: Flattening Road Areas") - progressBar.SetValue(8.0 / 11.0) + progressLabel.SetText("Step 10 of 13: Flattening Road and Wall Areas") + progressBar.SetValue(10.0 / 13.0) }) - flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, roadMask) + flattenedHeightmap := FlattenRoadAreas(flattenedBuildingHeightmap, placementMask) - // Step 8: Applying Roughness + // Step 11: Applying Roughness fyne.Do(func() { - progressLabel.SetText("Step 9 of 11: Applying Roughness") - progressBar.SetValue(9.0 / 11.0) + progressLabel.SetText("Step 11 of 13: Applying Roughness") + progressBar.SetValue(11.0 / 13.0) }) compositeImg := ApplyRoughness(flattenedHeightmap, settings.Roughness) - // Step 9: Generating Trees + // Step 12: Generating Trees fyne.Do(func() { - progressLabel.SetText("Step 10 of 11: Generating Trees") - progressBar.SetValue(10.0 / 11.0) + 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, roadMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) + return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out finalImage = treeBase @@ -827,10 +1095,10 @@ func main() { treeMask = NewPixelMask(settings.Width, settings.Height) } - // Step 10: Generating Bump Map + // Step 13: Generating Bump Map fyne.Do(func() { - progressLabel.SetText("Step 11 of 11: Generating Bump Map") + progressLabel.SetText("Step 13 of 13: Generating Bump Map") progressBar.SetValue(1.0) }) bumpMap := GenerateBumpMap(compositeImg.(*image.RGBA), settings.Width, settings.Height, 0.10) @@ -942,6 +1210,17 @@ func main() { 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) { @@ -1219,6 +1498,7 @@ func main() { terrainTab, waterTab, roadsTab, + fortificationsTab, buildingsTab, ) @@ -1303,7 +1583,7 @@ func encodeImageToWriter(w io.Writer, img image.Image, format string) error { } // 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, buildingMask *PixelMask) { +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") @@ -1387,6 +1667,8 @@ func showMasksSaveDialog(win fyne.Window, canvasImg, heightmapImg, bumpmapImg im {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) }}, } diff --git a/mask.go b/mask.go index 151aed0..b8018c5 100644 --- a/mask.go +++ b/mask.go @@ -41,6 +41,12 @@ func (m *PixelMask) SetXY(x, y int) { } } +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) } diff --git a/roads.go b/roads.go index b9df1ce..197fc7e 100644 --- a/roads.go +++ b/roads.go @@ -88,6 +88,42 @@ func GenerateRoads( waterMask *PixelMask, seed int64, ) (*PixelMask, *PixelMask, *PixelMask, []image.Point) { + roadMask, bridgeMask, exitRoadMask, roadAnchors, _ := GenerateRoadsWithPOIs(img, width, height, settings, waterMask, nil, nil, 0, false, seed) + return roadMask, bridgeMask, exitRoadMask, roadAnchors +} + +func PrepareRoadNodes(width, height int, settings *Settings, waterMask *PixelMask, seed int64) ([]*PointOfInterest, int, bool) { + randSrc := rand.New(rand.NewSource(seed)) + + if settings.NumBuildings == 0 { + internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100))) + exitRoads := max(0, settings.RoadExits) + if internalRoads == 0 && exitRoads > 0 && settings.RoadDistribution <= 0 { + return nil, 0, true + } + if internalRoads > 0 { + roadTarget := internalRoads + return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false + } + return nil, 0, false + } + + roadTarget := estimateRoadTarget(settings) + return generatePOIs(width, height, settings, waterMask, randSrc, roadTarget), roadTarget, false +} + +func GenerateRoadsWithPOIs( + img *image.RGBA, + width, + height int, + settings *Settings, + waterMask *PixelMask, + wallLayout *FortificationLayout, + pois []*PointOfInterest, + roadTarget int, + edgeToEdgeOnly bool, + seed int64, +) (*PixelMask, *PixelMask, *PixelMask, []image.Point, []*Road) { if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } @@ -95,65 +131,62 @@ func GenerateRoads( roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255} bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255} + if len(pois) > 0 && wallLayout != nil && wallLayout.Mask != nil { + nudgePOIsOutsideWalls(pois, wallLayout.Mask, waterMask, settings, width, height, randSrc) + } + // Edge-case mode: no buildings. - if settings.NumBuildings == 0 { + if settings.NumBuildings == 0 && roadTarget == 0 && !edgeToEdgeOnly { internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100))) exitRoads := max(0, settings.RoadExits) if internalRoads == 0 && exitRoads == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } - - var roads []*Road if internalRoads > 0 { - roadTarget := internalRoads - pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) - if len(pois) >= 2 { - roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, roadTarget) - // Use existing exit-road logic when internal roads are present. - roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask) - } + roadTarget = internalRoads } else if settings.RoadDistribution <= 0 && exitRoads > 0 { - // Only in 0% distribution mode: exit roads are edge-to-edge. - roads = generateEdgeToEdgeExitRoads(exitRoads, width, height, settings, randSrc, waterMask) + edgeToEdgeOnly = true } - - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil - } - roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) - if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil - } - assignRoadWidths(roads, settings, randSrc, width, height) - - roadMask := NewPixelMask(width, height) - bridgeMask := NewPixelMask(width, height) - exitRoadMask := NewPixelMask(width, height) - for _, road := range roads { - drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, roadMask, bridgeMask) - if road.Start.IsExit || road.End.IsExit { - drawRoadToMasks(img, road.Points, roadColor, bridgeColor, road.Width, exitRoadMask, exitRoadMask) - } - } - return roadMask, bridgeMask, exitRoadMask, roadMask.ToPoints() } - roadTarget := estimateRoadTarget(settings) - pois := generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) - if len(pois) < 2 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + var roads []*Road + if edgeToEdgeOnly { + roads = generateEdgeToEdgeExitRoads(max(0, settings.RoadExits), width, height, settings, randSrc, waterMask, wallLayout) + } else { + if roadTarget <= 0 { + roadTarget = estimateRoadTarget(settings) + } + if pois == nil { + pois = generatePOIs(width, height, settings, waterMask, randSrc, roadTarget) + } + if len(pois) < 2 { + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil + } + roads = connectPOIs(pois, width, height, settings, randSrc, waterMask, wallLayout, roadTarget) + roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask, wallLayout) } - roads := connectPOIs(pois, width, height, settings, randSrc, waterMask, roadTarget) - roads = appendExitRoads(roads, pois, width, height, settings, randSrc, waterMask) if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } + roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) + if len(roads) == 0 { + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil + } + + // Add gate roads after wall-crossing rules (so they are never filtered out). + if wallLayout != nil && len(wallLayout.Gates) > 0 { + gateRoads := generateGateRoads(wallLayout, settings, waterMask, width, height, randSrc) + roads = append(roads, gateRoads...) + roads = ensureGateRoadConnections(gateRoads, roads, wallLayout, settings, waterMask, width, height, randSrc) + } + roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) if len(roads) == 0 { - return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } - assignRoadWidths(roads, settings, randSrc, width, height) + roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) + assignRoadWidths(roads, settings, randSrc, width, height, wallLayout) roadMask := NewPixelMask(width, height) bridgeMask := NewPixelMask(width, height) @@ -166,10 +199,91 @@ func GenerateRoads( } roadAnchors := roadMask.ToPoints() - return roadMask, bridgeMask, exitRoadMask, roadAnchors + return roadMask, bridgeMask, exitRoadMask, roadAnchors, roads } -func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask) []*Road { +func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMask, settings *Settings, width, height int, randSrc *rand.Rand) { + if len(pois) == 0 || wallMask == nil { + return + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + + // Build exclusion zone: wall pixels dilated by one road width. + // POIs must be outside this zone so roads have room to run parallel to walls. + fakeLayout := &FortificationLayout{Mask: wallMask} + exclusion := buildWallExclusionMask(fakeLayout, settings, width, height) + centerX := float64(width-1) * 0.5 + centerY := float64(height-1) * 0.5 + + for _, p := range pois { + if p == nil { + continue + } + if !exclusion.GetXY(p.X, p.Y) { + continue + } + + vx := float64(p.X) - centerX + vy := float64(p.Y) - centerY + vlen := math.Hypot(vx, vy) + if vlen < 0.001 { + theta := randSrc.Float64() * 2 * math.Pi + vx = math.Cos(theta) + vy = math.Sin(theta) + vlen = 1 + } + dx := vx / vlen + dy := vy / vlen + + moved := false + maxSteps := exclusion.Width + exclusion.Height + for step := 1; step <= maxSteps; step++ { + nx := int(math.Round(float64(p.X) + float64(step)*dx)) + ny := int(math.Round(float64(p.Y) + float64(step)*dy)) + if nx < 0 || ny < 0 || nx >= width || ny >= height { + break + } + if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { + continue + } + p.X = nx + p.Y = ny + moved = true + break + } + if moved { + continue + } + + baseAngle := math.Atan2(dy, dx) + for a := -6; a <= 6; a++ { + ang := baseAngle + float64(a)*math.Pi/18.0 + adx := math.Cos(ang) + ady := math.Sin(ang) + for step := 1; step <= exclusion.Width+exclusion.Height; step++ { + nx := int(math.Round(float64(p.X) + float64(step)*adx)) + ny := int(math.Round(float64(p.Y) + float64(step)*ady)) + if nx < 0 || ny < 0 || nx >= width || ny >= height { + break + } + if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { + continue + } + p.X = nx + p.Y = ny + moved = true + break + } + if moved { + break + } + } + } +} + +func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road { if exitRoads <= 0 { return nil } @@ -179,7 +293,7 @@ func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Setting start, end := sampleDifferentEdgePair(width, height, randSrc) start.IsExit = true end.IsExit = true - path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) + path := calculateRoadPath(start, end, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) roads = append(roads, &Road{ Start: start, End: end, @@ -367,7 +481,7 @@ func sampleTargetDegree(randSrc *rand.Rand) int { } } -func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, roadTarget int) []*Road { +func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, roadTarget int) []*Road { minAngle := settings.MinRoadAngle * math.Pi / 180.0 if minAngle < 0 { minAngle = 0 @@ -512,7 +626,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, for _, e := range selectedEdges { a := pois[e.a] b := pois[e.b] - path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) + path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) imp := a.Connections + b.Connections + int(math.Round((a.ArterialWeight+b.ArterialWeight)*4)) roads = append(roads, &Road{Start: a, End: b, Points: path, Importance: imp}) } @@ -520,7 +634,7 @@ func connectPOIs(pois []*PointOfInterest, width, height int, settings *Settings, return roads } -func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask) []*Road { +func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road { if settings.RoadExits <= 0 || len(pois) == 0 { return roads } @@ -540,12 +654,38 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, continue } + path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(path, wallLayout)) == 0 { + bestScore := -1.0 + bestAnchor := anchor + bestPath := path + for _, cand := range pois { + testPath := calculateRoadPath(cand, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + if len(crossedWallIDs(testPath, wallLayout)) == 0 { + continue + } + d := math.Hypot(float64(cand.X-edgeNode.X), float64(cand.Y-edgeNode.Y)) + score := cand.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1) + if score > bestScore { + bestScore = score + bestAnchor = cand + bestPath = testPath + } + } + anchor = bestAnchor + path = bestPath + } + if wallLayout != nil && wallLayout.Mask != nil && len(wallLayout.Coverages) > 0 { + // Exit roads always use the gate-cheat when walls exist so they are always placeable. + if forced, ok := forcePathThroughWallGate(anchor, edgeNode, wallLayout, waterMask); ok { + path = forced + } + } + anchor.Connections++ edgeNode.IsExit = true edgeNode.TargetDegree = 1 edgeNode.Connections = 1 - - path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask) importance := anchor.Connections + edgeNode.Connections + int(math.Round(anchor.ArterialWeight*3)) roads = append(roads, &Road{ Start: anchor, @@ -561,6 +701,106 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, return roads } +func forcePathThroughWallGate(start, end *PointOfInterest, wallLayout *FortificationLayout, waterMask *PixelMask) ([]PathPoint, bool) { + if start == nil || end == nil || wallLayout == nil || wallLayout.Mask == nil { + return nil, false + } + mid, ok := nearestWallPixelToSegment(image.Point{X: start.X, Y: start.Y}, image.Point{X: end.X, Y: end.Y}, wallLayout.Mask) + if !ok { + return nil, false + } + tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) + if !ok { + return nil, false + } + nx, ny := -ty, tx + rx := float64(end.X - start.X) + ry := float64(end.Y - start.Y) + if rx*nx+ry*ny < 0 { + nx, ny = -nx, -ny + } + left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) + right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) + if !lok || !rok || left == right { + return nil, false + } + + startPt := image.Point{X: start.X, Y: start.Y} + endPt := image.Point{X: end.X, Y: end.Y} + entry, exit := left, right + d1 := sqDist(startPt, left) + sqDist(endPt, right) + d2 := sqDist(startPt, right) + sqDist(endPt, left) + if d2 < d1 { + entry, exit = right, left + } + + seg1 := bresenhamRoad([]image.Point{startPt, entry}) + seg2 := bresenhamRoad([]image.Point{entry, exit}) + seg3 := bresenhamRoad([]image.Point{exit, endPt}) + out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) + appendDedup := func(seg []image.Point) { + for _, p := range seg { + if len(out) > 0 && out[len(out)-1] == p { + continue + } + out = append(out, p) + } + } + appendDedup(seg1) + appendDedup(seg2) + appendDedup(seg3) + return toPathPoints(out, waterMask), true +} + +func nearestWallPixelToSegment(a, b image.Point, wallMask *PixelMask) (image.Point, bool) { + if wallMask == nil || wallMask.Width <= 0 || wallMask.Height <= 0 { + return image.Point{}, false + } + best := image.Point{} + bestD2 := math.MaxFloat64 + found := false + for y := 0; y < wallMask.Height; y++ { + row := y * wallMask.Width + for x := 0; x < wallMask.Width; x++ { + if wallMask.Data[row+x] == 0 { + continue + } + d2 := pointSegmentDistanceSquared(float64(x), float64(y), float64(a.X), float64(a.Y), float64(b.X), float64(b.Y)) + if d2 < bestD2 { + bestD2 = d2 + best = image.Point{X: x, Y: y} + found = true + } + } + } + return best, found +} + +func pointSegmentDistanceSquared(px, py, ax, ay, bx, by float64) float64 { + abx := bx - ax + aby := by - ay + apx := px - ax + apy := py - ay + den := abx*abx + aby*aby + if den <= 1e-9 { + dx := px - ax + dy := py - ay + return dx*dx + dy*dy + } + t := (apx*abx + apy*aby) / den + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + cx := ax + t*abx + cy := ay + t*aby + dx := px - cx + dy := py - cy + return dx*dx + dy*dy +} + func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *PixelMask, used []image.Point) (*PointOfInterest, bool) { minSpacing := math.Min(float64(width), float64(height)) * 0.08 minSpacing2 := minSpacing * minSpacing @@ -676,7 +916,7 @@ func normalizeAngle(a float64) float64 { return a } -func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int) { +func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int, wallLayout *FortificationLayout) { if len(roads) == 0 { return } @@ -742,6 +982,13 @@ func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, wid for i, r := range roads { w := clamp(widths[i], minWidth, maxWidth) + if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(r.Points, wallLayout)) > 0 { + // Wall-gate roads should be visibly substantial. + minGateWidth := minWidth + 0.55*(maxWidth-minWidth) + if w < minGateWidth { + w = minGateWidth + } + } r.Width = max(1, int(math.Round(w))) } } @@ -821,7 +1068,7 @@ func bresenhamRoad(path []image.Point) []image.Point { } // calculateRoadPath computes the path for a road including curves and bridges. -func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask) []PathPoint { +func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []PathPoint { dx := end.X - start.X dy := end.Y - start.Y dist := math.Hypot(float64(dx), float64(dy)) @@ -834,14 +1081,14 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r curve := clamp(curvyness, 0, 1) if curve <= 0 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } // Non-linear scaling: low values stay fairly straight, high values become very winding. strength := math.Pow(curve, 1.35) if strength < 0.001 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } baseControls := int(math.Max(12, dist/(22.0-14.0*strength))) @@ -902,17 +1149,472 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r } points := bresenhamRoad(controlPoints) - return toPathPoints(points, waterMask) + return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) } func toPathPoints(points []image.Point, waterMask *PixelMask) []PathPoint { pathPoints := make([]PathPoint, len(points)) for i, p := range points { - pathPoints[i] = PathPoint{Point: p, IsBridge: waterMask.GetPoint(p)} + isBridge := false + if waterMask != nil { + isBridge = waterMask.GetPoint(p) + } + pathPoints[i] = PathPoint{Point: p, IsBridge: isBridge} } return pathPoints } +func wallIDAtPoint(p image.Point, wallLayout *FortificationLayout) int { + if wallLayout == nil || wallLayout.Mask == nil { + return 0 + } + if !wallLayout.Mask.InBounds(p.X, p.Y) { + return 0 + } + if len(wallLayout.WallIDByPixel) != wallLayout.Mask.Width*wallLayout.Mask.Height { + return 0 + } + return wallLayout.WallIDByPixel[p.Y*wallLayout.Mask.Width+p.X] +} + +func straightenPathAcrossWalls(points []PathPoint, wallLayout *FortificationLayout, waterMask *PixelMask) []PathPoint { + if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { + return points + } + + straight := make([]image.Point, 0, len(points)) + i := 0 + for i < len(points) { + curr := points[i].Point + currWallID := wallIDAtPoint(curr, wallLayout) + if currWallID == 0 { + straight = append(straight, curr) + i++ + continue + } + + start := i + if start > 0 { + start-- + } + j := i + for j < len(points) && wallIDAtPoint(points[j].Point, wallLayout) != 0 { + j++ + } + end := j + if end >= len(points) { + end = len(points) - 1 + } + line := enforcePerpendicularWallCrossing(points, start, i, j, end, wallLayout) + for k, p := range line { + if len(straight) > 0 && k == 0 && straight[len(straight)-1] == p { + continue + } + straight = append(straight, p) + } + i = j + } + + return toPathPoints(straight, waterMask) +} + +func enforcePerpendicularWallCrossing(points []PathPoint, start, wallStart, wallEnd, end int, wallLayout *FortificationLayout) []image.Point { + startPt := points[start].Point + endPt := points[end].Point + baseLine := bresenhamRoad([]image.Point{startPt, endPt}) + if wallLayout == nil || wallLayout.Mask == nil { + return baseLine + } + if wallStart < 0 || wallEnd <= wallStart || wallEnd > len(points) { + return baseLine + } + + mid := points[wallStart+(wallEnd-wallStart)/2].Point + tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) + if !ok { + return baseLine + } + rx := float64(endPt.X - startPt.X) + ry := float64(endPt.Y - startPt.Y) + if crossingAngleToTangentDegrees(rx, ry, tx, ty) >= 75.0 { + return baseLine + } + + // Build a forced gate across the wall: one anchor just outside each side of the wall. + nx, ny := -ty, tx + vdot := rx*nx + ry*ny + if vdot < 0 { + nx, ny = -nx, -ny + } + left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) + right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) + if !lok || !rok || left == right { + return baseLine + } + + entry, exit := left, right + d1 := sqDist(startPt, left) + sqDist(endPt, right) + d2 := sqDist(startPt, right) + sqDist(endPt, left) + if d2 < d1 { + entry, exit = right, left + } + + seg1 := bresenhamRoad([]image.Point{startPt, entry}) + seg2 := bresenhamRoad([]image.Point{entry, exit}) + seg3 := bresenhamRoad([]image.Point{exit, endPt}) + out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) + appendDedup := func(seg []image.Point) { + for _, p := range seg { + if len(out) > 0 && out[len(out)-1] == p { + continue + } + out = append(out, p) + } + } + appendDedup(seg1) + appendDedup(seg2) + appendDedup(seg3) + 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) + if rn < 0.001 || tn < 0.001 { + return 90 + } + dot := (rx*tx + ry*ty) / (rn * tn) + if dot < -1 { + dot = -1 + } + if dot > 1 { + dot = 1 + } + ang := math.Acos(math.Abs(dot)) * 180.0 / math.Pi + return ang +} + +func walkToOutsideWall(mid image.Point, dx, dy float64, wallMask *PixelMask) (image.Point, bool) { + if wallMask == nil { + return image.Point{}, false + } + maxSteps := max(8, (wallMask.Width+wallMask.Height)/12) + for s := 1; s <= maxSteps; s++ { + x := int(math.Round(float64(mid.X) + dx*float64(s))) + y := int(math.Round(float64(mid.Y) + dy*float64(s))) + if x < 0 || y < 0 || x >= wallMask.Width || y >= wallMask.Height { + return image.Point{}, false + } + if !wallMask.GetXY(x, y) { + return image.Point{X: x, Y: y}, true + } + } + return image.Point{}, false +} + +func sqDist(a, b image.Point) int { + dx := a.X - b.X + dy := a.Y - b.Y + return dx*dx + dy*dy +} + +func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int { + if wallLayout == nil || wallLayout.Mask == nil || len(points) == 0 { + return nil + } + seen := make(map[int]bool) + out := make([]int, 0, 2) + prevID := wallIDAtPoint(points[0].Point, wallLayout) + for i := 1; i < len(points); i++ { + currID := wallIDAtPoint(points[i].Point, wallLayout) + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + wid := currID + if wid == 0 { + wid = prevID + } + if wid > 0 && !seen[wid] { + seen[wid] = true + out = append(out, wid) + } + } + prevID = currID + } + return out +} + +func containsWallID(ids []int, wallID int) bool { + for _, id := range ids { + if id == wallID { + return true + } + } + return false +} + +func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, waterMask *PixelMask, randSrc *rand.Rand) []*Road { + if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil || len(wallLayout.Coverages) == 0 { + return roads + } + + // Straighten each wall crossing segment first. + for _, road := range roads { + road.Points = straightenPathAcrossWalls(road.Points, wallLayout, waterMask) + } + + type roadInfo struct { + road *Road + ids []int + } + infos := make([]roadInfo, 0, len(roads)) + for _, road := range roads { + infos = append(infos, roadInfo{road: road, ids: crossedWallIDs(road.Points, wallLayout)}) + } + + const repeatWallFactor = 0.55 + wallCrossCount := make(map[int]int) + requiredWalls := make(map[int]bool) + for i, cov := range wallLayout.Coverages { + if cov < 95 { + requiredWalls[i+1] = true + } + } + + keep := make([]bool, len(infos)) + for i, info := range infos { + if len(info.ids) == 0 { + keep[i] = true + continue + } + if info.road.Start.IsExit || info.road.End.IsExit { + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + continue + } + if crossesSameWallMultipleTimes(info.road.Points, wallLayout) { + keep[i] = false + continue + } + + keepProb := 1.0 + for _, wid := range info.ids { + c := wallCrossCount[wid] + if c > 0 { + keepProb *= math.Pow(repeatWallFactor, float64(c)) + } + } + if randSrc.Float64() <= keepProb { + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + } + } + + // Ensure at least one crossing on each wall unless its configured coverage is >= 95%. + for wallID := range requiredWalls { + if wallCrossCount[wallID] > 0 { + continue + } + for i, info := range infos { + if keep[i] { + continue + } + if !containsWallID(info.ids, wallID) { + continue + } + keep[i] = true + for _, wid := range info.ids { + wallCrossCount[wid]++ + } + break + } + } + + filtered := make([]*Road, 0, len(roads)) + for i, info := range infos { + if keep[i] { + filtered = append(filtered, info.road) + } + } + return filtered +} + +func crossesSameWallMultipleTimes(points []PathPoint, wallLayout *FortificationLayout) bool { + if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { + return false + } + transitionCount := make(map[int]int) + prevID := wallIDAtPoint(points[0].Point, wallLayout) + for i := 1; i < len(points); i++ { + currID := wallIDAtPoint(points[i].Point, wallLayout) + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + wid := currID + if wid == 0 { + wid = prevID + } + if wid > 0 { + transitionCount[wid]++ + // More than two transitions means re-crossing the same wall. + if transitionCount[wid] > 2 { + return true + } + } + } + prevID = currID + } + return false +} + +func ensureRoadNetworkConnected(roads []*Road, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, width, height int) []*Road { + if len(roads) <= 1 { + return roads + } + + avgDim := float64(width+height) / 2.0 + const maxConnectorAttempts = 32 + + for attempts := 0; attempts < maxConnectorAttempts; attempts++ { + nodeIndex := make(map[*PointOfInterest]int) + nodes := make([]*PointOfInterest, 0, len(roads)*2) + getNodeID := func(p *PointOfInterest) int { + if id, ok := nodeIndex[p]; ok { + return id + } + id := len(nodes) + nodeIndex[p] = id + nodes = append(nodes, p) + return id + } + adj := make([][]int, 0, len(roads)*2) + ensureAdj := func(n int) { + for len(adj) <= n { + adj = append(adj, nil) + } + } + for _, r := range roads { + a := getNodeID(r.Start) + b := getNodeID(r.End) + ensureAdj(a) + ensureAdj(b) + adj[a] = append(adj[a], b) + adj[b] = append(adj[b], a) + } + + compID := make([]int, len(nodes)) + for i := range compID { + compID[i] = -1 + } + compCount := 0 + queue := make([]int, 0, len(nodes)) + for i := 0; i < len(nodes); i++ { + if compID[i] != -1 { + continue + } + compID[i] = compCount + queue = queue[:0] + queue = append(queue, i) + for h := 0; h < len(queue); h++ { + cur := queue[h] + for _, nb := range adj[cur] { + if compID[nb] != -1 { + continue + } + compID[nb] = compCount + queue = append(queue, nb) + } + } + compCount++ + } + if compCount <= 1 { + return roads + } + + bestA, bestB := -1, -1 + bestDist2 := math.MaxFloat64 + for i := 0; i < len(nodes); i++ { + for j := i + 1; j < len(nodes); j++ { + if compID[i] == compID[j] { + continue + } + dx := float64(nodes[i].X - nodes[j].X) + dy := float64(nodes[i].Y - nodes[j].Y) + d2 := dx*dx + dy*dy + if d2 < bestDist2 { + bestDist2 = d2 + bestA, bestB = i, j + } + } + } + if bestA == -1 || bestB == -1 { + return roads + } + + a := nodes[bestA] + b := nodes[bestB] + a.Connections++ + b.Connections++ + path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + roads = append(roads, &Road{ + Start: a, + End: b, + Points: path, + Importance: a.Connections + b.Connections + 2, + }) + } + + return roads +} + // drawLineMasked draws a line with specified width on the image and mask. func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int, mask *PixelMask) { dx := abs(x1 - x0) @@ -1064,6 +1766,199 @@ func bridgedRegionIDs(points []PathPoint, regionByPixel []int, width, height int return out } +// buildWallExclusionMask creates a mask of wall pixels dilated by one road width. +// Roads will avoid pixels set in this mask (except at gates). +func buildWallExclusionMask(wallLayout *FortificationLayout, settings *Settings, width, height int) *PixelMask { + if wallLayout == nil || wallLayout.Mask == nil { + return NewPixelMask(width, height) + } + _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) + margin := int(math.Ceil(maxRoadPx)) + if margin < 1 { + margin = 1 + } + out := NewPixelMask(width, height) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + if !wallLayout.Mask.GetXY(x, y) { + continue + } + for dy := -margin; dy <= margin; dy++ { + for dx := -margin; dx <= margin; dx++ { + if dx*dx+dy*dy <= margin*margin { + out.SetXY(x+dx, y+dy) + } + } + } + } + } + return out +} + +// generateGateRoads creates one straight perpendicular road per gate. +// Each road runs from the outer end to the inner end of the gate, crossing the wall gap. +// It also creates POIs at inner/outer ends so the road network can connect to them. +func generateGateRoads(wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road { + if wallLayout == nil || len(wallLayout.Gates) == 0 { + return nil + } + _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) + roadWidth := int(math.Round(maxRoadPx + 0.5*(maxRoadPx))) + if roadWidth < 1 { + roadWidth = 1 + } + + roads := make([]*Road, 0, len(wallLayout.Gates)) + for _, gate := range wallLayout.Gates { + // Straight line from outerEnd to innerEnd — do NOT route through gateCenter + // (which is a wall boundary pixel and causes a kink in the road). + outer := &PointOfInterest{X: gate.OuterEnd.X, Y: gate.OuterEnd.Y, IsExit: false} + inner := &PointOfInterest{X: gate.InnerEnd.X, Y: gate.InnerEnd.Y, IsExit: false} + outer.Connections = 1 + inner.Connections = 1 + + pts := bresenhamRoad([]image.Point{gate.OuterEnd, gate.InnerEnd}) + path := toPathPoints(pts, waterMask) + + roads = append(roads, &Road{ + Start: outer, + End: inner, + Points: path, + Width: roadWidth, + Importance: 10, // high importance so gate roads get wide treatment + }) + } + return roads +} + +// ensureGateRoadConnections adds short connector roads from each gate's inner/outer +// endpoints to the nearest existing road POI, so the gate road is part of the network. +func ensureGateRoadConnections(gateRoads []*Road, allRoads []*Road, wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road { + if len(gateRoads) == 0 || wallLayout == nil { + return allRoads + } + + // Collect non-gate POIs. + poiSet := make(map[*PointOfInterest]bool) + for _, r := range allRoads { + if r.Start != nil { + poiSet[r.Start] = true + } + if r.End != nil { + poiSet[r.End] = true + } + } + // Remove gate road endpoints from the non-gate set. + for _, r := range gateRoads { + delete(poiSet, r.Start) + delete(poiSet, r.End) + } + pois := make([]*PointOfInterest, 0, len(poiSet)) + for p := range poiSet { + pois = append(pois, p) + } + + connectors := make([]*Road, 0, len(gateRoads)*2) + _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) + connW := int(math.Round(maxRoadPx)) + if connW < 1 { + connW = 1 + } + + // pathCrossesWall returns true if a straight Bresenham line from a to b touches any wall pixel. + pathCrossesWall := func(a, b image.Point) bool { + dx := abs(b.X - a.X) + dy := abs(b.Y - a.Y) + sx := -1 + if a.X < b.X { + sx = 1 + } + sy := -1 + if a.Y < b.Y { + sy = 1 + } + err := dx - dy + x, y := a.X, a.Y + for { + if wallLayout.Mask.GetXY(x, y) { + return true + } + if x == b.X && y == b.Y { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x += sx + } + if e2 < dx { + err += dx + y += sy + } + } + return false + } + + for _, gr := range gateRoads { + for _, ep := range []*PointOfInterest{gr.Start, gr.End} { + if len(pois) == 0 { + break + } + epPt := image.Point{X: ep.X, Y: ep.Y} + + // Find nearest POI reachable without crossing any wall. + var best *PointOfInterest + bestD2 := math.MaxFloat64 + for _, p := range pois { + if wallLayout.Mask.GetXY(p.X, p.Y) { + continue + } + pPt := image.Point{X: p.X, Y: p.Y} + if pathCrossesWall(epPt, pPt) { + continue + } + dx := float64(p.X - ep.X) + dy := float64(p.Y - ep.Y) + d2 := dx*dx + dy*dy + if d2 < bestD2 { + bestD2 = d2 + best = p + } + } + // Fallback: if no wall-safe POI found, take the nearest regardless. + if best == nil { + for _, p := range pois { + if wallLayout.Mask.GetXY(p.X, p.Y) { + continue + } + dx := float64(p.X - ep.X) + dy := float64(p.Y - ep.Y) + d2 := dx*dx + dy*dy + if d2 < bestD2 { + bestD2 = d2 + best = p + } + } + } + if best == nil { + continue + } + pts := bresenhamRoad([]image.Point{epPt, {X: best.X, Y: best.Y}}) + path := toPathPoints(pts, waterMask) + ep.Connections++ + best.Connections++ + connectors = append(connectors, &Road{ + Start: ep, + End: best, + Points: path, + Width: connW, + Importance: 6, + }) + } + } + return append(allRoads, connectors...) +} + func clamp(v, lo, hi float64) float64 { if v < lo { return lo diff --git a/settings.go b/settings.go index d8c1839..f38765f 100644 --- a/settings.go +++ b/settings.go @@ -44,6 +44,18 @@ type Settings struct { RoadDistribution float64 `json:"road_distribution"` MinRoadAngle float64 `json:"min_road_angle"` + // Fortification settings + MinWallWidth float64 `json:"min_wall_width"` + MaxWallWidth float64 `json:"max_wall_width"` + NumWalls int `json:"num_walls"` + CityCoverage float64 `json:"city_coverage"` + WallCurvyness float64 `json:"wall_curvyness"` + ShowTurrets bool `json:"show_turrets"` + TurretSize float64 `json:"turret_size"` + TurretShape string `json:"turret_shape"` + TurretSpacing float64 `json:"turret_spacing"` + GateSpacing float64 `json:"gate_spacing"` // percent of wall circumference between gates (0=no gates) + // Building settings NumBuildings int `json:"num_buildings"` MinBuildingSize float64 `json:"min_building_size"` @@ -139,6 +151,16 @@ func LoadSettings() (*Settings, error) { RoadCurvyness: 50, RoadDistribution: 50, MinRoadAngle: 18, + MinWallWidth: 1.5, + MaxWallWidth: 4.0, + NumWalls: 1, + CityCoverage: 70, + WallCurvyness: 35, + ShowTurrets: true, + TurretSize: 0.6, + TurretShape: "circular", + TurretSpacing: 55, + GateSpacing: 25, NumBuildings: 200, MinBuildingSize: 3.5, MaxBuildingSize: 10.0, @@ -200,9 +222,61 @@ func LoadSettings() (*Settings, error) { if settings.BuildingsPerRoad == 0 { settings.BuildingsPerRoad = 6 } + if _, ok := rawKeys["min_wall_width"]; !ok { + settings.MinWallWidth = 1.5 + } + if _, ok := rawKeys["max_wall_width"]; !ok { + settings.MaxWallWidth = 4.0 + } + if _, ok := rawKeys["num_walls"]; !ok { + settings.NumWalls = 1 + } + if settings.CityCoverage == 0 { + settings.CityCoverage = 70 + } if _, ok := rawKeys["min_road_angle"]; !ok { settings.MinRoadAngle = 18 } + if _, ok := rawKeys["wall_curvyness"]; !ok { + settings.WallCurvyness = 35 + } + if _, ok := rawKeys["show_turrets"]; !ok { + settings.ShowTurrets = true + } + if _, ok := rawKeys["turret_size"]; !ok { + settings.TurretSize = 0.6 + } + if _, ok := rawKeys["turret_shape"]; !ok || (settings.TurretShape != "square" && settings.TurretShape != "circular") { + settings.TurretShape = "circular" + } + if _, ok := rawKeys["turret_spacing"]; !ok { + settings.TurretSpacing = 55 + } + if _, ok := rawKeys["gate_spacing"]; !ok { + settings.GateSpacing = 25 + } + + // Wall widths are percentages of average image dimension. + // Migrate older pixel-based values when they exceed the valid percentage range. + if settings.MinWallWidth > maxWallWidthPercent || settings.MaxWallWidth > maxWallWidthPercent { + avgDim := averageImageDimension(settings.Width, settings.Height) + if avgDim < 1 { + avgDim = 1 + } + settings.MinWallWidth = (settings.MinWallWidth / avgDim) * 100.0 + settings.MaxWallWidth = (settings.MaxWallWidth / avgDim) * 100.0 + } + settings.MinWallWidth, settings.MaxWallWidth = normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) + if settings.NumWalls < 0 { + settings.NumWalls = 0 + } + if settings.NumWalls > 5 { + settings.NumWalls = 5 + } + settings.CityCoverage = clamp(settings.CityCoverage, 1, 100) + settings.WallCurvyness = clamp(settings.WallCurvyness, 0, 100) + settings.TurretSize = snapTurretSizePercent(settings.TurretSize) + settings.TurretSpacing = clamp(settings.TurretSpacing, 0, 100) // Tree sizes are percentages of average image dimension. // Migrate older pixel-based values when they exceed the valid percentage range.