2026-03-02 10:40:14 -06:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"math"
|
|
|
|
|
"math/rand"
|
|
|
|
|
"sort"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
minWallWidthPercent = minBuildingSizePercent
|
|
|
|
|
maxWallWidthPercent = maxBuildingSizePercent
|
|
|
|
|
wallWidthPercentStep = buildingSizePercentStep
|
2026-03-02 12:50:57 -06:00
|
|
|
|
|
|
|
|
minTurretSizePercent = 0.2
|
|
|
|
|
maxTurretSizePercent = maxWallWidthPercent
|
|
|
|
|
turretSizePercentStep = 0.1
|
2026-03-02 10:40:14 -06:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func clampWallWidthPercent(v float64) float64 {
|
|
|
|
|
if v < minWallWidthPercent {
|
|
|
|
|
return minWallWidthPercent
|
|
|
|
|
}
|
|
|
|
|
if v > maxWallWidthPercent {
|
|
|
|
|
return maxWallWidthPercent
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func snapWallWidthPercent(v float64) float64 {
|
|
|
|
|
v = clampWallWidthPercent(v)
|
|
|
|
|
steps := math.Round((v - minWallWidthPercent) / wallWidthPercentStep)
|
|
|
|
|
return clampWallWidthPercent(minWallWidthPercent + steps*wallWidthPercentStep)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeWallWidthPercentRange(minPercent, maxPercent float64) (float64, float64) {
|
|
|
|
|
minPercent = snapWallWidthPercent(minPercent)
|
|
|
|
|
maxPercent = snapWallWidthPercent(maxPercent)
|
|
|
|
|
if minPercent > maxPercent {
|
|
|
|
|
minPercent, maxPercent = maxPercent, minPercent
|
|
|
|
|
}
|
|
|
|
|
return minPercent, maxPercent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getWallWidthRangePixels(settings *Settings, width, height int) (float64, float64) {
|
|
|
|
|
minPercent, maxPercent := normalizeWallWidthPercentRange(settings.MinWallWidth, settings.MaxWallWidth)
|
|
|
|
|
avgDim := averageImageDimension(width, height)
|
|
|
|
|
if avgDim < 1 {
|
|
|
|
|
avgDim = 1
|
|
|
|
|
}
|
|
|
|
|
minPx := (minPercent / 100.0) * avgDim
|
|
|
|
|
maxPx := (maxPercent / 100.0) * avgDim
|
|
|
|
|
if minPx < 1 {
|
|
|
|
|
minPx = 1
|
|
|
|
|
}
|
|
|
|
|
if maxPx < 1 {
|
|
|
|
|
maxPx = 1
|
|
|
|
|
}
|
|
|
|
|
return minPx, maxPx
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 12:50:57 -06:00
|
|
|
func clampTurretSizePercent(v float64) float64 {
|
|
|
|
|
if v < minTurretSizePercent {
|
|
|
|
|
return minTurretSizePercent
|
|
|
|
|
}
|
|
|
|
|
if v > maxTurretSizePercent {
|
|
|
|
|
return maxTurretSizePercent
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func snapTurretSizePercent(v float64) float64 {
|
|
|
|
|
v = clampTurretSizePercent(v)
|
|
|
|
|
steps := math.Round((v - minTurretSizePercent) / turretSizePercentStep)
|
|
|
|
|
return clampTurretSizePercent(minTurretSizePercent + steps*turretSizePercentStep)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getTurretSizePixels(settings *Settings, width, height int) float64 {
|
|
|
|
|
sizePercent := snapTurretSizePercent(settings.TurretSize)
|
|
|
|
|
avgDim := averageImageDimension(width, height)
|
|
|
|
|
if avgDim < 1 {
|
|
|
|
|
avgDim = 1
|
|
|
|
|
}
|
|
|
|
|
sizePx := (sizePercent / 100.0) * avgDim
|
|
|
|
|
if sizePx < 1 {
|
|
|
|
|
sizePx = 1
|
|
|
|
|
}
|
|
|
|
|
return sizePx
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// GateInfo describes one traversable gate cut through a wall ring.
|
2026-03-11 12:43:41 -05:00
|
|
|
type GateInfo struct {
|
2026-03-12 12:22:09 -05:00
|
|
|
WallID int
|
2026-03-23 10:03:22 -05:00
|
|
|
Center image.Point
|
|
|
|
|
Normal [2]float64
|
|
|
|
|
LeftTurret image.Point
|
|
|
|
|
RightTurret image.Point
|
|
|
|
|
InnerEnd image.Point
|
|
|
|
|
OuterEnd image.Point
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// FortificationLayout holds the final fortification geometry and masks.
|
2026-03-02 10:40:14 -06:00
|
|
|
type FortificationLayout struct {
|
|
|
|
|
Mask *PixelMask
|
|
|
|
|
WallIDByPixel []int
|
|
|
|
|
Coverages []float64
|
2026-03-11 12:43:41 -05:00
|
|
|
Gates []GateInfo
|
2026-03-23 10:03:22 -05:00
|
|
|
GateMask *PixelMask
|
|
|
|
|
Turrets []TurretPlacement
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// 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.
|
2026-03-02 10:40:14 -06:00
|
|
|
func GenerateFortifications(
|
|
|
|
|
img *image.RGBA,
|
|
|
|
|
width, height int,
|
|
|
|
|
settings *Settings,
|
|
|
|
|
waterMask *PixelMask,
|
|
|
|
|
roadNodes []*PointOfInterest,
|
|
|
|
|
seed int64,
|
|
|
|
|
) (*FortificationLayout, [][]image.Point) {
|
|
|
|
|
layout := &FortificationLayout{
|
|
|
|
|
Mask: NewPixelMask(width, height),
|
|
|
|
|
WallIDByPixel: make([]int, width*height),
|
2026-03-23 10:03:22 -05:00
|
|
|
GateMask: NewPixelMask(width, height),
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if settings == nil || settings.NumWalls <= 0 || settings.CityCoverage <= 0 || width <= 0 || height <= 0 {
|
2026-03-02 10:40:14 -06:00
|
|
|
return layout, nil
|
|
|
|
|
}
|
|
|
|
|
if img == nil {
|
|
|
|
|
img = image.NewRGBA(image.Rect(0, 0, width, height))
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if waterMask == nil {
|
|
|
|
|
waterMask = NewPixelMask(width, height)
|
|
|
|
|
}
|
2026-03-02 10:40:14 -06:00
|
|
|
|
|
|
|
|
randSrc := rand.New(rand.NewSource(seed))
|
2026-03-23 10:03:22 -05:00
|
|
|
minWallWidthPx, maxWallWidthPx := getWallWidthRangePixels(settings, width, height)
|
2026-03-02 10:40:14 -06:00
|
|
|
outerCoverage := clamp(settings.CityCoverage, 1, 100)
|
|
|
|
|
totalWalls := max(1, settings.NumWalls)
|
|
|
|
|
layout.Coverages = make([]float64, 0, totalWalls)
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
for wallIndex := 0; wallIndex < totalWalls; wallIndex++ {
|
|
|
|
|
coverage := outerCoverage * float64(totalWalls-wallIndex) / float64(totalWalls)
|
2026-03-02 10:40:14 -06:00
|
|
|
coverage = clamp(coverage, 1, 100)
|
|
|
|
|
layout.Coverages = append(layout.Coverages, coverage)
|
|
|
|
|
|
|
|
|
|
nodes := estimateWallNodeCount(coverage)
|
2026-03-23 10:03:22 -05:00
|
|
|
loop := generateWallLoop(width, height, coverage, settings.WallCurvyness, nodes, randSrc, roadNodes)
|
|
|
|
|
if len(loop) < 3 {
|
2026-03-02 10:40:14 -06:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
wallWidthPx := minWallWidthPx
|
|
|
|
|
if maxWallWidthPx > minWallWidthPx {
|
|
|
|
|
wallWidthPx += randSrc.Float64() * (maxWallWidthPx - minWallWidthPx)
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
wallWidth := max(1, int(math.Round(wallWidthPx)))
|
|
|
|
|
wallID := wallIndex + 1
|
|
|
|
|
|
|
|
|
|
runs := splitWallPathByWater(loop, waterMask)
|
|
|
|
|
if len(runs) == 0 {
|
|
|
|
|
continue
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
samples := rasterizeWallRuns(layout, runs, wallWidth, wallID)
|
|
|
|
|
if len(samples) == 0 {
|
|
|
|
|
continue
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
|
|
|
|
|
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)...)
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
layout.Turrets = dedupeTurretPlacements(layout.Turrets, width, height)
|
|
|
|
|
drawWallMask(img, layout.Mask)
|
|
|
|
|
return layout, nil
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
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(
|
2026-03-11 12:43:41 -05:00
|
|
|
layout *FortificationLayout,
|
|
|
|
|
settings *Settings,
|
2026-03-23 10:03:22 -05:00
|
|
|
samples []wallSample,
|
|
|
|
|
center image.Point,
|
|
|
|
|
wallIndex, wallID, wallWidth int,
|
|
|
|
|
turretSizePx float64,
|
2026-03-11 12:43:41 -05:00
|
|
|
width, height int,
|
2026-03-23 10:03:22 -05:00
|
|
|
) ([]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 {
|
2026-03-11 12:43:41 -05:00
|
|
|
return
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
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 {
|
2026-03-11 12:43:41 -05:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
blocked[candidate.Index] = true
|
|
|
|
|
turrets = append(turrets, TurretPlacement{WallID: wallID, Center: candidate.Point, Angle: candidate.Angle})
|
|
|
|
|
}
|
|
|
|
|
return turrets
|
|
|
|
|
}
|
2026-03-11 12:43:41 -05:00
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
func estimateWallNodeCount(coverage float64) int {
|
2026-03-23 10:03:22 -05:00
|
|
|
nodes := int(math.Round(20 + coverage*0.7))
|
|
|
|
|
if nodes < 20 {
|
|
|
|
|
nodes = 20
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if nodes > 96 {
|
|
|
|
|
nodes = 96
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
return nodes
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// generateWallLoop builds a closed wall path using two sine waves for large and small curvature.
|
2026-03-02 10:40:14 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
centerX, centerY, radiusX, radiusY := wallEllipseFromRoadNodes(width, height, coverage, roadNodes)
|
2026-03-02 10:40:14 -06:00
|
|
|
curveScale := clamp(curvyness, 0, 100) / 100.0
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-02 10:40:14 -06:00
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
points := make([]image.Point, 0, nodes+1)
|
2026-03-02 10:40:14 -06:00
|
|
|
for i := 0; i < nodes; i++ {
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
x := int(math.Round(centerX + radiusX*warp*math.Cos(t)))
|
|
|
|
|
y := int(math.Round(centerY + radiusY*warp*math.Sin(t)))
|
2026-03-02 10:40:14 -06:00
|
|
|
if x < 0 {
|
|
|
|
|
x = 0
|
|
|
|
|
}
|
|
|
|
|
if y < 0 {
|
|
|
|
|
y = 0
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if x >= width {
|
|
|
|
|
x = width - 1
|
|
|
|
|
}
|
2026-03-02 10:40:14 -06:00
|
|
|
if y >= height {
|
|
|
|
|
y = height - 1
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
points = append(points, image.Point{X: x, Y: y})
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if len(points) > 0 {
|
|
|
|
|
points = append(points, points[0])
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
return points
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func wallEllipseFromRoadNodes(width, height int, coverage float64, roadNodes []*PointOfInterest) (centerX, centerY, radiusX, radiusY float64) {
|
|
|
|
|
centerX = float64(width-1) * 0.5
|
|
|
|
|
centerY = float64(height-1) * 0.5
|
|
|
|
|
coverageRadius := math.Sqrt(clamp(coverage, 1, 100) / 100.0)
|
|
|
|
|
radiusX = centerX * coverageRadius
|
|
|
|
|
radiusY = centerY * coverageRadius
|
|
|
|
|
|
|
|
|
|
if len(roadNodes) == 0 {
|
|
|
|
|
return centerX, centerY, radiusX, radiusY
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
var sumX, sumY float64
|
|
|
|
|
for _, node := range roadNodes {
|
|
|
|
|
sumX += float64(node.X)
|
|
|
|
|
sumY += float64(node.Y)
|
2026-03-02 10:40:14 -06:00
|
|
|
}
|
|
|
|
|
centerX = sumX / float64(len(roadNodes))
|
|
|
|
|
centerY = sumY / float64(len(roadNodes))
|
|
|
|
|
|
|
|
|
|
dists := make([]float64, 0, len(roadNodes))
|
|
|
|
|
var sx, sy float64
|
2026-03-23 10:03:22 -05:00
|
|
|
for _, node := range roadNodes {
|
|
|
|
|
dx := float64(node.X) - centerX
|
|
|
|
|
dy := float64(node.Y) - centerY
|
2026-03-02 10:40:14 -06:00
|
|
|
dists = append(dists, math.Hypot(dx, dy))
|
|
|
|
|
sx += dx * dx
|
|
|
|
|
sy += dy * dy
|
|
|
|
|
}
|
|
|
|
|
sort.Float64s(dists)
|
|
|
|
|
q := clamp(coverage, 1, 100) / 100.0
|
|
|
|
|
idx := int(math.Ceil(q*float64(len(dists)))) - 1
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
idx = 0
|
|
|
|
|
}
|
|
|
|
|
if idx >= len(dists) {
|
|
|
|
|
idx = len(dists) - 1
|
|
|
|
|
}
|
|
|
|
|
baseRadius := dists[idx]
|
|
|
|
|
if baseRadius < 10 {
|
|
|
|
|
baseRadius = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stdX := math.Sqrt(sx / float64(len(roadNodes)))
|
|
|
|
|
stdY := math.Sqrt(sy / float64(len(roadNodes)))
|
|
|
|
|
aspect := 1.0
|
|
|
|
|
if stdY > 0.001 {
|
|
|
|
|
aspect = stdX / stdY
|
|
|
|
|
}
|
|
|
|
|
aspect = clamp(aspect, 0.65, 1.55)
|
|
|
|
|
radiusX = baseRadius * aspect
|
|
|
|
|
radiusY = baseRadius / aspect
|
|
|
|
|
|
|
|
|
|
maxRadiusX := math.Max(5, math.Min(centerX, float64(width-1)-centerX))
|
|
|
|
|
maxRadiusY := math.Max(5, math.Min(centerY, float64(height-1)-centerY))
|
|
|
|
|
radiusX = clamp(radiusX, 5, maxRadiusX)
|
|
|
|
|
radiusY = clamp(radiusY, 5, maxRadiusY)
|
|
|
|
|
|
|
|
|
|
return centerX, centerY, radiusX, radiusY
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 12:43:41 -05:00
|
|
|
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)
|
2026-03-23 10:03:22 -05:00
|
|
|
x0, y0 := a.X, a.Y
|
|
|
|
|
x1, y1 := b.X, b.Y
|
2026-03-11 12:43:41 -05:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 10:40:14 -06:00
|
|
|
func drawSegmentSelective(x0, y0, x1, y1 int, plot func(x, y int)) {
|
|
|
|
|
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 {
|
|
|
|
|
plot(x0, y0)
|
|
|
|
|
if x0 == x1 && y0 == y1 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
e2 := 2 * err
|
|
|
|
|
if e2 > -dy {
|
|
|
|
|
err -= dy
|
|
|
|
|
x0 += sx
|
|
|
|
|
}
|
|
|
|
|
if e2 < dx {
|
|
|
|
|
err += dx
|
|
|
|
|
y0 += sy
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// drawWallMask paints the wall mask and is used for final fortification redraws.
|
2026-03-02 11:20:57 -06:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-02 12:50:57 -06:00
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// GenerateTurrets renders all configured turret placements and returns their mask.
|
2026-03-02 12:50:57 -06:00
|
|
|
func GenerateTurrets(
|
|
|
|
|
img *image.RGBA,
|
|
|
|
|
width, height int,
|
|
|
|
|
settings *Settings,
|
|
|
|
|
layout *FortificationLayout,
|
2026-03-23 10:03:22 -05:00
|
|
|
_ *PixelMask,
|
|
|
|
|
_ *PixelMask,
|
|
|
|
|
_ []*Road,
|
2026-03-02 12:50:57 -06:00
|
|
|
) *PixelMask {
|
|
|
|
|
mask := NewPixelMask(width, height)
|
|
|
|
|
if img == nil {
|
|
|
|
|
img = image.NewRGBA(image.Rect(0, 0, width, height))
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if settings == nil || !settings.ShowTurrets || layout == nil {
|
|
|
|
|
return mask
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
radius := max(1, int(math.Round(getTurretSizePixels(settings, width, height)/2.0)))
|
2026-03-02 12:50:57 -06:00
|
|
|
shape := settings.TurretShape
|
|
|
|
|
if shape != "square" {
|
|
|
|
|
shape = "circular"
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
turretColor := color.RGBA{R: 220, G: 25, B: 25, A: 255}
|
2026-03-02 12:50:57 -06:00
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
for _, turret := range layout.Turrets {
|
|
|
|
|
drawTurret(img, mask, turret.Center, turret.Angle, radius, shape, turretColor)
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|
|
|
|
|
return mask
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
2026-03-02 12:50:57 -06:00
|
|
|
if !mask.InBounds(x, y) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-02 12:50:57 -06:00
|
|
|
mask.SetXY(x, y)
|
|
|
|
|
img.Set(x, y, col)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
// 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
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
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)
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:03:22 -05:00
|
|
|
func clampPoint(p image.Point, width, height int) image.Point {
|
|
|
|
|
if p.X < 0 {
|
|
|
|
|
p.X = 0
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if p.Y < 0 {
|
|
|
|
|
p.Y = 0
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if p.X >= width {
|
|
|
|
|
p.X = width - 1
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
if p.Y >= height {
|
|
|
|
|
p.Y = height - 1
|
2026-03-11 12:43:41 -05:00
|
|
|
}
|
2026-03-23 10:03:22 -05:00
|
|
|
return p
|
2026-03-02 12:50:57 -06:00
|
|
|
}
|