Files
RPG_City_Maker_Reborn/fortifications.go
T

1078 lines
28 KiB
Go
Raw Normal View History

2026-03-02 10:40:14 -06:00
package main
import (
"image"
"image/color"
"math"
"math/rand"
"sort"
)
const (
minWallWidthPercent = minBuildingSizePercent
maxWallWidthPercent = maxBuildingSizePercent
wallWidthPercentStep = buildingSizePercentStep
2026-03-02 12:50:57 -06:00
minTurretSizePercent = 0.2
maxTurretSizePercent = maxWallWidthPercent
turretSizePercentStep = 0.1
2026-03-02 10:40:14 -06:00
)
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
}
2026-03-02 12:50:57 -06:00
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
}
2026-03-02 10:40:14 -06:00
type FortificationLayout struct {
Mask *PixelMask
WallIDByPixel []int
Coverages []float64
Gates []GateInfo
2026-03-02 10:40:14 -06:00
}
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)
2026-03-02 10:40:14 -06:00
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,
})
}
}
}
2026-03-02 10:40:14 -06:00
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
}
2026-03-02 10:40:14 -06:00
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)
}
}
}
}
2026-03-02 12:50:57 -06:00
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))
2026-03-02 12:50:57 -06:00
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)
}
}
2026-03-02 12:50:57 -06:00
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))
}
2026-03-02 12:50:57 -06:00
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
}