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 }