Polished wall generation, though work is needed good enough for main now
This commit is contained in:
+362
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user