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 one traversable gate cut through a wall ring. type GateInfo struct { WallID int Center image.Point Normal [2]float64 LeftTurret image.Point RightTurret image.Point InnerEnd image.Point OuterEnd image.Point } // FortificationLayout holds the final fortification geometry and masks. type FortificationLayout struct { Mask *PixelMask WallIDByPixel []int Coverages []float64 Gates []GateInfo GateMask *PixelMask Turrets []TurretPlacement } // TurretPlacement describes one turret centered on a wall. type TurretPlacement struct { WallID int Center image.Point Angle float64 IsGate bool IsWater bool } type wallSample struct { Point image.Point Angle float64 RunID int Pos int Index int } // GenerateFortifications builds wall geometry, gate openings, and turret placements. 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), GateMask: NewPixelMask(width, height), } if settings == nil || settings.NumWalls <= 0 || settings.CityCoverage <= 0 || width <= 0 || height <= 0 { return layout, nil } if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } if waterMask == nil { waterMask = NewPixelMask(width, height) } randSrc := rand.New(rand.NewSource(seed)) minWallWidthPx, maxWallWidthPx := getWallWidthRangePixels(settings, width, height) outerCoverage := clamp(settings.CityCoverage, 1, 100) totalWalls := max(1, settings.NumWalls) layout.Coverages = make([]float64, 0, totalWalls) for wallIndex := 0; wallIndex < totalWalls; wallIndex++ { coverage := outerCoverage * float64(totalWalls-wallIndex) / float64(totalWalls) coverage = clamp(coverage, 1, 100) layout.Coverages = append(layout.Coverages, coverage) nodes := estimateWallNodeCount(coverage) loop := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes) if len(loop) < 3 { continue } wallWidthPx := minWallWidthPx if maxWallWidthPx > minWallWidthPx { wallWidthPx += randSrc.Float64() * (maxWallWidthPx - minWallWidthPx) } wallWidth := max(1, int(math.Round(wallWidthPx))) wallID := wallIndex + 1 runs := splitWallPathByWater(loop, waterMask) if len(runs) == 0 { continue } samples := rasterizeWallRuns(layout, runs, wallWidth, wallID) if len(samples) == 0 { continue } center := averagePoint(samplesToPoints(samples)) turretSizePx := getTurretSizePixels(settings, width, height) gates, gateSampleIndexes := buildGatesForWall(layout, settings, samples, center, wallIndex, wallID, wallWidth, turretSizePx, width, height) layout.Gates = append(layout.Gates, gates...) layout.Turrets = append(layout.Turrets, buildWaterEndpointTurrets(samples, wallID)...) layout.Turrets = append(layout.Turrets, buildGateTurrets(samples, gates, wallID)...) layout.Turrets = append(layout.Turrets, buildNaturalTurrets(settings, samples, gateSampleIndexes, coverage, outerCoverage, wallID)...) } layout.Turrets = dedupeTurretPlacements(layout.Turrets, width, height) drawWallMask(img, layout.Mask) return layout, nil } func splitWallPathByWater(loop []image.Point, waterMask *PixelMask) [][]image.Point { if len(loop) < 2 { return nil } if waterMask == nil { run := make([]image.Point, len(loop)) copy(run, loop) return [][]image.Point{run} } var runs [][]image.Point current := make([]image.Point, 0, len(loop)) appendPoint := func(p image.Point) { if len(current) == 0 || current[len(current)-1] != p { current = append(current, p) } } flush := func() { if len(current) > 1 { run := make([]image.Point, len(current)) copy(run, current) runs = append(runs, run) } current = current[:0] } for i := 0; i < len(loop)-1; i++ { seg := bresenhamPoints(loop[i], loop[i+1]) for _, p := range seg { if waterMask.GetPoint(p) { flush() continue } appendPoint(p) } } flush() return runs } func rasterizeWallRuns(layout *FortificationLayout, runs [][]image.Point, wallWidth, wallID int) []wallSample { if layout == nil || layout.Mask == nil || wallWidth < 1 { return nil } samples := make([]wallSample, 0) globalIndex := 0 radius := max(1, wallWidth/2) paintDisk := func(cx, cy int) { for dy := -radius; dy <= radius; dy++ { yy := cy + dy if yy < 0 || yy >= layout.Mask.Height { continue } for dx := -radius; dx <= radius; dx++ { if dx*dx+dy*dy > radius*radius { continue } xx := cx + dx if xx < 0 || xx >= layout.Mask.Width { continue } layout.Mask.SetXY(xx, yy) if len(layout.WallIDByPixel) == layout.Mask.Width*layout.Mask.Height { layout.WallIDByPixel[yy*layout.Mask.Width+xx] = wallID } } } } for runID, run := range runs { if len(run) < 2 { continue } for i := 0; i < len(run)-1; i++ { a := run[i] b := run[i+1] drawSegmentSelective(a.X, a.Y, b.X, b.Y, paintDisk) } for i, p := range run { samples = append(samples, wallSample{Point: p, Angle: wallSampleAngle(run, i), RunID: runID, Pos: i, Index: globalIndex}) globalIndex++ } } return samples } func wallSampleAngle(run []image.Point, idx int) float64 { prev := run[max(0, idx-1)] next := run[min(len(run)-1, idx+1)] return math.Atan2(float64(next.Y-prev.Y), float64(next.X-prev.X)) } func samplesToPoints(samples []wallSample) []image.Point { points := make([]image.Point, 0, len(samples)) for _, sample := range samples { points = append(points, sample.Point) } return points } func buildGatesForWall( layout *FortificationLayout, settings *Settings, samples []wallSample, center image.Point, wallIndex, wallID, wallWidth int, turretSizePx float64, width, height int, ) ([]GateInfo, []int) { if layout == nil || len(samples) == 0 || settings == nil || settings.GateCount <= 0 { return nil, nil } gateCount := max(1, settings.GateCount>>wallIndex) if gateCount > len(samples) { gateCount = len(samples) } _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) roadWidth := max(1, int(math.Round(maxRoadPx))) centerSeparation := turretSizePx * 1.25 requiredMargin := max(3, int(math.Ceil(centerSeparation))) runLengths := make(map[int]int) for _, sample := range samples { runLengths[sample.RunID]++ } used := make([]int, 0, gateCount) gates := make([]GateInfo, 0, gateCount) for gateIdx := 0; gateIdx < gateCount; gateIdx++ { target := int(math.Round((float64(gateIdx)+0.5)*float64(len(samples))/float64(gateCount))) % len(samples) sampleIdx := nearestUsableGateSample(samples, target, used, requiredMargin, runLengths) if sampleIdx < 0 { continue } used = append(used, sampleIdx) sample := samples[sampleIdx] tx := math.Cos(sample.Angle) ty := math.Sin(sample.Angle) nx := -ty ny := tx if float64(sample.Point.X-center.X)*nx+float64(sample.Point.Y-center.Y)*ny < 0 { nx = -nx ny = -ny } halfSep := centerSeparation * 0.5 left := clampPoint(image.Point{ X: int(math.Round(float64(sample.Point.X) + tx*halfSep)), Y: int(math.Round(float64(sample.Point.Y) + ty*halfSep)), }, width, height) right := clampPoint(image.Point{ X: int(math.Round(float64(sample.Point.X) - tx*halfSep)), Y: int(math.Round(float64(sample.Point.Y) - ty*halfSep)), }, width, height) reach := float64(max(wallWidth, roadWidth)) + turretSizePx inner := clampPoint(image.Point{ X: int(math.Round(float64(sample.Point.X) - nx*reach)), Y: int(math.Round(float64(sample.Point.Y) - ny*reach)), }, width, height) outer := clampPoint(image.Point{ X: int(math.Round(float64(sample.Point.X) + nx*reach)), Y: int(math.Round(float64(sample.Point.Y) + ny*reach)), }, width, height) gate := GateInfo{ WallID: wallID, Center: sample.Point, Normal: [2]float64{nx, ny}, LeftTurret: left, RightTurret: right, InnerEnd: inner, OuterEnd: outer, } cutGateOpening(layout, gate, wallWidth, roadWidth) gates = append(gates, gate) } return gates, used } func nearestUsableGateSample(samples []wallSample, target int, used []int, margin int, runLengths map[int]int) int { if len(samples) == 0 { return -1 } bestIdx := -1 bestCost := math.MaxFloat64 for idx, sample := range samples { runLen := runLengths[sample.RunID] if sample.Pos < margin || sample.Pos >= runLen-margin { continue } ok := true for _, other := range used { if other == idx { ok = false break } if samples[other].RunID == sample.RunID && abs(samples[other].Pos-sample.Pos) < margin { ok = false break } } if !ok { continue } cost := math.Abs(float64(idx - target)) if cost < bestCost { bestCost = cost bestIdx = idx } } return bestIdx } func cutGateOpening(layout *FortificationLayout, gate GateInfo, wallWidth, roadWidth int) { if layout == nil || layout.Mask == nil { return } clearWidth := max(1, roadWidth) scratch := image.NewRGBA(image.Rect(0, 0, layout.Mask.Width, layout.Mask.Height)) clearMask := NewPixelMask(layout.Mask.Width, layout.Mask.Height) drawLineMasked(scratch, gate.LeftTurret.X, gate.LeftTurret.Y, gate.RightTurret.X, gate.RightTurret.Y, color.RGBA{}, clearWidth, clearMask) for y := 0; y < layout.Mask.Height; y++ { row := y * layout.Mask.Width for x := 0; x < layout.Mask.Width; x++ { if clearMask.Data[row+x] == 0 { continue } layout.Mask.ClearXY(x, y) if len(layout.WallIDByPixel) == layout.Mask.Width*layout.Mask.Height { layout.WallIDByPixel[row+x] = 0 } } } gateWidth := max(roadWidth+2, wallWidth+2) drawLineMasked(scratch, gate.OuterEnd.X, gate.OuterEnd.Y, gate.InnerEnd.X, gate.InnerEnd.Y, color.RGBA{}, gateWidth, layout.GateMask) } func buildWaterEndpointTurrets(samples []wallSample, wallID int) []TurretPlacement { if len(samples) == 0 { return nil } runFirst := make(map[int]wallSample) runLast := make(map[int]wallSample) runOrder := make([]int, 0) for _, sample := range samples { if _, exists := runFirst[sample.RunID]; !exists { runFirst[sample.RunID] = sample runOrder = append(runOrder, sample.RunID) } runLast[sample.RunID] = sample } out := make([]TurretPlacement, 0, len(runOrder)*2) for _, runID := range runOrder { first := runFirst[runID] last := runLast[runID] out = append(out, TurretPlacement{WallID: wallID, Center: first.Point, Angle: first.Angle, IsWater: true}, TurretPlacement{WallID: wallID, Center: last.Point, Angle: last.Angle, IsWater: true}, ) } return out } func buildGateTurrets(samples []wallSample, gates []GateInfo, wallID int) []TurretPlacement { if len(gates) == 0 { return nil } out := make([]TurretPlacement, 0, len(gates)*2) for _, gate := range gates { angle := nearestSampleAngle(samples, gate.Center) out = append(out, TurretPlacement{WallID: wallID, Center: gate.LeftTurret, Angle: angle, IsGate: true}, TurretPlacement{WallID: wallID, Center: gate.RightTurret, Angle: angle, IsGate: true}, ) } return out } func buildNaturalTurrets(settings *Settings, samples []wallSample, gateSampleIndexes []int, coverage, outerCoverage float64, wallID int) []TurretPlacement { if settings == nil || len(samples) == 0 { return nil } scale := 1.0 if outerCoverage > 0 { scale = coverage / outerCoverage } stepPct := clamp(settings.TurretSpacing*scale, 0, 100) step := int(math.Round((stepPct / 100.0) * float64(len(samples)))) if step < 1 { step = 1 } runLengths := make(map[int]int) for _, sample := range samples { runLengths[sample.RunID]++ } blocked := make(map[int]bool) for _, idx := range gateSampleIndexes { blocked[idx] = true } for _, sample := range samples { runLen := runLengths[sample.RunID] if sample.Pos == 0 || sample.Pos == runLen-1 { blocked[sample.Index] = true } } turrets := make([]TurretPlacement, 0) for i := 0; i < len(samples); i += step { candidate := samples[i] tooClose := false for _, other := range samples { if !blocked[other.Index] || other.RunID != candidate.RunID { continue } if abs(other.Pos-candidate.Pos) < step { tooClose = true break } } if tooClose { continue } blocked[candidate.Index] = true turrets = append(turrets, TurretPlacement{WallID: wallID, Center: candidate.Point, Angle: candidate.Angle}) } return turrets } func nearestSampleAngle(samples []wallSample, center image.Point) float64 { bestIdx := -1 bestD2 := math.MaxInt for i, sample := range samples { dx := sample.Point.X - center.X dy := sample.Point.Y - center.Y d2 := dx*dx + dy*dy if d2 < bestD2 { bestD2 = d2 bestIdx = i } } if bestIdx < 0 { return 0 } return samples[bestIdx].Angle } func dedupeTurretPlacements(turrets []TurretPlacement, width, height int) []TurretPlacement { if len(turrets) == 0 || width <= 0 || height <= 0 { return turrets } seen := make(map[int]bool) out := make([]TurretPlacement, 0, len(turrets)) for _, turret := range turrets { if turret.Center.X < 0 || turret.Center.Y < 0 || turret.Center.X >= width || turret.Center.Y >= height { continue } key := turret.Center.Y*width + turret.Center.X if seen[key] { continue } seen[key] = true out = append(out, turret) } return out } func estimateWallNodeCount(coverage float64) int { nodes := int(math.Round(20 + coverage*0.7)) if nodes < 20 { nodes = 20 } if nodes > 96 { nodes = 96 } return nodes } // generateWallLoop builds a closed wall path using two sine waves for large and small curvature. 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, radiusX, radiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes) curveScale := clamp(curvyness, 0, 100) / 100.0 largePhase := randSrc.Float64() * 2 * math.Pi smallPhase := randSrc.Float64() * 2 * math.Pi largeAmp := 0.18 * curveScale smallAmp := 0.08 * curveScale largeFreq := 3.0 + randSrc.Float64()*1.5 smallFreq := 7.0 + randSrc.Float64()*3.0 points := make([]image.Point, 0, nodes+1) for i := 0; i < nodes; i++ { t := 2 * math.Pi * float64(i) / float64(nodes) warp := 1.0 + largeAmp*math.Sin(largeFreq*t+largePhase) + smallAmp*math.Sin(smallFreq*t+smallPhase) if warp < 0.55 { warp = 0.55 } x := int(math.Round(centerX + radiusX*warp*math.Cos(t))) y := int(math.Round(centerY + radiusY*warp*math.Sin(t))) if x < 0 { x = 0 } if y < 0 { y = 0 } if x >= width { x = width - 1 } if y >= height { y = height - 1 } points = append(points, image.Point{X: x, Y: y}) } if len(points) > 0 { points = append(points, points[0]) } return points } 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 } var sumX, sumY float64 for _, node := range roadNodes { sumX += float64(node.X) sumY += float64(node.Y) } centerX = sumX / float64(len(roadNodes)) centerY = sumY / float64(len(roadNodes)) dists := make([]float64, 0, len(roadNodes)) var sx, sy float64 for _, node := range roadNodes { dx := float64(node.X) - centerX dy := float64(node.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 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 := a.X, a.Y x1, y1 := 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 } } } // drawWallMask paints the wall mask and is used for final fortification redraws. 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) } } } } // GenerateTurrets renders all configured turret placements and returns their mask. func GenerateTurrets( img *image.RGBA, width, height int, settings *Settings, layout *FortificationLayout, _ *PixelMask, _ *PixelMask, _ []*Road, ) *PixelMask { mask := NewPixelMask(width, height) if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } if settings == nil || !settings.ShowTurrets || layout == nil { return mask } radius := max(1, int(math.Round(getTurretSizePixels(settings, width, height)/2.0))) shape := settings.TurretShape if shape != "square" { shape = "circular" } turretColor := color.RGBA{R: 220, G: 25, B: 25, A: 255} for _, turret := range layout.Turrets { drawTurret(img, mask, turret.Center, turret.Angle, radius, shape, turretColor) } return mask } func drawTurret(img *image.RGBA, mask *PixelMask, center image.Point, angle float64, radius int, shape string, col color.RGBA) { if img == nil || mask == nil || radius < 1 { return } cosA := math.Cos(angle) sinA := math.Sin(angle) extent := radius + 1 for dy := -extent; dy <= extent; dy++ { for dx := -extent; dx <= extent; dx++ { x := center.X + dx y := center.Y + dy if !mask.InBounds(x, y) { continue } draw := false if shape == "square" { lx := float64(dx)*cosA + float64(dy)*sinA ly := -float64(dx)*sinA + float64(dy)*cosA draw = math.Abs(lx) <= float64(radius) && math.Abs(ly) <= float64(radius) } else { draw = dx*dx+dy*dy <= radius*radius } if !draw { continue } mask.SetXY(x, y) img.Set(x, y, col) } } } // drawTurretMask repaints the turret mask and is used in the final redraw pass. func drawTurretMask(img *image.RGBA, turretMask *PixelMask) { if img == nil || turretMask == nil { return } turretColor := color.RGBA{R: 220, G: 25, B: 25, A: 255} for y := 0; y < turretMask.Height; y++ { row := y * turretMask.Width for x := 0; x < turretMask.Width; x++ { if turretMask.Data[row+x] != 0 { img.Set(x, y, turretColor) } } } } func clampPoint(p image.Point, width, height int) image.Point { if p.X < 0 { p.X = 0 } if p.Y < 0 { p.Y = 0 } if p.X >= width { p.X = width - 1 } if p.Y >= height { p.Y = height - 1 } return p }