diff --git a/Presets/Large City.json b/Presets/Large City.json index 80759d5..b9401e1 100644 --- a/Presets/Large City.json +++ b/Presets/Large City.json @@ -34,7 +34,7 @@ "turret_size": 2.5000000000000004, "turret_shape": "circular", "turret_spacing": 20, - "gate_spacing": 34, + "gate_count": 3, "num_buildings": 1500, "min_building_size": 1, "max_building_size": 2.5, @@ -51,4 +51,4 @@ "seed": 1773254459149268515, "last_export_path": "", "image_view_state": 0 -} \ No newline at end of file +} diff --git a/Presets/Medium Town.json b/Presets/Medium Town.json index 0e5ffc8..87afb3b 100644 --- a/Presets/Medium Town.json +++ b/Presets/Medium Town.json @@ -34,7 +34,7 @@ "turret_size": 2.5000000000000004, "turret_shape": "circular", "turret_spacing": 20, - "gate_spacing": 34, + "gate_count": 3, "num_buildings": 50, "min_building_size": 2.5, "max_building_size": 5, @@ -51,4 +51,4 @@ "seed": 1773254246832144883, "last_export_path": "", "image_view_state": 0 -} \ No newline at end of file +} diff --git a/Presets/Metropolis.json b/Presets/Metropolis.json index 319f742..66bdf7c 100644 --- a/Presets/Metropolis.json +++ b/Presets/Metropolis.json @@ -34,7 +34,7 @@ "turret_size": 2.5000000000000004, "turret_shape": "circular", "turret_spacing": 20, - "gate_spacing": 34, + "gate_count": 3, "num_buildings": 7000, "min_building_size": 0.5, "max_building_size": 1, @@ -51,4 +51,4 @@ "seed": 1773254695515253606, "last_export_path": "", "image_view_state": 0 -} \ No newline at end of file +} diff --git a/Presets/Small City.json b/Presets/Small City.json index aa0d81b..e0c2684 100644 --- a/Presets/Small City.json +++ b/Presets/Small City.json @@ -34,7 +34,7 @@ "turret_size": 2.5000000000000004, "turret_shape": "circular", "turret_spacing": 20, - "gate_spacing": 34, + "gate_count": 3, "num_buildings": 500, "min_building_size": 1.5, "max_building_size": 3.5, @@ -51,4 +51,4 @@ "seed": 1773254407932680563, "last_export_path": "", "image_view_state": 0 -} \ No newline at end of file +} diff --git a/Presets/Small Town.json b/Presets/Small Town.json index 687d78a..433a3ad 100644 --- a/Presets/Small Town.json +++ b/Presets/Small Town.json @@ -34,7 +34,7 @@ "turret_size": 2.5000000000000004, "turret_shape": "circular", "turret_spacing": 20, - "gate_spacing": 34, + "gate_count": 3, "num_buildings": 10, "min_building_size": 2.5, "max_building_size": 5, @@ -51,4 +51,4 @@ "seed": 1773254133615935858, "last_export_path": "", "image_view_state": 0 -} \ No newline at end of file +} diff --git a/fortifications.go b/fortifications.go index 007ba8e..5c5611d 100644 --- a/fortifications.go +++ b/fortifications.go @@ -89,24 +89,45 @@ func getTurretSizePixels(settings *Settings, width, height int) float64 { return sizePx } -// GateInfo describes a single gate in a wall ring. +// GateInfo describes one traversable gate cut through 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 + Center image.Point + Normal [2]float64 + LeftTurret image.Point + RightTurret image.Point + InnerEnd image.Point + OuterEnd image.Point } +// FortificationLayout holds the final fortification geometry and masks. type FortificationLayout struct { Mask *PixelMask WallIDByPixel []int Coverages []float64 Gates []GateInfo + GateMask *PixelMask + Turrets []TurretPlacement } +// TurretPlacement describes one turret centered on a wall. +type TurretPlacement struct { + WallID int + Center image.Point + Angle float64 + IsGate bool + IsWater bool +} + +type wallSample struct { + Point image.Point + Angle float64 + RunID int + Pos int + Index int +} + +// GenerateFortifications builds wall geometry, gate openings, and turret placements. func GenerateFortifications( img *image.RGBA, width, height int, @@ -118,361 +139,499 @@ func GenerateFortifications( layout := &FortificationLayout{ Mask: NewPixelMask(width, height), WallIDByPixel: make([]int, width*height), + GateMask: NewPixelMask(width, height), } - if settings.NumWalls <= 0 || settings.CityCoverage <= 0 { + if settings == nil || settings.NumWalls <= 0 || settings.CityCoverage <= 0 || width <= 0 || height <= 0 { return layout, nil } - if waterMask == nil { - waterMask = NewPixelMask(width, height) - } if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } + if waterMask == nil { + waterMask = NewPixelMask(width, height) + } randSrc := rand.New(rand.NewSource(seed)) - minWidthPx, maxWidthPx := getWallWidthRangePixels(settings, width, height) - wallColor := color.RGBA{R: 0, G: 0, B: 0, A: 255} - - walls := make([][]image.Point, 0, settings.NumWalls) + minWallWidthPx, maxWallWidthPx := getWallWidthRangePixels(settings, width, height) outerCoverage := clamp(settings.CityCoverage, 1, 100) totalWalls := max(1, settings.NumWalls) - prevCoverage := 101.0 layout.Coverages = make([]float64, 0, totalWalls) - for i := 0; i < totalWalls; i++ { - baseCoverage := outerCoverage * float64(totalWalls-i) / float64(totalWalls) - coverage := baseCoverage - if i > 0 { - coverage += randSrc.Float64()*10.0 - 5.0 - } + for wallIndex := 0; wallIndex < totalWalls; wallIndex++ { + coverage := outerCoverage * float64(totalWalls-wallIndex) / float64(totalWalls) coverage = clamp(coverage, 1, 100) - if coverage >= prevCoverage { - coverage = prevCoverage - 1 - if coverage < 1 { - coverage = 1 - } - } - prevCoverage = coverage layout.Coverages = append(layout.Coverages, coverage) nodes := estimateWallNodeCount(coverage) - wallPath := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes) - if len(wallPath) < 3 { + loop := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes) + if len(loop) < 3 { continue } - wallWidthPx := minWidthPx - if maxWidthPx > minWidthPx { - wallWidthPx = minWidthPx + randSrc.Float64()*(maxWidthPx-minWidthPx) + wallWidthPx := minWallWidthPx + if maxWallWidthPx > minWallWidthPx { + wallWidthPx += randSrc.Float64() * (maxWallWidthPx - minWallWidthPx) } - wallWidth := int(math.Round(wallWidthPx)) - if wallWidth < 1 { - wallWidth = 1 + wallWidth := max(1, int(math.Round(wallWidthPx))) + wallID := wallIndex + 1 + + runs := splitWallPathByWater(loop, waterMask) + if len(runs) == 0 { + continue } - pixels := drawWallLoopWithWaterGaps(img, wallPath, wallColor, wallWidth, layout.Mask, waterMask, layout.WallIDByPixel, i+1) - if len(pixels) > 0 { - walls = append(walls, pixels) + samples := rasterizeWallRuns(layout, runs, wallWidth, wallID) + if len(samples) == 0 { + continue } + + center := averagePoint(samplesToPoints(samples)) + turretSizePx := getTurretSizePixels(settings, width, height) + gates, gateSampleIndexes := buildGatesForWall(layout, settings, samples, center, wallIndex, wallID, wallWidth, turretSizePx, width, height) + layout.Gates = append(layout.Gates, gates...) + layout.Turrets = append(layout.Turrets, buildWaterEndpointTurrets(samples, wallID)...) + layout.Turrets = append(layout.Turrets, buildGateTurrets(samples, gates, wallID)...) + layout.Turrets = append(layout.Turrets, buildNaturalTurrets(settings, samples, gateSampleIndexes, coverage, outerCoverage, wallID)...) } - computeGatesForLayout(img, layout, walls, settings, width, height, waterMask) - - return layout, walls + layout.Turrets = dedupeTurretPlacements(layout.Turrets, width, height) + drawWallMask(img, layout.Mask) + return layout, nil } -// 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, +func splitWallPathByWater(loop []image.Point, waterMask *PixelMask) [][]image.Point { + if len(loop) < 2 { + return nil + } + if waterMask == nil { + run := make([]image.Point, len(loop)) + copy(run, loop) + return [][]image.Point{run} + } + + var runs [][]image.Point + current := make([]image.Point, 0, len(loop)) + appendPoint := func(p image.Point) { + if len(current) == 0 || current[len(current)-1] != p { + current = append(current, p) + } + } + flush := func() { + if len(current) > 1 { + run := make([]image.Point, len(current)) + copy(run, current) + runs = append(runs, run) + } + current = current[:0] + } + + for i := 0; i < len(loop)-1; i++ { + seg := bresenhamPoints(loop[i], loop[i+1]) + for _, p := range seg { + if waterMask.GetPoint(p) { + flush() + continue + } + appendPoint(p) + } + } + flush() + return runs +} + +func rasterizeWallRuns(layout *FortificationLayout, runs [][]image.Point, wallWidth, wallID int) []wallSample { + if layout == nil || layout.Mask == nil || wallWidth < 1 { + return nil + } + samples := make([]wallSample, 0) + globalIndex := 0 + radius := max(1, wallWidth/2) + + paintDisk := func(cx, cy int) { + for dy := -radius; dy <= radius; dy++ { + yy := cy + dy + if yy < 0 || yy >= layout.Mask.Height { + continue + } + for dx := -radius; dx <= radius; dx++ { + if dx*dx+dy*dy > radius*radius { + continue + } + xx := cx + dx + if xx < 0 || xx >= layout.Mask.Width { + continue + } + layout.Mask.SetXY(xx, yy) + if len(layout.WallIDByPixel) == layout.Mask.Width*layout.Mask.Height { + layout.WallIDByPixel[yy*layout.Mask.Width+xx] = wallID + } + } + } + } + + for runID, run := range runs { + if len(run) < 2 { + continue + } + for i := 0; i < len(run)-1; i++ { + a := run[i] + b := run[i+1] + drawSegmentSelective(a.X, a.Y, b.X, b.Y, paintDisk) + } + for i, p := range run { + samples = append(samples, wallSample{Point: p, Angle: wallSampleAngle(run, i), RunID: runID, Pos: i, Index: globalIndex}) + globalIndex++ + } + } + + return samples +} + +func wallSampleAngle(run []image.Point, idx int) float64 { + prev := run[max(0, idx-1)] + next := run[min(len(run)-1, idx+1)] + return math.Atan2(float64(next.Y-prev.Y), float64(next.X-prev.X)) +} + +func samplesToPoints(samples []wallSample) []image.Point { + points := make([]image.Point, 0, len(samples)) + for _, sample := range samples { + points = append(points, sample.Point) + } + return points +} + +func buildGatesForWall( layout *FortificationLayout, - walls [][]image.Point, settings *Settings, + samples []wallSample, + center image.Point, + wallIndex, wallID, wallWidth int, + turretSizePx float64, width, height int, - waterMask *PixelMask, -) { - if layout == nil || settings.GateSpacing <= 0 || len(walls) == 0 { +) ([]GateInfo, []int) { + if layout == nil || len(samples) == 0 || settings == nil || settings.GateCount <= 0 { + return nil, nil + } + + gateCount := max(1, settings.GateCount>>wallIndex) + if gateCount > len(samples) { + gateCount = len(samples) + } + _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) + roadWidth := max(1, int(math.Round(maxRoadPx))) + turretGap := turretSizePx * 0.75 + centerSeparation := turretSizePx + turretGap + requiredMargin := max(3, int(math.Ceil(centerSeparation))) + + runLengths := make(map[int]int) + for _, sample := range samples { + runLengths[sample.RunID]++ + } + + used := make([]int, 0, gateCount) + gates := make([]GateInfo, 0, gateCount) + + for gateIdx := 0; gateIdx < gateCount; gateIdx++ { + target := int(math.Round((float64(gateIdx)+0.5)*float64(len(samples))/float64(gateCount))) % len(samples) + sampleIdx := nearestUsableGateSample(samples, target, used, requiredMargin, runLengths) + if sampleIdx < 0 { + continue + } + used = append(used, sampleIdx) + sample := samples[sampleIdx] + + tx := math.Cos(sample.Angle) + ty := math.Sin(sample.Angle) + nx := -ty + ny := tx + if float64(sample.Point.X-center.X)*nx+float64(sample.Point.Y-center.Y)*ny < 0 { + nx = -nx + ny = -ny + } + + halfSep := centerSeparation * 0.5 + left := clampPoint(image.Point{ + X: int(math.Round(float64(sample.Point.X) + tx*halfSep)), + Y: int(math.Round(float64(sample.Point.Y) + ty*halfSep)), + }, width, height) + right := clampPoint(image.Point{ + X: int(math.Round(float64(sample.Point.X) - tx*halfSep)), + Y: int(math.Round(float64(sample.Point.Y) - ty*halfSep)), + }, width, height) + + reach := float64(max(wallWidth, roadWidth)) + turretSizePx + inner := clampPoint(image.Point{ + X: int(math.Round(float64(sample.Point.X) - nx*reach)), + Y: int(math.Round(float64(sample.Point.Y) - ny*reach)), + }, width, height) + outer := clampPoint(image.Point{ + X: int(math.Round(float64(sample.Point.X) + nx*reach)), + Y: int(math.Round(float64(sample.Point.Y) + ny*reach)), + }, width, height) + + gate := GateInfo{ + WallID: wallID, + Center: sample.Point, + Normal: [2]float64{nx, ny}, + LeftTurret: left, + RightTurret: right, + InnerEnd: inner, + OuterEnd: outer, + } + cutGateOpening(layout, gate, wallWidth, roadWidth) + gates = append(gates, gate) + } + + return gates, used +} + +func nearestUsableGateSample(samples []wallSample, target int, used []int, margin int, runLengths map[int]int) int { + if len(samples) == 0 { + return -1 + } + bestIdx := -1 + bestCost := math.MaxFloat64 + for idx, sample := range samples { + runLen := runLengths[sample.RunID] + if sample.Pos < margin || sample.Pos >= runLen-margin { + continue + } + ok := true + for _, other := range used { + if other == idx { + ok = false + break + } + if samples[other].RunID == sample.RunID && abs(samples[other].Pos-sample.Pos) < margin { + ok = false + break + } + } + if !ok { + continue + } + cost := math.Abs(float64(idx - target)) + if cost < bestCost { + bestCost = cost + bestIdx = idx + } + } + return bestIdx +} + +func cutGateOpening(layout *FortificationLayout, gate GateInfo, wallWidth, roadWidth int) { + if layout == nil || layout.Mask == nil { return } - - _, maxRoadPx := getRoadWidthRangePixels(settings, width, height) - roadWidth := maxRoadPx - if roadWidth < 1 { - roadWidth = 1 + clearWidth := max(wallWidth+2, roadWidth+2) + scratch := image.NewRGBA(image.Rect(0, 0, layout.Mask.Width, layout.Mask.Height)) + clearMask := NewPixelMask(layout.Mask.Width, layout.Mask.Height) + drawLineMasked(scratch, gate.InnerEnd.X, gate.InnerEnd.Y, gate.OuterEnd.X, gate.OuterEnd.Y, color.RGBA{}, clearWidth, clearMask) + for y := 0; y < layout.Mask.Height; y++ { + row := y * layout.Mask.Width + for x := 0; x < layout.Mask.Width; x++ { + if clearMask.Data[row+x] == 0 { + continue + } + layout.Mask.ClearXY(x, y) + if len(layout.WallIDByPixel) == layout.Mask.Width*layout.Mask.Height { + layout.WallIDByPixel[row+x] = 0 + } + } } - 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 + gateWidth := max(roadWidth+2, wallWidth+2) + drawLineMasked(scratch, gate.OuterEnd.X, gate.OuterEnd.Y, gate.InnerEnd.X, gate.InnerEnd.Y, color.RGBA{}, gateWidth, layout.GateMask) +} - for wallIdx, wallPixels := range walls { - wallID := wallIdx + 1 - if len(wallPixels) == 0 { +func buildWaterEndpointTurrets(samples []wallSample, wallID int) []TurretPlacement { + if len(samples) == 0 { + return nil + } + runFirst := make(map[int]wallSample) + runLast := make(map[int]wallSample) + runOrder := make([]int, 0) + for _, sample := range samples { + if _, exists := runFirst[sample.RunID]; !exists { + runFirst[sample.RunID] = sample + runOrder = append(runOrder, sample.RunID) + } + runLast[sample.RunID] = sample + } + + out := make([]TurretPlacement, 0, len(runOrder)*2) + for _, runID := range runOrder { + first := runFirst[runID] + last := runLast[runID] + out = append(out, + TurretPlacement{WallID: wallID, Center: first.Point, Angle: first.Angle, IsWater: true}, + TurretPlacement{WallID: wallID, Center: last.Point, Angle: last.Angle, IsWater: true}, + ) + } + return out +} + +func buildGateTurrets(samples []wallSample, gates []GateInfo, wallID int) []TurretPlacement { + if len(gates) == 0 { + return nil + } + out := make([]TurretPlacement, 0, len(gates)*2) + for _, gate := range gates { + angle := nearestSampleAngle(samples, gate.Center) + out = append(out, + TurretPlacement{WallID: wallID, Center: gate.LeftTurret, Angle: angle, IsGate: true}, + TurretPlacement{WallID: wallID, Center: gate.RightTurret, Angle: angle, IsGate: true}, + ) + } + return out +} + +func buildNaturalTurrets(settings *Settings, samples []wallSample, gateSampleIndexes []int, coverage, outerCoverage float64, wallID int) []TurretPlacement { + if settings == nil || len(samples) == 0 { + return nil + } + + scale := 1.0 + if outerCoverage > 0 { + scale = coverage / outerCoverage + } + stepPct := clamp(settings.TurretSpacing*scale, 0, 100) + step := int(math.Round((stepPct / 100.0) * float64(len(samples)))) + if step < 1 { + step = 1 + } + + runLengths := make(map[int]int) + for _, sample := range samples { + runLengths[sample.RunID]++ + } + blocked := make(map[int]bool) + for _, idx := range gateSampleIndexes { + blocked[idx] = true + } + for _, sample := range samples { + runLen := runLengths[sample.RunID] + if sample.Pos == 0 || sample.Pos == runLen-1 { + blocked[sample.Index] = true + } + } + + turrets := make([]TurretPlacement, 0) + for i := 0; i < len(samples); i += step { + candidate := samples[i] + tooClose := false + for _, other := range samples { + if !blocked[other.Index] || other.RunID != candidate.RunID { + continue + } + if abs(other.Pos-candidate.Pos) < step { + tooClose = true + break + } + } + if tooClose { continue } + blocked[candidate.Index] = true + turrets = append(turrets, TurretPlacement{WallID: wallID, Center: candidate.Point, Angle: candidate.Angle}) + } + return turrets +} - // 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 := estimateWallTangent(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 nearestSampleAngle(samples []wallSample, center image.Point) float64 { + bestIdx := -1 + bestD2 := math.MaxInt + for i, sample := range samples { + dx := sample.Point.X - center.X + dy := sample.Point.Y - center.Y + d2 := dx*dx + dy*dy + if d2 < bestD2 { + bestD2 = d2 + bestIdx = i } } + if bestIdx < 0 { + return 0 + } + return samples[bestIdx].Angle +} + +func dedupeTurretPlacements(turrets []TurretPlacement, width, height int) []TurretPlacement { + if len(turrets) == 0 || width <= 0 || height <= 0 { + return turrets + } + seen := make(map[int]bool) + out := make([]TurretPlacement, 0, len(turrets)) + for _, turret := range turrets { + if turret.Center.X < 0 || turret.Center.Y < 0 || turret.Center.X >= width || turret.Center.Y >= height { + continue + } + key := turret.Center.Y*width + turret.Center.X + if seen[key] { + continue + } + seen[key] = true + out = append(out, turret) + } + return out } func estimateWallNodeCount(coverage float64) int { - n := int(math.Round(20 + coverage*0.7)) - if n < 20 { - n = 20 + nodes := int(math.Round(20 + coverage*0.7)) + if nodes < 20 { + nodes = 20 } - if n > 96 { - n = 96 + if nodes > 96 { + nodes = 96 } - return n + return nodes } +// generateWallLoop builds a closed wall path using two sine waves for large and small curvature. func generateWallLoop(width, height int, coverage, curvyness float64, nodes int, randSrc *rand.Rand, roadNodes []*PointOfInterest) []image.Point { if width <= 0 || height <= 0 || nodes < 3 { return nil } - centerX, centerY, baseRadiusX, baseRadiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes) + centerX, centerY, radiusX, radiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes) curveScale := clamp(curvyness, 0, 100) / 100.0 - warpAmp := 0.20 * curveScale - phaseA := randSrc.Float64() * 2 * math.Pi - phaseB := randSrc.Float64() * 2 * math.Pi + largePhase := randSrc.Float64() * 2 * math.Pi + smallPhase := randSrc.Float64() * 2 * math.Pi + largeAmp := 0.18 * curveScale + smallAmp := 0.08 * curveScale + largeFreq := 3.0 + randSrc.Float64()*1.5 + smallFreq := 7.0 + randSrc.Float64()*3.0 - out := make([]image.Point, 0, nodes+1) + points := make([]image.Point, 0, nodes+1) for i := 0; i < nodes; i++ { - t := (2 * math.Pi * float64(i)) / float64(nodes) - warp := 1.0 + warpAmp*(0.6*math.Sin(3*t+phaseA)+0.4*math.Sin(5*t+phaseB)) - if warp < 0.7 { - warp = 0.7 + t := 2 * math.Pi * float64(i) / float64(nodes) + warp := 1.0 + + largeAmp*math.Sin(largeFreq*t+largePhase) + + smallAmp*math.Sin(smallFreq*t+smallPhase) + if warp < 0.55 { + warp = 0.55 } - rx := baseRadiusX * warp - ry := baseRadiusY * warp - x := int(math.Round(centerX + rx*math.Cos(t))) - y := int(math.Round(centerY + ry*math.Sin(t))) + x := int(math.Round(centerX + radiusX*warp*math.Cos(t))) + y := int(math.Round(centerY + radiusY*warp*math.Sin(t))) if x < 0 { x = 0 } - if x >= width { - x = width - 1 - } if y < 0 { y = 0 } + if x >= width { + x = width - 1 + } if y >= height { y = height - 1 } - out = append(out, image.Point{X: x, Y: y}) + points = append(points, image.Point{X: x, Y: y}) } - if len(out) > 0 { - out = append(out, out[0]) + if len(points) > 0 { + points = append(points, points[0]) } - return out + return points } func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []*PointOfInterest) (centerX, centerY, radiusX, radiusY float64) { @@ -486,19 +645,19 @@ func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []* return centerX, centerY, radiusX, radiusY } - sumX, sumY := 0.0, 0.0 - for _, n := range roadNodes { - sumX += float64(n.X) - sumY += float64(n.Y) + var sumX, sumY float64 + for _, node := range roadNodes { + sumX += float64(node.X) + sumY += float64(node.Y) } centerX = sumX / float64(len(roadNodes)) centerY = sumY / float64(len(roadNodes)) dists := make([]float64, 0, len(roadNodes)) var sx, sy float64 - for _, n := range roadNodes { - dx := float64(n.X) - centerX - dy := float64(n.Y) - centerY + for _, node := range roadNodes { + dx := float64(node.X) - centerX + dy := float64(node.Y) - centerY dists = append(dists, math.Hypot(dx, dy)) sx += dx * dx sy += dy * dy @@ -535,71 +694,10 @@ func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []* return centerX, centerY, radiusX, radiusY } -func drawWallLoopWithWaterGaps( - img *image.RGBA, - loop []image.Point, - col color.RGBA, - width int, - wallMask *PixelMask, - waterMask *PixelMask, - wallIDByPixel []int, - wallID int, -) []image.Point { - if len(loop) < 2 || wallMask == nil { - return nil - } - seen := make(map[int]bool) - pixels := make([]image.Point, 0, len(loop)*8) - radius := max(1, width/2) - - for i := 0; i < len(loop)-1; i++ { - a := loop[i] - b := loop[i+1] - drawSegmentSelective(a.X, a.Y, b.X, b.Y, func(x, y int) { - if !wallMask.InBounds(x, y) { - return - } - if waterMask != nil && waterMask.GetXY(x, y) { - return - } - for dy := -radius; dy <= radius; dy++ { - yy := y + dy - if yy < 0 || yy >= wallMask.Height { - continue - } - for dx := -radius; dx <= radius; dx++ { - if dx*dx+dy*dy > radius*radius { - continue - } - xx := x + dx - if xx < 0 || xx >= wallMask.Width { - continue - } - if waterMask != nil && waterMask.GetXY(xx, yy) { - continue - } - wallMask.SetXY(xx, yy) - if len(wallIDByPixel) == wallMask.Width*wallMask.Height { - wallIDByPixel[yy*wallMask.Width+xx] = wallID - } - img.Set(xx, yy, col) - idx := yy*wallMask.Width + xx - if !seen[idx] { - seen[idx] = true - pixels = append(pixels, image.Point{X: xx, Y: yy}) - } - } - } - }) - } - - 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 + x0, y0 := a.X, a.Y + x1, y1 := b.X, b.Y dx := abs(x1 - x0) dy := abs(y1 - y0) sx := -1 @@ -658,6 +756,7 @@ func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) { } } +// drawWallMask paints the wall mask and is used for final fortification redraws. func drawWallMask(img *image.RGBA, wallMask *PixelMask) { if img == nil || wallMask == nil { return @@ -673,338 +772,96 @@ func drawWallMask(img *image.RGBA, wallMask *PixelMask) { } } +// GenerateTurrets renders all configured turret placements and returns their mask. func GenerateTurrets( img *image.RGBA, width, height int, settings *Settings, layout *FortificationLayout, - waterMask, roadMask *PixelMask, - roads []*Road, + _ *PixelMask, + _ *PixelMask, + _ []*Road, ) *PixelMask { mask := NewPixelMask(width, height) - if !settings.ShowTurrets || layout == nil || layout.Mask == nil || len(layout.WallIDByPixel) != width*height { - return mask - } if img == nil { img = image.NewRGBA(image.Rect(0, 0, width, height)) } - if waterMask == nil { - waterMask = NewPixelMask(width, height) - } - if roadMask == nil { - roadMask = NewPixelMask(width, height) + if settings == nil || !settings.ShowTurrets || layout == nil { + return mask } - sizePx := getTurretSizePixels(settings, width, height) - radius := int(math.Round(sizePx / 2.0)) - if radius < 1 { - radius = 1 - } + radius := max(1, int(math.Round(getTurretSizePixels(settings, width, height)/2.0))) shape := settings.TurretShape if shape != "square" { shape = "circular" } - colorRed := color.RGBA{R: 220, G: 25, B: 25, A: 255} + turretColor := color.RGBA{R: 220, G: 25, B: 25, A: 255} - wallPoints := make(map[int][]image.Point) - waterMeetPoints := make(map[int][]image.Point) - for y := 0; y < height; y++ { - row := y * width - for x := 0; x < width; x++ { - wid := layout.WallIDByPixel[row+x] - if wid <= 0 { - continue - } - if !isBoundaryWallPixel(x, y, layout.Mask) { - continue - } - p := image.Point{X: x, Y: y} - wallPoints[wid] = append(wallPoints[wid], p) - if touchesWater(x, y, waterMask) { - waterMeetPoints[wid] = append(waterMeetPoints[wid], p) - } - } + for _, turret := range layout.Turrets { + drawTurret(img, mask, turret.Center, turret.Angle, radius, shape, turretColor) } - - occupied := make(map[int]bool) - addTurret := func(center image.Point) { - snapped, ok := snapPointToWallCenter(center, layout.Mask, max(3, radius*4)) - if !ok { - return - } - if nearbyTurretExists(mask, snapped, max(2, radius)) { - return - } - key := snapped.Y*width + snapped.X - if occupied[key] { - return - } - occupied[key] = true - drawTurret(img, mask, snapped, radius, shape, colorRed) - } - - // Base spacing turrets along each wall ring. - for wid, pts := range wallPoints { - if len(pts) == 0 { - continue - } - centroid := averagePoint(pts) - sort.Slice(pts, func(i, j int) bool { - ai := math.Atan2(float64(pts[i].Y-centroid.Y), float64(pts[i].X-centroid.X)) - aj := math.Atan2(float64(pts[j].Y-centroid.Y), float64(pts[j].X-centroid.X)) - return ai < aj - }) - // Spacing is "distance along wall as % of wall circumference", independent of turret size. - spacingPct := clamp(settings.TurretSpacing, 0, 100) - step := int(math.Round((spacingPct / 100.0) * float64(len(pts)))) - if step < 1 { - step = 1 - } - if step > len(pts) { - step = len(pts) - } - for i := 0; i < len(pts); i += step { - addTurret(pts[i]) - } - - // Always place turrets where wall meets water. - for _, p := range waterMeetPoints[wid] { - addTurret(p) - } - } - - // Gate turrets: one on each side of each road crossing, spacing = 3x road width. - for _, r := range roads { - if r == nil || len(r.Points) < 2 { - continue - } - gates := roadGateCentersForRoad(r, layout) - if len(gates) == 0 { - continue - } - for _, g := range gates { - tx, ty, ok := estimateWallTangent(g, layout.Mask) - if !ok { - continue - } - offset := 1.5 * float64(max(1, r.Width)) - left := image.Point{ - X: int(math.Round(float64(g.X) + tx*offset)), - Y: int(math.Round(float64(g.Y) + ty*offset)), - } - right := image.Point{ - X: int(math.Round(float64(g.X) - tx*offset)), - Y: int(math.Round(float64(g.Y) - ty*offset)), - } - addTurret(left) - addTurret(right) - } - } - - // 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 } -func isBoundaryWallPixel(x, y int, wallMask *PixelMask) bool { - if wallMask == nil || !wallMask.GetXY(x, y) { - return false +func drawTurret(img *image.RGBA, mask *PixelMask, center image.Point, angle float64, radius int, shape string, col color.RGBA) { + if img == nil || mask == nil || radius < 1 { + return } - for dy := -1; dy <= 1; dy++ { - for dx := -1; dx <= 1; dx++ { - if dx == 0 && dy == 0 { - continue - } - nx, ny := x+dx, y+dy - if !wallMask.InBounds(nx, ny) || !wallMask.GetXY(nx, ny) { - return true - } - } - } - return false -} - -func touchesWater(x, y int, waterMask *PixelMask) bool { - if waterMask == nil { - return false - } - for dy := -1; dy <= 1; dy++ { - for dx := -1; dx <= 1; dx++ { - nx, ny := x+dx, y+dy - if waterMask.GetXY(nx, ny) { - return true - } - } - } - return false -} - -func drawTurret(img *image.RGBA, mask *PixelMask, center image.Point, radius int, shape string, col color.RGBA) { - for dy := -radius; dy <= radius; dy++ { - for dx := -radius; dx <= radius; dx++ { - if shape == "circular" && dx*dx+dy*dy > radius*radius { - continue - } - x, y := center.X+dx, center.Y+dy + cosA := math.Cos(angle) + sinA := math.Sin(angle) + extent := radius + 1 + for dy := -extent; dy <= extent; dy++ { + for dx := -extent; dx <= extent; dx++ { + x := center.X + dx + y := center.Y + dy if !mask.InBounds(x, y) { continue } + draw := false + if shape == "square" { + lx := float64(dx)*cosA + float64(dy)*sinA + ly := -float64(dx)*sinA + float64(dy)*cosA + draw = math.Abs(lx) <= float64(radius) && math.Abs(ly) <= float64(radius) + } else { + draw = dx*dx+dy*dy <= radius*radius + } + if !draw { + continue + } mask.SetXY(x, y) img.Set(x, y, col) } } } -func nearbyTurretExists(mask *PixelMask, center image.Point, radius int) bool { - if mask == nil { - return false +// drawTurretMask repaints the turret mask and is used in the final redraw pass. +func drawTurretMask(img *image.RGBA, turretMask *PixelMask) { + if img == nil || turretMask == nil { + return } - for dy := -radius; dy <= radius; dy++ { - for dx := -radius; dx <= radius; dx++ { - if dx*dx+dy*dy > radius*radius { - continue - } - if mask.GetXY(center.X+dx, center.Y+dy) { - return true + turretColor := color.RGBA{R: 220, G: 25, B: 25, A: 255} + for y := 0; y < turretMask.Height; y++ { + row := y * turretMask.Width + for x := 0; x < turretMask.Width; x++ { + if turretMask.Data[row+x] != 0 { + img.Set(x, y, turretColor) } } } - 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 +func clampPoint(p image.Point, width, height int) image.Point { + if p.X < 0 { + p.X = 0 } - - // First, snap hint to a wall pixel at all. - start, ok := snapPointToWall(hint, wallMask, maxRadius) - if !ok { - return image.Point{}, false + if p.Y < 0 { + p.Y = 0 } - - // 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}, + if p.X >= width { + p.X = width - 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 p.Y >= height { + p.Y = height - 1 } - - 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 - } - if wallMask.GetXY(center.X, center.Y) { - return center, true - } - if maxRadius < 1 { - maxRadius = 1 - } - best := image.Point{} - bestD2 := math.MaxInt - found := false - for r := 1; r <= maxRadius; r++ { - minX := center.X - r - maxX := center.X + r - minY := center.Y - r - maxY := center.Y + r - for y := minY; y <= maxY; y++ { - for x := minX; x <= maxX; x++ { - if x != minX && x != maxX && y != minY && y != maxY { - continue - } - if !wallMask.GetXY(x, y) { - continue - } - dx := x - center.X - dy := y - center.Y - d2 := dx*dx + dy*dy - if d2 < bestD2 { - bestD2 = d2 - best = image.Point{X: x, Y: y} - found = true - } - } - } - if found { - return best, true - } - } - return image.Point{}, false -} - -func roadGateCentersForRoad(r *Road, layout *FortificationLayout) []image.Point { - out := make([]image.Point, 0, 2) - if r == nil || layout == nil || layout.Mask == nil || len(r.Points) < 2 { - return out - } - prevID := 0 - if layout.Mask.InBounds(r.Points[0].Point.X, r.Points[0].Point.Y) { - prevID = layout.WallIDByPixel[r.Points[0].Point.Y*layout.Mask.Width+r.Points[0].Point.X] - } - for i := 1; i < len(r.Points); i++ { - p := r.Points[i].Point - currID := 0 - if layout.Mask.InBounds(p.X, p.Y) { - currID = layout.WallIDByPixel[p.Y*layout.Mask.Width+p.X] - } - if (prevID == 0 && currID > 0) || (prevID > 0 && currID == 0) { - out = append(out, p) - } - prevID = currID - } - return out + return p } diff --git a/roads.go b/roads.go index 998c99c..452f64c 100644 --- a/roads.go +++ b/roads.go @@ -169,23 +169,23 @@ func GenerateRoadsWithPOIs( if len(roads) == 0 { return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } - roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) - 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 = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) + if len(roads) == 0 { + return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil + } + roads = reduceRepeatedBridges(roads, waterMask, width, height, randSrc) if len(roads) == 0 { return NewPixelMask(width, height), NewPixelMask(width, height), NewPixelMask(width, height), nil, nil } roads = ensureRoadNetworkConnected(roads, settings, randSrc, waterMask, wallLayout, width, height) + roads = applyWallCrossingRules(roads, wallLayout, waterMask, randSrc) assignRoadWidths(roads, settings, randSrc, width, height, wallLayout) roadMask := NewPixelMask(width, height) @@ -655,32 +655,6 @@ func appendExitRoads(roads []*Road, pois []*PointOfInterest, width, height int, } path := calculateRoadPath(anchor, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) - if wallLayout != nil && wallLayout.Mask != nil && len(crossedWallIDs(path, wallLayout)) == 0 { - bestScore := -1.0 - bestAnchor := anchor - bestPath := path - for _, cand := range pois { - testPath := calculateRoadPath(cand, edgeNode, settings.RoadCurvyness/100.0, avgDim, randSrc, waterMask, wallLayout) - if len(crossedWallIDs(testPath, wallLayout)) == 0 { - continue - } - d := math.Hypot(float64(cand.X-edgeNode.X), float64(cand.Y-edgeNode.Y)) - score := cand.ArterialWeight*2.0 + clamp(1.0-d/2000.0, 0, 1) - if score > bestScore { - bestScore = score - bestAnchor = cand - bestPath = testPath - } - } - anchor = bestAnchor - path = bestPath - } - 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++ edgeNode.IsExit = true @@ -1081,14 +1055,14 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r curve := clamp(curvyness, 0, 1) if curve <= 0 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) + return toPathPoints(points, waterMask) } // Non-linear scaling: low values stay fairly straight, high values become very winding. strength := math.Pow(curve, 1.35) if strength < 0.001 { points := bresenhamRoad([]image.Point{{X: start.X, Y: start.Y}, {X: end.X, Y: end.Y}}) - return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) + return toPathPoints(points, waterMask) } baseControls := int(math.Max(12, dist/(22.0-14.0*strength))) @@ -1149,7 +1123,7 @@ func calculateRoadPath(start, end *PointOfInterest, curvyness, avgDim float64, r } points := bresenhamRoad(controlPoints) - return straightenPathAcrossWalls(toPathPoints(points, waterMask), wallLayout, waterMask) + return toPathPoints(points, waterMask) } func toPathPoints(points []image.Point, waterMask *PixelMask) []PathPoint { @@ -1352,95 +1326,41 @@ func containsWallID(ids []int, wallID int) bool { } func applyWallCrossingRules(roads []*Road, wallLayout *FortificationLayout, waterMask *PixelMask, randSrc *rand.Rand) []*Road { - if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil || len(wallLayout.Coverages) == 0 { + if len(roads) == 0 || wallLayout == nil || wallLayout.Mask == nil { return roads } - - // Straighten each wall crossing segment first. - for _, road := range roads { - road.Points = straightenPathAcrossWalls(road.Points, wallLayout, waterMask) - } - - type roadInfo struct { - road *Road - ids []int - } - infos := make([]roadInfo, 0, len(roads)) - for _, road := range roads { - infos = append(infos, roadInfo{road: road, ids: crossedWallIDs(road.Points, wallLayout)}) - } - - const repeatWallFactor = 0.55 - wallCrossCount := make(map[int]int) - requiredWalls := make(map[int]bool) - for i, cov := range wallLayout.Coverages { - if cov < 95 { - requiredWalls[i+1] = true - } - } - - keep := make([]bool, len(infos)) - for i, info := range infos { - if len(info.ids) == 0 { - keep[i] = true - continue - } - if info.road.Start.IsExit || info.road.End.IsExit { - keep[i] = true - for _, wid := range info.ids { - wallCrossCount[wid]++ - } - continue - } - if crossesSameWallMultipleTimes(info.road.Points, wallLayout) { - keep[i] = false - continue - } - - keepProb := 1.0 - for _, wid := range info.ids { - c := wallCrossCount[wid] - if c > 0 { - keepProb *= math.Pow(repeatWallFactor, float64(c)) - } - } - if randSrc.Float64() <= keepProb { - keep[i] = true - for _, wid := range info.ids { - wallCrossCount[wid]++ - } - } - } - - // Ensure at least one crossing on each wall unless its configured coverage is >= 95%. - for wallID := range requiredWalls { - if wallCrossCount[wallID] > 0 { - continue - } - for i, info := range infos { - if keep[i] { - continue - } - if !containsWallID(info.ids, wallID) { - continue - } - keep[i] = true - for _, wid := range info.ids { - wallCrossCount[wid]++ - } - break - } - } - + _ = waterMask + _ = randSrc filtered := make([]*Road, 0, len(roads)) - for i, info := range infos { - if keep[i] { - filtered = append(filtered, info.road) + for _, road := range roads { + if pathRespectsWallPassages(road.Points, wallLayout.Mask, wallLayout.GateMask) { + filtered = append(filtered, road) } } return filtered } +func pathRespectsWallPassages(points []PathPoint, exclusionMask, gateMask *PixelMask) bool { + if len(points) == 0 || exclusionMask == nil { + return true + } + for _, pp := range points { + x := pp.Point.X + y := pp.Point.Y + if !exclusionMask.InBounds(x, y) { + continue + } + if !exclusionMask.GetXY(x, y) { + continue + } + if gateMask != nil && gateMask.GetXY(x, y) { + continue + } + return false + } + return true +} + func crossesSameWallMultipleTimes(points []PathPoint, wallLayout *FortificationLayout) bool { if wallLayout == nil || wallLayout.Mask == nil || len(points) < 2 { return false diff --git a/settings.go b/settings.go index 5af8233..7b05e40 100644 --- a/settings.go +++ b/settings.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "io" + "math" "os" "path/filepath" "time" @@ -54,7 +55,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) + GateCount int `json:"gate_count"` // number of gates on the outer wall before inner-wall halving // Building settings NumBuildings int `json:"num_buildings"` @@ -161,7 +162,7 @@ func LoadSettings() (*Settings, error) { TurretSize: 0.6, TurretShape: "circular", TurretSpacing: 55, - GateSpacing: 25, + GateCount: 3, NumBuildings: 200, MinBuildingSize: 3.5, MaxBuildingSize: 10.0, @@ -264,8 +265,20 @@ func normalizeSettings(settings *Settings, rawKeys map[string]json.RawMessage) { if _, ok := rawKeys["turret_spacing"]; !ok { settings.TurretSpacing = 55 } - if _, ok := rawKeys["gate_spacing"]; !ok { - settings.GateSpacing = 25 + if _, ok := rawKeys["gate_count"]; !ok { + if legacyRaw, legacy := rawKeys["gate_spacing"]; legacy && settings.GateCount == 0 { + var legacySpacing float64 + if err := json.Unmarshal(legacyRaw, &legacySpacing); err == nil { + legacyCount := int(math.Round(100.0 / clamp(legacySpacing, 1, 100))) + if legacyCount < 1 { + legacyCount = 1 + } + settings.GateCount = legacyCount + } + } + if settings.GateCount == 0 { + settings.GateCount = 3 + } } // Wall widths are percentages of average image dimension. @@ -289,6 +302,12 @@ func normalizeSettings(settings *Settings, rawKeys map[string]json.RawMessage) { settings.WallCurvyness = clamp(settings.WallCurvyness, 0, 100) settings.TurretSize = snapTurretSizePercent(settings.TurretSize) settings.TurretSpacing = clamp(settings.TurretSpacing, 0, 100) + if settings.GateCount < 1 { + settings.GateCount = 1 + } + if settings.GateCount > 20 { + settings.GateCount = 20 + } // Tree sizes are percentages of average image dimension. // Migrate older pixel-based values when they exceed the valid percentage range. diff --git a/ui.go b/ui.go index 0f1106a..2fc4c9c 100644 --- a/ui.go +++ b/ui.go @@ -12,6 +12,7 @@ import ( "image/png" "io" "log" + "math" "os" "path/filepath" "strconv" @@ -322,6 +323,12 @@ func main() { return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out + if wallMask != nil { + drawWallMask(treeBase, wallMask) + } + if turretMask != nil { + drawTurretMask(treeBase, turretMask) + } finalImage = treeBase } else { log.Println("GenerateTrees timed out after 1 minute; continuing.") @@ -789,16 +796,16 @@ func main() { markPresetDirty() })) - gateSpacingSlider := newNumericInputSlider(0, 100, settings.GateSpacing, "%.0f%%", "Gate Spacing") - gateSpacingSlider.entry.OnChanged = func(s string) { - gateSpacingSlider.validate(s, func(hasError bool) { - errorStates["gateSpacing"] = hasError + gateCountSlider := newNumericInputSliderWithStep(1, 20, float64(settings.GateCount), 1, "%.0f", "Gate Count") + gateCountSlider.entry.OnChanged = func(s string) { + gateCountSlider.validate(s, func(hasError bool) { + errorStates["gateCount"] = hasError updateGenerateBtnState() }) } - gateSpacingSlider.value.AddListener(binding.NewDataListener(func() { - val, _ := gateSpacingSlider.value.Get() - settings.GateSpacing = val + gateCountSlider.value.AddListener(binding.NewDataListener(func() { + val, _ := gateCountSlider.value.Get() + settings.GateCount = int(math.Round(val)) markPresetDirty() })) @@ -1111,6 +1118,12 @@ func main() { return GenerateTrees(treeBase, waterMask, placementMask, buildingMask, settings.MinTreeSize, settings.MaxTreeSize, settings.TreeCoverage, settings.TreeClumpiness, seedProvider.Next()) }); ok { treeMask = out + if wallMask != nil { + drawWallMask(treeBase, wallMask) + } + if turretMask != nil { + drawTurretMask(treeBase, turretMask) + } finalImage = treeBase } else { log.Println("GenerateTrees timed out after 1 minute; continuing.") @@ -1240,7 +1253,7 @@ func main() { numWallsSlider, cityCoverageSlider, wallCurvynessSlider, - gateSpacingSlider, + gateCountSlider, showTurretsCheck, turretControls, )) @@ -1553,7 +1566,7 @@ func main() { numWallsSlider.value.Set(float64(settings.NumWalls)) cityCoverageSlider.value.Set(settings.CityCoverage) wallCurvynessSlider.value.Set(settings.WallCurvyness) - gateSpacingSlider.value.Set(settings.GateSpacing) + gateCountSlider.value.Set(float64(settings.GateCount)) showTurretsCheck.SetChecked(settings.ShowTurrets) turretSizeSlider.value.Set(settings.TurretSize) turretShapeSelect.SetSelected(settings.TurretShape)