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 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 { type FortificationLayout struct {
Mask *PixelMask Mask *PixelMask
WallIDByPixel []int WallIDByPixel []int
Coverages []float64 Coverages []float64
Gates []GateInfo
} }
func GenerateFortifications( func GenerateFortifications(
@@ -164,9 +176,252 @@ func GenerateFortifications(
} }
} }
computeGatesForLayout(img, layout, walls, settings, width, height, waterMask)
return layout, walls 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 { func estimateWallNodeCount(coverage float64) int {
n := int(math.Round(20 + coverage*0.7)) n := int(math.Round(20 + coverage*0.7))
if n < 20 { if n < 20 {
@@ -341,6 +596,39 @@ func drawWallLoopWithWaterGaps(
return pixels 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)) { func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) {
dx := abs(x1 - x0) dx := abs(x1 - x0)
dy := abs(y1 - y0) dy := abs(y1 - y0)
@@ -449,7 +737,7 @@ func GenerateTurrets(
occupied := make(map[int]bool) occupied := make(map[int]bool)
addTurret := func(center image.Point) { 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 { if !ok {
return 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 return mask
} }
@@ -603,6 +900,70 @@ func nearbyTurretExists(mask *PixelMask, center image.Point, radius int) bool {
return false 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) { func snapPointToWall(center image.Point, wallMask *PixelMask, maxRadius int) (image.Point, bool) {
if wallMask == nil { if wallMask == nil {
return image.Point{}, false return image.Point{}, false
+1 -1
View File
@@ -1,6 +1,6 @@
module rpg_city_maker_reborn module rpg_city_maker_reborn
go 1.25.6 go 1.24.0
require ( require (
fyne.io/fyne/v2 v2.7.2 fyne.io/fyne/v2 v2.7.2
+14 -1
View File
@@ -704,7 +704,7 @@ func main() {
settings.MaxWallWidth = val settings.MaxWallWidth = val
})) }))
numWallsSlider := newNumericInputSlider(1, 5, float64(settings.NumWalls), "%.0f", "Number of Walls") numWallsSlider := newNumericInputSlider(0, 5, float64(settings.NumWalls), "%.0f", "Number of Walls")
numWallsSlider.entry.OnChanged = func(s string) { numWallsSlider.entry.OnChanged = func(s string) {
numWallsSlider.validate(s, func(hasError bool) { numWallsSlider.validate(s, func(hasError bool) {
errorStates["numWalls"] = hasError errorStates["numWalls"] = hasError
@@ -775,6 +775,18 @@ func main() {
settings.TurretSpacing = val settings.TurretSpacing = val
})) }))
gateSpacingSlider := newNumericInputSlider(0, 100, settings.GateSpacing, "%.0f%%", "Gate Spacing")
gateSpacingSlider.entry.OnChanged = func(s string) {
gateSpacingSlider.validate(s, func(hasError bool) {
errorStates["gateSpacing"] = hasError
updateGenerateBtnState()
})
}
gateSpacingSlider.value.AddListener(binding.NewDataListener(func() {
val, _ := gateSpacingSlider.value.Get()
settings.GateSpacing = val
}))
turretControls := container.NewVBox( turretControls := container.NewVBox(
turretSizeSlider, turretSizeSlider,
turretShapeLabel, turretShapeLabel,
@@ -1205,6 +1217,7 @@ func main() {
numWallsSlider, numWallsSlider,
cityCoverageSlider, cityCoverageSlider,
wallCurvynessSlider, wallCurvynessSlider,
gateSpacingSlider,
showTurretsCheck, showTurretsCheck,
turretControls, turretControls,
)) ))
+6
View File
@@ -41,6 +41,12 @@ func (m *PixelMask) SetXY(x, y int) {
} }
} }
func (m *PixelMask) ClearXY(x, y int) {
if m.InBounds(x, y) {
m.Data[m.index(x, y)] = 0
}
}
func (m *PixelMask) GetPoint(p image.Point) bool { func (m *PixelMask) GetPoint(p image.Point) bool {
return m.GetXY(p.X, p.Y) return m.GetXY(p.X, p.Y)
} }
+214 -16
View File
@@ -173,6 +173,14 @@ func GenerateRoadsWithPOIs(
if len(roads) == 0 { if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
} }
// Add gate roads after wall-crossing rules (so they are never filtered out).
if wallLayout != nil && len(wallLayout.Gates) > 0 {
gateRoads := generateGateRoads(wallLayout, settings, waterMask, width, height, randSrc)
roads = append(roads, gateRoads...)
roads = ensureGateRoadConnections(gateRoads, roads, wallLayout, settings, waterMask, width, height, randSrc)
}
roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc)
if len(roads) == 0 { if len(roads) == 0 {
return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil
@@ -201,23 +209,20 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa
if waterMask == nil { if waterMask == nil {
waterMask = NewPixelMask(width, height) waterMask = NewPixelMask(width, height)
} }
minWallPx, maxWallPx := getWallWidthRangePixels(settings, width, height)
// Build exclusion zone: wall pixels dilated by one road width.
// POIs must be outside this zone so roads have room to run parallel to walls.
fakeLayout := &FortificationLayout{Mask: wallMask}
exclusion := buildWallExclusionMask(fakeLayout, settings, width, height)
centerX := float64(width-1) * 0.5 centerX := float64(width-1) * 0.5
centerY := float64(height-1) * 0.5 centerY := float64(height-1) * 0.5
for _, p := range pois { for _, p := range pois {
if p == nil || !wallMask.GetXY(p.X, p.Y) { if p == nil {
continue continue
} }
if !exclusion.GetXY(p.X, p.Y) {
wallWidthPx := minWallPx continue
if maxWallPx > minWallPx {
wallWidthPx = minWallPx + randSrc.Float64()*(maxWallPx-minWallPx)
}
nudgeFactor := 0.02 + randSrc.Float64()*0.03
nudgeDist := int(math.Round(wallWidthPx * nudgeFactor))
if nudgeDist < 1 {
nudgeDist = 1
} }
vx := float64(p.X) - centerX vx := float64(p.X) - centerX
@@ -233,13 +238,14 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa
dy := vy / vlen dy := vy / vlen
moved := false moved := false
for step := 1; step <= nudgeDist+32; step++ { maxSteps := exclusion.Width + exclusion.Height
for step := 1; step <= maxSteps; step++ {
nx := int(math.Round(float64(p.X) + float64(step)*dx)) nx := int(math.Round(float64(p.X) + float64(step)*dx))
ny := int(math.Round(float64(p.Y) + float64(step)*dy)) ny := int(math.Round(float64(p.Y) + float64(step)*dy))
if nx < 0 || ny < 0 || nx >= width || ny >= height { if nx < 0 || ny < 0 || nx >= width || ny >= height {
break break
} }
if wallMask.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
continue continue
} }
p.X = nx p.X = nx
@@ -251,19 +257,18 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa
continue continue
} }
// Fallback: small radial sweep if direct outward ray was blocked.
baseAngle := math.Atan2(dy, dx) baseAngle := math.Atan2(dy, dx)
for a := -6; a <= 6; a++ { for a := -6; a <= 6; a++ {
ang := baseAngle + float64(a)*math.Pi/18.0 ang := baseAngle + float64(a)*math.Pi/18.0
adx := math.Cos(ang) adx := math.Cos(ang)
ady := math.Sin(ang) ady := math.Sin(ang)
for step := 1; step <= nudgeDist+32; step++ { for step := 1; step <= exclusion.Width+exclusion.Height; step++ {
nx := int(math.Round(float64(p.X) + float64(step)*adx)) nx := int(math.Round(float64(p.X) + float64(step)*adx))
ny := int(math.Round(float64(p.Y) + float64(step)*ady)) ny := int(math.Round(float64(p.Y) + float64(step)*ady))
if nx < 0 || ny < 0 || nx >= width || ny >= height { if nx < 0 || ny < 0 || nx >= width || ny >= height {
break break
} }
if wallMask.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) {
continue continue
} }
p.X = nx p.X = nx
@@ -1761,6 +1766,199 @@ func bridgedRegionIDs(points []PathPoint, regionByPixel []int, width, height int
return out return out
} }
// buildWallExclusionMask creates a mask of wall pixels dilated by one road width.
// Roads will avoid pixels set in this mask (except at gates).
func buildWallExclusionMask(wallLayout *FortificationLayout, settings *Settings, width, height int) *PixelMask {
if wallLayout == nil || wallLayout.Mask == nil {
return NewPixelMask(width, height)
}
_, maxRoadPx := getRoadWidthRangePixels(settings, width, height)
margin := int(math.Ceil(maxRoadPx))
if margin < 1 {
margin = 1
}
out := NewPixelMask(width, height)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
if !wallLayout.Mask.GetXY(x, y) {
continue
}
for dy := -margin; dy <= margin; dy++ {
for dx := -margin; dx <= margin; dx++ {
if dx*dx+dy*dy <= margin*margin {
out.SetXY(x+dx, y+dy)
}
}
}
}
}
return out
}
// generateGateRoads creates one straight perpendicular road per gate.
// Each road runs from the outer end to the inner end of the gate, crossing the wall gap.
// It also creates POIs at inner/outer ends so the road network can connect to them.
func generateGateRoads(wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road {
if wallLayout == nil || len(wallLayout.Gates) == 0 {
return nil
}
_, maxRoadPx := getRoadWidthRangePixels(settings, width, height)
roadWidth := int(math.Round(maxRoadPx + 0.5*(maxRoadPx)))
if roadWidth < 1 {
roadWidth = 1
}
roads := make([]*Road, 0, len(wallLayout.Gates))
for _, gate := range wallLayout.Gates {
// Straight line from outerEnd to innerEnd — do NOT route through gateCenter
// (which is a wall boundary pixel and causes a kink in the road).
outer := &PointOfInterest{X: gate.OuterEnd.X, Y: gate.OuterEnd.Y, IsExit: false}
inner := &PointOfInterest{X: gate.InnerEnd.X, Y: gate.InnerEnd.Y, IsExit: false}
outer.Connections = 1
inner.Connections = 1
pts := bresenhamRoad([]image.Point{gate.OuterEnd, gate.InnerEnd})
path := toPathPoints(pts, waterMask)
roads = append(roads, &Road{
Start: outer,
End: inner,
Points: path,
Width: roadWidth,
Importance: 10, // high importance so gate roads get wide treatment
})
}
return roads
}
// ensureGateRoadConnections adds short connector roads from each gate's inner/outer
// endpoints to the nearest existing road POI, so the gate road is part of the network.
func ensureGateRoadConnections(gateRoads []*Road, allRoads []*Road, wallLayout *FortificationLayout, settings *Settings, waterMask *PixelMask, width, height int, randSrc *rand.Rand) []*Road {
if len(gateRoads) == 0 || wallLayout == nil {
return allRoads
}
// Collect non-gate POIs.
poiSet := make(map[*PointOfInterest]bool)
for _, r := range allRoads {
if r.Start != nil {
poiSet[r.Start] = true
}
if r.End != nil {
poiSet[r.End] = true
}
}
// Remove gate road endpoints from the non-gate set.
for _, r := range gateRoads {
delete(poiSet, r.Start)
delete(poiSet, r.End)
}
pois := make([]*PointOfInterest, 0, len(poiSet))
for p := range poiSet {
pois = append(pois, p)
}
connectors := make([]*Road, 0, len(gateRoads)*2)
_, maxRoadPx := getRoadWidthRangePixels(settings, width, height)
connW := int(math.Round(maxRoadPx))
if connW < 1 {
connW = 1
}
// pathCrossesWall returns true if a straight Bresenham line from a to b touches any wall pixel.
pathCrossesWall := func(a, b image.Point) bool {
dx := abs(b.X - a.X)
dy := abs(b.Y - a.Y)
sx := -1
if a.X < b.X {
sx = 1
}
sy := -1
if a.Y < b.Y {
sy = 1
}
err := dx - dy
x, y := a.X, a.Y
for {
if wallLayout.Mask.GetXY(x, y) {
return true
}
if x == b.X && y == b.Y {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x += sx
}
if e2 < dx {
err += dx
y += sy
}
}
return false
}
for _, gr := range gateRoads {
for _, ep := range []*PointOfInterest{gr.Start, gr.End} {
if len(pois) == 0 {
break
}
epPt := image.Point{X: ep.X, Y: ep.Y}
// Find nearest POI reachable without crossing any wall.
var best *PointOfInterest
bestD2 := math.MaxFloat64
for _, p := range pois {
if wallLayout.Mask.GetXY(p.X, p.Y) {
continue
}
pPt := image.Point{X: p.X, Y: p.Y}
if pathCrossesWall(epPt, pPt) {
continue
}
dx := float64(p.X - ep.X)
dy := float64(p.Y - ep.Y)
d2 := dx*dx + dy*dy
if d2 < bestD2 {
bestD2 = d2
best = p
}
}
// Fallback: if no wall-safe POI found, take the nearest regardless.
if best == nil {
for _, p := range pois {
if wallLayout.Mask.GetXY(p.X, p.Y) {
continue
}
dx := float64(p.X - ep.X)
dy := float64(p.Y - ep.Y)
d2 := dx*dx + dy*dy
if d2 < bestD2 {
bestD2 = d2
best = p
}
}
}
if best == nil {
continue
}
pts := bresenhamRoad([]image.Point{epPt, {X: best.X, Y: best.Y}})
path := toPathPoints(pts, waterMask)
ep.Connections++
best.Connections++
connectors = append(connectors, &Road{
Start: ep,
End: best,
Points: path,
Width: connW,
Importance: 6,
})
}
}
return append(allRoads, connectors...)
}
func clamp(v, lo, hi float64) float64 { func clamp(v, lo, hi float64) float64 {
if v < lo { if v < lo {
return lo return lo
+8 -3
View File
@@ -54,6 +54,7 @@ type Settings struct {
TurretSize float64 `json:"turret_size"` TurretSize float64 `json:"turret_size"`
TurretShape string `json:"turret_shape"` TurretShape string `json:"turret_shape"`
TurretSpacing float64 `json:"turret_spacing"` TurretSpacing float64 `json:"turret_spacing"`
GateSpacing float64 `json:"gate_spacing"` // percent of wall circumference between gates (0=no gates)
// Building settings // Building settings
NumBuildings int `json:"num_buildings"` NumBuildings int `json:"num_buildings"`
@@ -159,6 +160,7 @@ func LoadSettings() (*Settings, error) {
TurretSize: 0.6, TurretSize: 0.6,
TurretShape: "circular", TurretShape: "circular",
TurretSpacing: 55, TurretSpacing: 55,
GateSpacing: 25,
NumBuildings: 200, NumBuildings: 200,
MinBuildingSize: 3.5, MinBuildingSize: 3.5,
MaxBuildingSize: 10.0, MaxBuildingSize: 10.0,
@@ -226,7 +228,7 @@ func LoadSettings() (*Settings, error) {
if _, ok := rawKeys["max_wall_width"]; !ok { if _, ok := rawKeys["max_wall_width"]; !ok {
settings.MaxWallWidth = 4.0 settings.MaxWallWidth = 4.0
} }
if settings.NumWalls == 0 { if _, ok := rawKeys["num_walls"]; !ok {
settings.NumWalls = 1 settings.NumWalls = 1
} }
if settings.CityCoverage == 0 { if settings.CityCoverage == 0 {
@@ -250,6 +252,9 @@ func LoadSettings() (*Settings, error) {
if _, ok := rawKeys["turret_spacing"]; !ok { if _, ok := rawKeys["turret_spacing"]; !ok {
settings.TurretSpacing = 55 settings.TurretSpacing = 55
} }
if _, ok := rawKeys["gate_spacing"]; !ok {
settings.GateSpacing = 25
}
// Wall widths are percentages of average image dimension. // Wall widths are percentages of average image dimension.
// Migrate older pixel-based values when they exceed the valid percentage range. // Migrate older pixel-based values when they exceed the valid percentage range.
@@ -262,8 +267,8 @@ func LoadSettings() (*Settings, error) {
settings.MaxWallWidth = (settings.MaxWallWidth / avgDim) * 100.0 settings.MaxWallWidth = (settings.MaxWallWidth / avgDim) * 100.0
} }
settings.MinWallWidth, settings.MaxWallWidth = normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) settings.MinWallWidth, settings.MaxWallWidth = normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth)
if settings.NumWalls < 1 { if settings.NumWalls < 0 {
settings.NumWalls = 1 settings.NumWalls = 0
} }
if settings.NumWalls > 5 { if settings.NumWalls > 5 {
settings.NumWalls = 5 settings.NumWalls = 5