Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6e6afc1
Add polygon startbox support with backwards-compatible validation
burnhamrobertp Apr 22, 2026
1b7a08d
Clean up style to match codebase conventions
burnhamrobertp Apr 23, 2026
972516c
Fix multi-polygon rendering for teams with non-contiguous startboxes
burnhamrobertp Apr 23, 2026
30ba930
Fix mapside startbox configs: gametype shim and key normalization
burnhamrobertp Apr 23, 2026
3d7bad0
Fix mapside startbox configs that use camelCase gametype methods
burnhamrobertp Apr 23, 2026
b09b822
Remove dead modside startbox config lookup
burnhamrobertp Apr 23, 2026
55ef4b4
Remove generic fallback boxes from ParseBoxes
burnhamrobertp Apr 23, 2026
0eccb53
Remove dead modside entry from EXPLICIT_SOURCES lookup table
burnhamrobertp Apr 29, 2026
62921a9
Add Catmull-Rom spline tessellation for startbox polygons
burnhamrobertp May 4, 2026
7e41a17
#7513 Remove Zero-K gametype compatibility shims from startbox utilities
burnhamrobertp May 11, 2026
e0623fc
#7513 Remove unused StartBoxes config placeholder
burnhamrobertp May 22, 2026
8d6d527
#7513 Route startboxes through mapmetadata modoptions
burnhamrobertp Jun 3, 2026
55f4cf6
#7513 Centralize startbox resolution chain
burnhamrobertp Jun 5, 2026
19054bb
#7513 Add cross-repo contract header to startbox resolution module
burnhamrobertp Jun 5, 2026
aa12e1a
#7513 Add smaller-direction fallback to startbox resolution
burnhamrobertp Jun 5, 2026
9afeace
Merge remote-tracking branch 'upstream/master' into feature/polygon-s…
burnhamrobertp Jun 10, 2026
1ae4a14
Use centripetal Catmull-Rom for startbox spline tessellation
burnhamrobertp Jun 18, 2026
48e894e
Use coordinate-agnostic x,y naming in lib_polygon
burnhamrobertp Jun 23, 2026
f9da7f9
Annotate spline anchors with SplineXY type
burnhamrobertp Jun 23, 2026
fa97411
Use Spring.GetTeamAllyTeamID for gaia ally team
burnhamrobertp Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions common/lib_polygon.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- Polygon math utilities for startbox containment checks.
-- Used by both synced gadgets and unsynced widgets.

local PolygonLib = {}

--- Point-in-polygon test using the ray-casting (crossing number) algorithm.
--- Handles convex and concave polygons correctly.
--- @param x number X coordinate
--- @param y number Y coordinate
--- @param vertices table array of {x, y} vertex pairs defining the polygon
--- @return boolean true if the point is inside the polygon
function PolygonLib.PointInPolygon(x, y, vertices)
local n = #vertices
if n < 3 then
return false
end

local inside = false
local j = n
for i = 1, n do
local xi, yi = vertices[i][1], vertices[i][2]
local xj, yj = vertices[j][1], vertices[j][2]

if (yi > y) ~= (yj > y) then
local intersectX = xj + (y - yj) * (xi - xj) / (yi - yj)
if x < intersectX then
inside = not inside
end
end

j = i
end

return inside
end

--- Check if a point is inside any polygon of a startbox entry.
--- A startbox entry may contain multiple disjoint polygons (entry.boxes).
--- @param x number X coordinate
--- @param y number Y coordinate
--- @param entry table startbox config entry with entry.boxes = {polygon1, polygon2, ...}
--- @return boolean true if the point is inside any sub-polygon
function PolygonLib.PointInStartbox(x, y, entry)
if not entry or not entry.boxes then
return false
end

for i = 1, #entry.boxes do
if PolygonLib.PointInPolygon(x, y, entry.boxes[i]) then
return true
end
end

return false
end

--- Compute the axis-aligned bounding box of all sub-polygons in a startbox entry.
--- @param entry table startbox config entry with entry.boxes = {polygon1, polygon2, ...}
--- @return number, number, number, number xmin, ymin, xmax, ymax
function PolygonLib.GetStartboxBounds(entry)
if not entry or not entry.boxes then
return 0, 0, 0, 0
end

local xmin, ymin = math.huge, math.huge
local xmax, ymax = -math.huge, -math.huge

for i = 1, #entry.boxes do
local polygon = entry.boxes[i]
for j = 1, #polygon do
local vx, vy = polygon[j][1], polygon[j][2]
if vx < xmin then xmin = vx end
if vx > xmax then xmax = vx end
if vy < ymin then ymin = vy end
if vy > ymax then ymax = vy end
end
end

return xmin, ymin, xmax, ymax
end

return PolygonLib
124 changes: 124 additions & 0 deletions common/lib_spline.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
-- Spline tessellation for startbox anchor rings.
-- Used by startbox_utilities.lua to convert spline-flagged polygons into dense
-- polygon vertex lists before they reach containment/rendering code.

local SplineLib = {}

local DEFAULT_SEGMENTS = 12

---@class SplineXY
---@field [1] number x coordinate
---@field [2] number y coordinate
---@field [3] number? strength Catmull-Rom anchor weight in [0,1]; absent or 0 is a sharp corner

local function clamp01(v)
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end

--- Sample a Catmull-Rom curve segment between p1 and p2 (with neighbours p0, p3),
--- blended toward the linear interpolation by `tension` in [0, 1].
--- tension == 0 returns the exact linear interpolation between p1 and p2.
--- tension == 1 returns the full uniform Catmull-Rom sample.
--- Anchor points lie on the curve regardless of tension because at t=0 and t=1
--- both linear and Catmull-Rom outputs coincide with p1 and p2.
local function knotDelta(ax, az, bx, bz)
local dx, dz = bx - ax, bz - az
return (dx * dx + dz * dz) ^ 0.25 -- |delta|^0.5 (alpha = 0.5, centripetal)
end

local function bgLerp(tt, ax, az, bx, bz, ta, tb)
local w = (tb - tt) / (tb - ta)
return w * ax + (1 - w) * bx, w * az + (1 - w) * bz
end

local function sampleSegment(p0, p1, p2, p3, t, tension)
local lx = p1[1] + (p2[1] - p1[1]) * t
local lz = p1[2] + (p2[2] - p1[2]) * t
if tension <= 0 then
return lx, lz
end

-- Centripetal Catmull-Rom (Barry-Goldman). Centripetal parameterization
-- avoids the cusps/self-intersections (the "curly-q" overshoot) that uniform
-- Catmull-Rom produces at sharp corners between unevenly spaced anchors.
local t0 = 0
local t1 = t0 + knotDelta(p0[1], p0[2], p1[1], p1[2])
local t2 = t1 + knotDelta(p1[1], p1[2], p2[1], p2[2])
local t3 = t2 + knotDelta(p2[1], p2[2], p3[1], p3[2])

local crX, crZ
if t2 - t1 <= 1e-9 then
crX, crZ = p1[1], p1[2] -- coincident segment endpoints
else
local tt = t1 + (t2 - t1) * t
local A1x, A1z = p1[1], p1[2]
if t1 - t0 > 1e-9 then
A1x, A1z = bgLerp(tt, p0[1], p0[2], p1[1], p1[2], t0, t1)
end
local A2x, A2z = bgLerp(tt, p1[1], p1[2], p2[1], p2[2], t1, t2)
local A3x, A3z = p2[1], p2[2]
if t3 - t2 > 1e-9 then
A3x, A3z = bgLerp(tt, p2[1], p2[2], p3[1], p3[2], t2, t3)
end
local B1x, B1z = bgLerp(tt, A1x, A1z, A2x, A2z, t0, t2)
local B2x, B2z = bgLerp(tt, A2x, A2z, A3x, A3z, t1, t3)
crX, crZ = bgLerp(tt, B1x, B1z, B2x, B2z, t1, t2)
end

if tension >= 1 then
return crX, crZ
end
return lx + (crX - lx) * tension, lz + (crZ - lz) * tension
end

--- Tessellate a closed ring of anchor points into a polygon.
--- Every polygon goes through this function — plain polygons are a degenerate
--- case where all anchor strengths are zero, producing vertex-identical output
--- to the input. An anchor with a missing 3rd element is treated as strength 0
--- (sharp corner): if the caller didn't ask for curvature, they don't get it.
--- @param anchors SplineXY[] closed ring of anchors (closed implicitly).
--- @param opts table|nil { segments = number }
--- segments: subdivisions per curved edge (default 12). Test-time hook;
--- production callers omit this and accept the default.
--- @return SplineXY[] polygon vertices suitable for the existing polygon consumers.
function SplineLib.TessellateRing(anchors, opts)
local n = #anchors
if n < 2 then
local out = {}
for i = 1, n do
out[i] = { anchors[i][1], anchors[i][2] }
end
return out
end

local segments = (opts and opts.segments) or DEFAULT_SEGMENTS
if segments < 1 then segments = 1 end

local out = {}
for i = 1, n do
local iPrev = ((i - 2) % n) + 1
local iNext = (i % n) + 1
local iNext2 = (iNext % n) + 1
local p0 = anchors[iPrev]
local p1 = anchors[i]
local p2 = anchors[iNext]
local p3 = anchors[iNext2]

local s1 = p1[3]; if s1 == nil then s1 = 0 end
local s2 = p2[3]; if s2 == nil then s2 = 0 end
local edgeTension = clamp01((clamp01(s1) + clamp01(s2)) * 0.5)

out[#out + 1] = { p1[1], p1[2] }
if edgeTension > 0 and n >= 3 then
for k = 1, segments - 1 do
local x, z = sampleSegment(p0, p1, p2, p3, k / segments, edgeTension)
out[#out + 1] = { x, z }
end
end
end
return out
end

return SplineLib
14 changes: 14 additions & 0 deletions luarules/Utilities/damgam_lib/position_checks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,20 @@ local function StartboxCheck(posx, posy, posz, allyTeamID, returnTrueWhenNoStart
if allyTeamID == GaiaAllyTeamID then
return not returnTrueWhenNoStartbox
end

-- try polygon-aware check first (available when explicit polygon config is loaded)
if GG.IsInsideStartbox then
local polygonResult = GG.IsInsideStartbox(posx, posz, allyTeamID)
if polygonResult ~= nil then
if polygonResult then
return not returnTrueWhenNoStartbox
else
return returnTrueWhenNoStartbox
end
end
end

-- fall back to engine AABB
local startbox = AllyTeamStartboxes[allyTeamID+1]

if startbox.allyTeamHasStartbox == false then
Expand Down
36 changes: 27 additions & 9 deletions luarules/gadgets/game_initial_spawn.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ if gadgetHandler:IsSyncedCode() then

local getValidRandom, isUnitValid

local function getEffectiveStartboxBounds(allyTeamID)
if GG.GetStartboxBounds then
local xmin, zmin, xmax, zmax = GG.GetStartboxBounds(allyTeamID)
if xmin then
return xmin, zmin, xmax, zmax
end
end
return spGetAllyTeamStartBox(allyTeamID)
end

local function updateAIManualPlacement(teamID, x, z)
if allowEnemyAIPlacement then
if x and z then
Expand Down Expand Up @@ -464,15 +474,23 @@ if gadgetHandler:IsSyncedCode() then
if allyTeamID == nil then
return false
end
local xmin, zmin, xmax, zmax = spGetAllyTeamStartBox(allyTeamID)
if xmin >= xmax or zmin >= zmax then
return true
else
local isOutsideStartbox = (xmin + 1 >= x) or (x >= xmax - 1) or (zmin + 1 >= z) or
(z >= zmax - 1) -- the engine rounds startpoints to integers but does not round the startbox (wtf)
if isOutsideStartbox then

local polygonResult = GG.IsInsideStartbox and GG.IsInsideStartbox(x, z, allyTeamID)
if polygonResult ~= nil then
if not polygonResult then
return false
end
else
local xmin, zmin, xmax, zmax = spGetAllyTeamStartBox(allyTeamID)
if xmin >= xmax or zmin >= zmax then
-- no startbox defined, allow placement anywhere
else
local isOutsideStartbox = (xmin + 1 >= x) or (x >= xmax - 1) or (zmin + 1 >= z) or
(z >= zmax - 1) -- the engine rounds startpoints to integers but does not round the startbox (wtf)
if isOutsideStartbox then
return false
end
end
end

if isFootingUntraversable(x,y,z, tonumber(spGetTeamRulesParam(teamID, startUnitParamName))) then
Expand Down Expand Up @@ -622,7 +640,7 @@ if gadgetHandler:IsSyncedCode() then

local function spawnRegularly(teamID, allyTeamID)
local x, _, z = Spring.GetTeamStartPosition(teamID)
local xmin, zmin, xmax, zmax = spGetAllyTeamStartBox(allyTeamID)
local xmin, zmin, xmax, zmax = getEffectiveStartboxBounds(allyTeamID)

if Game.startPosType == SPAWN_CHOOSE_IN_GAME then
if not startPointTable[teamID] or startPointTable[teamID][1] < 0 then
Expand Down Expand Up @@ -655,7 +673,7 @@ if gadgetHandler:IsSyncedCode() then
end

if needsPosition then
local xmin, zmin, xmax, zmax = spGetAllyTeamStartBox(allyTeamID)
local xmin, zmin, xmax, zmax = getEffectiveStartboxBounds(allyTeamID)
local guessedX, guessedZ = GuessStartSpot(teamID, allyTeamID, xmin, zmin, xmax, zmax, startPointTable)
if guessedX and guessedZ then
local y = spGetGroundHeight(guessedX, guessedZ)
Expand Down
92 changes: 92 additions & 0 deletions luarules/gadgets/game_startbox_config.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
local gadget = gadget ---@type Gadget

function gadget:GetInfo()
return {
name = 'Startbox Config',
desc = 'Loads polygon startbox configurations and provides containment checks via GG',
author = 'Harkenn',
date = '2026',
license = 'GNU GPL, v2 or later',
layer = -999, -- after FFA start setup (-1000), before initial spawn (0) and no-rush (-100)
enabled = true
}
end

if not gadgetHandler:IsSyncedCode() then
return false
end

local PolygonLib = VFS.Include("common/lib_polygon.lua")

local startBoxConfig
local configSource
local isExplicitConfig = false

function gadget:Initialize()
local ParseBoxes = VFS.Include("luarules/gadgets/include/startbox_utilities.lua")
local ok, config, source, isExplicit = pcall(ParseBoxes)
if ok then
startBoxConfig = config
configSource = source
isExplicitConfig = isExplicit
else
Spring.Log(gadget:GetInfo().name, LOG.WARNING, 'Failed to parse startbox config: ' .. tostring(config))
end

-- Expand the engine AABB for each active allyTeam to cover the polygon bounds.
-- Without this, the engine silently drops clicks outside its default AABB and never
-- calls AllowStartPosition, making polygons that extend beyond the lobby's
-- rectangle unreachable.
if isExplicitConfig and startBoxConfig then
local allyTeamList = Spring.GetAllyTeamList()
for _, allyTeamID in ipairs(allyTeamList) do
local entry = startBoxConfig[allyTeamID]
if entry and entry.boxes then
local xmin, zmin, xmax, zmax = PolygonLib.GetStartboxBounds(entry)
Spring.SetAllyTeamStartBox(allyTeamID, xmin, zmin, xmax, zmax)
end
end
end

GG.startBoxConfig = startBoxConfig
GG.startBoxConfigSource = configSource

GG.IsInsideStartbox = function(x, z, allyTeamID)
if not isExplicitConfig then
return nil -- caller should fall back to engine AABB
end

local entry = startBoxConfig[allyTeamID]
if not entry then
return nil -- no polygon config for this allyTeam
end

return PolygonLib.PointInStartbox(x, z, entry)
end

GG.GetStartboxBounds = function(allyTeamID)
if not isExplicitConfig then
return nil -- caller should fall back to engine AABB
end

local entry = startBoxConfig[allyTeamID]
if not entry then
return nil
end

return PolygonLib.GetStartboxBounds(entry)
end

GG.GetStartboxPolygons = function(allyTeamID)
if not isExplicitConfig then
return nil
end

local entry = startBoxConfig[allyTeamID]
if not entry then
return nil
end

return entry.boxes
end
end
Loading
Loading