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
|
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,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
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user