diff --git a/fortifications.go b/fortifications.go index 8bbf969..55c6f89 100644 --- a/fortifications.go +++ b/fortifications.go @@ -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 diff --git a/go.mod b/go.mod index 4ac0080..51a2261 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module rpg_city_maker_reborn -go 1.25.6 +go 1.24.0 require ( fyne.io/fyne/v2 v2.7.2 diff --git a/main.go b/main.go index 97d047f..f62632f 100644 --- a/main.go +++ b/main.go @@ -704,7 +704,7 @@ func main() { 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.validate(s, func(hasError bool) { errorStates["numWalls"] = hasError @@ -775,6 +775,18 @@ func main() { 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( turretSizeSlider, turretShapeLabel, @@ -1205,6 +1217,7 @@ func main() { numWallsSlider, cityCoverageSlider, wallCurvynessSlider, + gateSpacingSlider, showTurretsCheck, turretControls, )) diff --git a/mask.go b/mask.go index 151aed0..b8018c5 100644 --- a/mask.go +++ b/mask.go @@ -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 { return m.GetXY(p.X, p.Y) } diff --git a/roads.go b/roads.go index 4abe48d..197fc7e 100644 --- a/roads.go +++ b/roads.go @@ -173,6 +173,14 @@ func GenerateRoadsWithPOIs( if len(roads) == 0 { 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) if len(roads) == 0 { 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 { 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 centerY := float64(height-1) * 0.5 for _, p := range pois { - if p == nil || !wallMask.GetXY(p.X, p.Y) { + if p == nil { continue } - - wallWidthPx := minWallPx - 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 + if !exclusion.GetXY(p.X, p.Y) { + continue } vx := float64(p.X) - centerX @@ -233,13 +238,14 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa dy := vy / vlen 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)) ny := int(math.Round(float64(p.Y) + float64(step)*dy)) if nx < 0 || ny < 0 || nx >= width || ny >= height { break } - if wallMask.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { + if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { continue } p.X = nx @@ -251,19 +257,18 @@ func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMa continue } - // Fallback: small radial sweep if direct outward ray was blocked. baseAngle := math.Atan2(dy, dx) for a := -6; a <= 6; a++ { ang := baseAngle + float64(a)*math.Pi/18.0 adx := math.Cos(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)) ny := int(math.Round(float64(p.Y) + float64(step)*ady)) if nx < 0 || ny < 0 || nx >= width || ny >= height { break } - if wallMask.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { + if exclusion.GetXY(nx, ny) || waterMask.GetXY(nx, ny) { continue } p.X = nx @@ -1761,6 +1766,199 @@ func bridgedRegionIDs(points []PathPoint, regionByPixel []int, width, height int 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 { if v < lo { return lo diff --git a/settings.go b/settings.go index e01ecfc..f38765f 100644 --- a/settings.go +++ b/settings.go @@ -54,6 +54,7 @@ type Settings struct { TurretSize float64 `json:"turret_size"` TurretShape string `json:"turret_shape"` TurretSpacing float64 `json:"turret_spacing"` + GateSpacing float64 `json:"gate_spacing"` // percent of wall circumference between gates (0=no gates) // Building settings NumBuildings int `json:"num_buildings"` @@ -159,6 +160,7 @@ func LoadSettings() (*Settings, error) { TurretSize: 0.6, TurretShape: "circular", TurretSpacing: 55, + GateSpacing: 25, NumBuildings: 200, MinBuildingSize: 3.5, MaxBuildingSize: 10.0, @@ -226,7 +228,7 @@ func LoadSettings() (*Settings, error) { if _, ok := rawKeys["max_wall_width"]; !ok { settings.MaxWallWidth = 4.0 } - if settings.NumWalls == 0 { + if _, ok := rawKeys["num_walls"]; !ok { settings.NumWalls = 1 } if settings.CityCoverage == 0 { @@ -250,6 +252,9 @@ func LoadSettings() (*Settings, error) { if _, ok := rawKeys["turret_spacing"]; !ok { settings.TurretSpacing = 55 } + if _, ok := rawKeys["gate_spacing"]; !ok { + settings.GateSpacing = 25 + } // Wall widths are percentages of average image dimension. // 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.MinWallWidth, settings.MaxWallWidth = normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth) - if settings.NumWalls < 1 { - settings.NumWalls = 1 + if settings.NumWalls < 0 { + settings.NumWalls = 0 } if settings.NumWalls > 5 { settings.NumWalls = 5