Polished wall generation, though work is needed good enough for main now

This commit is contained in:
grimsace
2026-03-11 12:43:41 -05:00
parent 580d7e9d4c
commit 8dc7546e09
6 changed files with 605 additions and 22 deletions
+362 -1
View File
@@ -89,10 +89,22 @@ func getTurretSizePixels(settings *Settings, width, height int) float64 {
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(
@@ -164,9 +176,252 @@ func GenerateFortifications(
}
}
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 {
@@ -341,6 +596,39 @@ func drawWallLoopWithWaterGaps(
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)
@@ -449,7 +737,7 @@ func GenerateTurrets(
occupied := make(map[int]bool)
addTurret := func(center image.Point) {
snapped, ok := snapPointToWall(center, layout.Mask, max(3, radius*4))
snapped, ok := snapPointToWallCenter(center, layout.Mask, max(3, radius*4))
if !ok {
return
}
@@ -522,6 +810,15 @@ func GenerateTurrets(
}
}
// 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
}
@@ -603,6 +900,70 @@ func nearbyTurretExists(mask *PixelMask, center image.Point, radius int) bool {
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