From 8a1f05ba6bfe51cd7f22079eea384e863e6bec49 Mon Sep 17 00:00:00 2001 From: Grimsace Date: Mon, 2 Mar 2026 11:20:57 -0600 Subject: [PATCH] added some road rules with interacting with walls for more natural generation --- fortifications.go | 15 ++ main.go | 2 + roads.go | 488 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 499 insertions(+), 6 deletions(-) diff --git a/fortifications.go b/fortifications.go index 0796e06..0bbb2f1 100644 --- a/fortifications.go +++ b/fortifications.go @@ -345,3 +345,18 @@ func cloneMask(src *PixelMask) *PixelMask { copy(dst.Data, src.Data) return dst } + +func drawWallMask(img *image.RGBA, wallMask *PixelMask) { + if img == nil || wallMask == nil { + return + } + black := color.RGBA{R: 0, G: 0, B: 0, A: 255} + for y := 0; y < wallMask.Height; y++ { + row := y * wallMask.Width + for x := 0; x < wallMask.Width; x++ { + if wallMask.Data[row+x] != 0 { + img.Set(x, y, black) + } + } + } +} diff --git a/main.go b/main.go index fbaacdd..d20141b 100644 --- a/main.go +++ b/main.go @@ -298,6 +298,7 @@ func main() { }{rd: rd, br: br, ex: ex, anc: anc} }); ok { roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + drawWallMask(roadBase, wallMask) finalImage = roadBase } else { log.Println("GenerateRoads timed out after 1 minute; continuing.") @@ -920,6 +921,7 @@ func main() { }{rd: rd, br: br, ex: ex, anc: anc} }); ok { roadMask, bridgeMask, exitRoadMask, roadAnchors = out.rd, out.br, out.ex, out.anc + drawWallMask(roadBase, wallMask) finalImage = roadBase } else { log.Println("GenerateRoads timed out after 1 minute; continuing.") diff --git a/roads.go b/roads.go index 596fc28..527031e 100644 --- a/roads.go +++ b/roads.go @@ -130,6 +130,10 @@ func GenerateRoadsWithPOIs( roadColor := color.RGBA{R: 139, G: 69, B: 19, A: 255} bridgeColor := color.RGBA{R: 60, G: 42, B: 33, A: 255} + if len(pois) > 0 && wallLayout != nil && wallLayout.Mask != nil { + nudgePOIsOutsideWalls(pois, wallLayout.Mask, waterMask, settings, width, height, randSrc) + } + // Edge-case mode: no buildings. if settings.NumBuildings == 0 && roadTarget == 0 && !edgeToEdgeOnly { internalRoads := int(math.Round(clamp(settings.RoadDistribution, 0, 100))) @@ -172,7 +176,8 @@ func GenerateRoadsWithPOIs( if len(roads) == 0 { return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil } - assignRoadWidths(roads, settings, randSrc, width, height) + roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) + assignRoadWidths(roads, settings, randSrc, width, height, wallLayout) roadMask := NewPixelMask(width, height) bridgeMask := NewPixelMask(width, height) @@ -188,6 +193,90 @@ func GenerateRoadsWithPOIs( return roadMask, bridgeMask, exitRoadMask, roadAnchors } +func nudgePOIsOutsideWalls(pois []*PointOfInterest, wallMask, waterMask *PixelMask, settings *Settings, width, height int, randSrc *rand.Rand) { + if len(pois) == 0 || wallMask == nil { + return + } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } + minWallPx, maxWallPx := getWallWidthRangePixels(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) { + 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 + } + + vx := float64(p.X) - centerX + vy := float64(p.Y) - centerY + vlen := math.Hypot(vx, vy) + if vlen < 0.001 { + theta := randSrc.Float64() * 2 * math.Pi + vx = math.Cos(theta) + vy = math.Sin(theta) + vlen = 1 + } + dx := vx / vlen + dy := vy / vlen + + moved := false + for step := 1; step <= nudgeDist+32; 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) { + continue + } + p.X = nx + p.Y = ny + moved = true + break + } + if moved { + 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++ { + 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) { + continue + } + p.X = nx + p.Y = ny + moved = true + break + } + if moved { + break + } + } + } +} + func generateEdgeToEdgeExitRoads(exitRoads, width, height int, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout) []*Road { if exitRoads <= 0 { return nil @@ -580,9 +669,11 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, anchor = bestAnchor path = bestPath } - if wallLayout != nil && wallLayout.Mask != nil && len(wallLayout.Coverages) > 0 && len(crossedWallIDs(path, wallLayout)) == 0 { - // Exit roads should pass through walls when walls exist. - continue + if wallLayout != nil && wallLayout.Mask != nil && len(wallLayout.Coverages) > 0 { + // Exit roads always use the gate-cheat when walls exist so they are always placeable. + if forced, ok := forcePathThroughWallGate(anchor, edgeNode, wallLayout, waterMask); ok { + path = forced + } } anchor.Connections++ @@ -604,6 +695,106 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, return roads } +func forcePathThroughWallGate(start, end *PointOfInterest, wallLayout *FortificationLayout, waterMask *PixelMask) ([]PathPoint, bool) { + if start == nil || end == nil || wallLayout == nil || wallLayout.Mask == nil { + return nil, false + } + mid, ok := nearestWallPixelToSegment(image.Point{X: start.X, Y: start.Y}, image.Point{X: end.X, Y: end.Y}, wallLayout.Mask) + if !ok { + return nil, false + } + tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) + if !ok { + return nil, false + } + nx, ny := -ty, tx + rx := float64(end.X - start.X) + ry := float64(end.Y - start.Y) + if rx*nx+ry*ny < 0 { + nx, ny = -nx, -ny + } + left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) + right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) + if !lok || !rok || left == right { + return nil, false + } + + startPt := image.Point{X: start.X, Y: start.Y} + endPt := image.Point{X: end.X, Y: end.Y} + entry, exit := left, right + d1 := sqDist(startPt, left) + sqDist(endPt, right) + d2 := sqDist(startPt, right) + sqDist(endPt, left) + if d2 < d1 { + entry, exit = right, left + } + + seg1 := bresenhamRoad([]image.Point{startPt, entry}) + seg2 := bresenhamRoad([]image.Point{entry, exit}) + seg3 := bresenhamRoad([]image.Point{exit, endPt}) + out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) + appendDedup := func(seg []image.Point) { + for _, p := range seg { + if len(out) > 0 && out[len(out)-1] == p { + continue + } + out = append(out, p) + } + } + appendDedup(seg1) + appendDedup(seg2) + appendDedup(seg3) + return toPathPoints(out, waterMask), true +} + +func nearestWallPixelToSegment(a, b image.Point, wallMask *PixelMask) (image.Point, bool) { + if wallMask == nil || wallMask.Width <= 0 || wallMask.Height <= 0 { + return image.Point{}, false + } + best := image.Point{} + bestD2 := math.MaxFloat64 + found := false + for y := 0; y < wallMask.Height; y++ { + row := y * wallMask.Width + for x := 0; x < wallMask.Width; x++ { + if wallMask.Data[row+x] == 0 { + continue + } + d2 := pointSegmentDistanceSquared(float64(x), float64(y), float64(a.X), float64(a.Y), float64(b.X), float64(b.Y)) + if d2 < bestD2 { + bestD2 = d2 + best = image.Point{X: x, Y: y} + found = true + } + } + } + return best, found +} + +func pointSegmentDistanceSquared(px, py, ax, ay, bx, by float64) float64 { + abx := bx - ax + aby := by - ay + apx := px - ax + apy := py - ay + den := abx*abx + aby*aby + if den <= 1e-9 { + dx := px - ax + dy := py - ay + return dx*dx + dy*dy + } + t := (apx*abx + apy*aby) / den + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + cx := ax + t*abx + cy := ay + t*aby + dx := px - cx + dy := py - cy + return dx*dx + dy*dy +} + func sampleNonWaterEdgePOI(width, height int, randSrc *rand.Rand, waterMask *PixelMask, used []image.Point) (*PointOfInterest, bool) { minSpacing := math.Min(float64(width), float64(height)) * 0.08 minSpacing2 := minSpacing * minSpacing @@ -719,7 +910,7 @@ func normalizeAngle(a float64) float64 { return a } -func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int) { +func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, width, height int, wallLayout *FortificationLayout) { if len(roads) == 0 { return } @@ -785,6 +976,13 @@ func assignRoadWidths(roads []*Road, settings *Settings, randSrc *rand.Rand, wid for i, r := range roads { w := clamp(widths[i], minWidth, maxWidth) + if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(r.Points, wallLayout)) > 0 { + // Wall-gate roads should be visibly substantial. + minGateWidth := minWidth + 0.55*(maxWidth-minWidth) + if w < minGateWidth { + w = minGateWidth + } + } r.Width = max(1, int(math.Round(w))) } } @@ -1001,7 +1199,7 @@ func straightenPathAcrossWalls(points []PathPoint, wallLayout *FortificationLayo if end >= len(points) { end = len(points) - 1 } - line := bresenhamRoad([]image.Point{points[start].Point, points[end].Point}) + line := enforcePerpendicularWallCrossing(points, start, i, j, end, wallLayout) for k, p := range line { if len(straight) > 0 && k == 0 && straight[len(straight)-1] == p { continue @@ -1014,6 +1212,154 @@ func straightenPathAcrossWalls(points []PathPoint, wallLayout *FortificationLayo return toPathPoints(straight, waterMask) } +func enforcePerpendicularWallCrossing(points []PathPoint, start, wallStart, wallEnd, end int, wallLayout *FortificationLayout) []image.Point { + startPt := points[start].Point + endPt := points[end].Point + baseLine := bresenhamRoad([]image.Point{startPt, endPt}) + if wallLayout == nil || wallLayout.Mask == nil { + return baseLine + } + if wallStart < 0 || wallEnd <= wallStart || wallEnd > len(points) { + return baseLine + } + + mid := points[wallStart+(wallEnd-wallStart)/2].Point + tx, ty, ok := estimateWallTangent(mid, wallLayout.Mask) + if !ok { + return baseLine + } + rx := float64(endPt.X - startPt.X) + ry := float64(endPt.Y - startPt.Y) + if crossingAngleToTangentDegrees(rx, ry, tx, ty) >= 75.0 { + return baseLine + } + + // Build a forced gate across the wall: one anchor just outside each side of the wall. + nx, ny := -ty, tx + vdot := rx*nx + ry*ny + if vdot < 0 { + nx, ny = -nx, -ny + } + left, lok := walkToOutsideWall(mid, -nx, -ny, wallLayout.Mask) + right, rok := walkToOutsideWall(mid, nx, ny, wallLayout.Mask) + if !lok || !rok || left == right { + return baseLine + } + + entry, exit := left, right + d1 := sqDist(startPt, left) + sqDist(endPt, right) + d2 := sqDist(startPt, right) + sqDist(endPt, left) + if d2 < d1 { + entry, exit = right, left + } + + seg1 := bresenhamRoad([]image.Point{startPt, entry}) + seg2 := bresenhamRoad([]image.Point{entry, exit}) + seg3 := bresenhamRoad([]image.Point{exit, endPt}) + out := make([]image.Point, 0, len(seg1)+len(seg2)+len(seg3)) + appendDedup := func(seg []image.Point) { + for _, p := range seg { + if len(out) > 0 && out[len(out)-1] == p { + continue + } + out = append(out, p) + } + } + appendDedup(seg1) + appendDedup(seg2) + appendDedup(seg3) + return out +} + +func estimateWallTangent(mid image.Point, wallMask *PixelMask) (float64, float64, bool) { + if wallMask == nil { + return 0, 0, false + } + const r = 4 + var pts [][2]float64 + for dy := -r; dy <= r; dy++ { + y := mid.Y + dy + if y < 0 || y >= wallMask.Height { + continue + } + for dx := -r; dx <= r; dx++ { + x := mid.X + dx + if x < 0 || x >= wallMask.Width { + continue + } + if wallMask.GetXY(x, y) { + pts = append(pts, [2]float64{float64(x), float64(y)}) + } + } + } + if len(pts) < 3 { + return 0, 0, false + } + + var mx, my float64 + for _, p := range pts { + mx += p[0] + my += p[1] + } + mx /= float64(len(pts)) + my /= float64(len(pts)) + + var sxx, syy, sxy float64 + for _, p := range pts { + dx := p[0] - mx + dy := p[1] - my + sxx += dx * dx + syy += dy * dy + sxy += dx * dy + } + if sxx+syy < 0.001 { + return 0, 0, false + } + theta := 0.5 * math.Atan2(2*sxy, sxx-syy) + return math.Cos(theta), math.Sin(theta), true +} + +func crossingAngleToTangentDegrees(rx, ry, tx, ty float64) float64 { + rn := math.Hypot(rx, ry) + tn := math.Hypot(tx, ty) + if rn < 0.001 || tn < 0.001 { + return 90 + } + dot := (rx*tx + ry*ty) / (rn * tn) + if dot < -1 { + dot = -1 + } + if dot > 1 { + dot = 1 + } + ang := math.Acos(math.Abs(dot)) * 180.0 / math.Pi + return ang +} + +func walkToOutsideWall(mid image.Point, dx, dy float64, wallMask *PixelMask) (image.Point, bool) { + if wallMask == nil { + return image.Point{}, false + } + maxSteps := max(8, (wallMask.Width+wallMask.Height)/12) + for s := 1; s <= maxSteps; s++ { + x := int(math.Round(float64(mid.X) + dx*float64(s))) + y := int(math.Round(float64(mid.Y) + dy*float64(s))) + if x < 0 || y < 0 || x >= wallMask.Width || y >= wallMask.Height { + return image.Point{}, false + } + if !wallMask.GetXY(x, y) { + return image.Point{X: x, Y: y}, true + } + } + return image.Point{}, false +} + +func sqDist(a, b image.Point) int { + dx := a.X - b.X + dy := a.Y - b.Y + return dx*dx + dy*dy +} + func crossedWallIDs(points []PathPoint, wallLayout *FortificationLayout) []int { if wallLayout == nil || wallLayout.Mask == nil || len(points) == 0 { return nil @@ -1088,6 +1434,10 @@ func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, wate } continue } + if crossesSameWallMultipleTimes(info.road.Points, wallLayout) { + keep[i] = false + continue + } keepProb := 1.0 for _, wid := range info.ids { @@ -1133,6 +1483,132 @@ func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, wate return filtered } +func crossesSameWallMultipleTimes(points []PathPoint, wallLayout *FortificationLayout) bool { + if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { + return false + } + transitionCount := make(map[int]int) + prevID := wallIDAtPoint(points[0].Point, wallLayout) + for i := 1; i < len(points); i++ { + currID := wallIDAtPoint(points[i].Point, wallLayout) + if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { + wid := currID + if wid == 0 { + wid = prevID + } + if wid > 0 { + transitionCount[wid]++ + // More than two transitions means re-crossing the same wall. + if transitionCount[wid] > 2 { + return true + } + } + } + prevID = currID + } + return false +} + +func ensureRoadNetworkConnected(roads []*Road, settings *Settings, randSrc *rand.Rand, waterMask *PixelMask, wallLayout *FortificationLayout, width, height int) []*Road { + if len(roads) <= 1 { + return roads + } + + avgDim := float64(width+height) / 2.0 + const maxConnectorAttempts = 32 + + for attempts := 0; attempts < maxConnectorAttempts; attempts++ { + nodeIndex := make(map[*PointOfInterest]int) + nodes := make([]*PointOfInterest, 0, len(roads)*2) + getNodeID := func(p *PointOfInterest) int { + if id, ok := nodeIndex[p]; ok { + return id + } + id := len(nodes) + nodeIndex[p] = id + nodes = append(nodes, p) + return id + } + adj := make([][]int, 0, len(roads)*2) + ensureAdj := func(n int) { + for len(adj) <= n { + adj = append(adj, nil) + } + } + for _, r := range roads { + a := getNodeID(r.Start) + b := getNodeID(r.End) + ensureAdj(a) + ensureAdj(b) + adj[a] = append(adj[a], b) + adj[b] = append(adj[b], a) + } + + compID := make([]int, len(nodes)) + for i := range compID { + compID[i] = -1 + } + compCount := 0 + queue := make([]int, 0, len(nodes)) + for i := 0; i < len(nodes); i++ { + if compID[i] != -1 { + continue + } + compID[i] = compCount + queue = queue[:0] + queue = append(queue, i) + for h := 0; h < len(queue); h++ { + cur := queue[h] + for _, nb := range adj[cur] { + if compID[nb] != -1 { + continue + } + compID[nb] = compCount + queue = append(queue, nb) + } + } + compCount++ + } + if compCount <= 1 { + return roads + } + + bestA, bestB := -1, -1 + bestDist2 := math.MaxFloat64 + for i := 0; i < len(nodes); i++ { + for j := i + 1; j < len(nodes); j++ { + if compID[i] == compID[j] { + continue + } + dx := float64(nodes[i].X - nodes[j].X) + dy := float64(nodes[i].Y - nodes[j].Y) + d2 := dx*dx + dy*dy + if d2 < bestDist2 { + bestDist2 = d2 + bestA, bestB = i, j + } + } + } + if bestA == -1 || bestB == -1 { + return roads + } + + a := nodes[bestA] + b := nodes[bestB] + a.Connections++ + b.Connections++ + path := calculateRoadPath(a, b, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) + roads = append(roads, &Road{ + Start: a, + End: b, + Points: path, + Importance: a.Connections + b.Connections + 2, + }) + } + + return roads +} + // drawLineMasked draws a line with specified width on the image and mask. func drawLineMasked(img *image.RGBA, x0, y0, x1, y1 int, col color.Color, width int, mask *PixelMask) { dx := abs(x1 - x0)