diff --git a/common/lib_polygon.lua b/common/lib_polygon.lua new file mode 100644 index 00000000000..492f9458f53 --- /dev/null +++ b/common/lib_polygon.lua @@ -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 diff --git a/common/lib_spline.lua b/common/lib_spline.lua new file mode 100644 index 00000000000..412dbf86f0f --- /dev/null +++ b/common/lib_spline.lua @@ -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 diff --git a/luarules/Utilities/damgam_lib/position_checks.lua b/luarules/Utilities/damgam_lib/position_checks.lua index 5c7637220fd..b1295cef75b 100644 --- a/luarules/Utilities/damgam_lib/position_checks.lua +++ b/luarules/Utilities/damgam_lib/position_checks.lua @@ -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 diff --git a/luarules/gadgets/game_initial_spawn.lua b/luarules/gadgets/game_initial_spawn.lua index 4d128635d75..699fcb8ff0a 100644 --- a/luarules/gadgets/game_initial_spawn.lua +++ b/luarules/gadgets/game_initial_spawn.lua @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/luarules/gadgets/game_startbox_config.lua b/luarules/gadgets/game_startbox_config.lua new file mode 100644 index 00000000000..b4b9772143c --- /dev/null +++ b/luarules/gadgets/game_startbox_config.lua @@ -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 diff --git a/luarules/gadgets/include/startbox_utilities.lua b/luarules/gadgets/include/startbox_utilities.lua index bd5f4e132be..e4475e3b79f 100644 --- a/luarules/gadgets/include/startbox_utilities.lua +++ b/luarules/gadgets/include/startbox_utilities.lua @@ -1,11 +1,17 @@ -local function WrappedInclude(x) - local env = getfenv() - local prevGTC = env.GetTeamCount -- typically nil but also works otherwise - env.GetTeamCount = Spring.Utilities.GetAllyTeamCount -- for legacy mapside boxes - local ret = VFS.Include(x, env) - env.GetTeamCount = prevGTC - return ret -end +-- Startbox resolution: canonical contract for lobby implementations. +-- The lobby (bar-lobby, BYAR-Chobby) produces these modoptions; this module +-- consumes them. Any lobby-side resolution must mirror resolveArrangement +-- below so previews match what the game enforces. +-- +-- Modoptions (declared in modoptions.lua): +-- mapmetadata_startboxes_set base64url(zlib(json)) of { num_teams_str: arrangement, ... } +-- mapmetadata_startbox_override base64url(zlib(json)) of a single arrangement +-- +-- arrangement shape == maps-metadata `startboxesInfo`: +-- https://github.com/beyond-all-reason/maps-metadata schemas/map_list.yaml + +local SplineLib = VFS.Include("common/lib_spline.lua") +local base64 = VFS.Include("common/luaUtilities/base64.lua") local function GetStartboxName(midX, midZ) if (midX < 0.33) then @@ -35,111 +41,263 @@ local function GetStartboxName(midX, midZ) end end -local function ParseBoxes () - local mapsideBoxes = "mapconfig/map_startboxes.lua" +local function decodeModoption(raw) + if not raw or #raw == 0 then + return nil + end - local startBoxConfig + local okDecode, decoded = pcall(base64.Decode, raw) + if not okDecode or not decoded then + return nil + end - if VFS.FileExists (mapsideBoxes) then - startBoxConfig = WrappedInclude (mapsideBoxes) - else - startBoxConfig = { } - local startboxString = Spring.GetModOptions().startboxes - local startboxStringLoadedBoxes = false - if startboxString then - local springieBoxes = loadstring(startboxString)() - for id, box in pairs(springieBoxes) do - startboxStringLoadedBoxes = true -- Autohost always sends a table. Often it is empty. - local midX = (box[1]+box[3]) / 2 - local midZ = (box[2]+box[4]) / 2 - - box[1] = box[1]*Game.mapSizeX - box[2] = box[2]*Game.mapSizeZ - box[3] = box[3]*Game.mapSizeX - box[4] = box[4]*Game.mapSizeZ - - local longName, shortName = GetStartboxName(midX, midZ) - - startBoxConfig[id] = { - boxes = {{ - {box[1], box[2]}, - {box[1], box[4]}, - {box[3], box[4]}, - {box[3], box[2]}, - }}, - startpoints = { - {(box[1]+box[3]) / 2, (box[2]+box[4]) / 2} - }, - nameLong = longName, - nameShort = shortName - } - end + local decompressed = VFS.ZlibDecompress(decoded) + if not decompressed then + return nil + end + + local okJson, parsed = pcall(Json.decode, decompressed) + if not okJson or type(parsed) ~= "table" then + return nil + end + + return parsed +end + +local function getActiveAllyTeamCount() + local gaiaAllyTeamID + local gaiaTeamID = Spring.GetGaiaTeamID() + if gaiaTeamID then + gaiaAllyTeamID = Spring.GetTeamAllyTeamID(gaiaTeamID) + end + + local count = 0 + for _, atID in ipairs(Spring.GetAllyTeamList()) do + if atID ~= gaiaAllyTeamID then + count = count + 1 + end + end + + return count +end + +local function matchOverride(override, numTeams) + if override and override.startboxes and #override.startboxes == numTeams then + return override + end + + return nil +end + +local function matchSetExact(set, numTeams) + if not set then return nil end + + return set[tostring(numTeams)] +end + +local function matchSetLarger(set, numTeams) + if not set then return nil end + + local bestKey, bestNum + for k in pairs(set) do + local kn = tonumber(k) + if kn and kn > numTeams and (not bestNum or kn < bestNum) then + bestKey = k + bestNum = kn + end + end + + return bestKey and set[bestKey] or nil +end + +local function matchSetSmaller(set, numTeams) + if not set then return nil end + + local bestKey, bestNum + for k in pairs(set) do + local kn = tonumber(k) + if kn and kn < numTeams and (not bestNum or kn > bestNum) then + bestKey = k + bestNum = kn + end + end + + return bestKey and set[bestKey] or nil +end + +local function resolveArrangement(override, set, numTeams) + local match = matchOverride(override, numTeams) + if match then return match, "modoption_override" end + + match = matchSetExact(set, numTeams) + if match then return match, "modoption_set" end + + match = matchSetLarger(set, numTeams) + if match then return match, "modoption_set" end + + match = matchSetSmaller(set, numTeams) + if match then return match, "modoption_set" end + + -- No modoption arrangement applies; defer to the engine startrect. + return nil, nil +end + +local function isExplicitSource(configSource) + return configSource == "modoption_override" or configSource == "modoption_set" +end + +local function expandPoly(poly) + if #poly == 2 then + local x1, z1 = poly[1].x, poly[1].y + local x2, z2 = poly[2].x, poly[2].y + + return { + { x1, z1 }, + { x2, z1 }, + { x2, z2 }, + { x1, z2 }, + } + end + + local out = {} + for i, p in ipairs(poly) do + if p.strength ~= nil then + out[i] = { p.x, p.y, p.strength } + else + out[i] = { p.x, p.y } end + end - if not startboxStringLoadedBoxes then - local mapSizeX = Game.mapSizeX - local mapSizeZ = Game.mapSizeZ - if mapSizeZ > mapSizeX then - startBoxConfig[0] = { - boxes = {{ - {0, 0}, - {0, mapSizeZ * 0.2}, - {mapSizeX, mapSizeZ * 0.2}, - {mapSizeX, 0} - }}, - startpoints = { - {mapSizeX * 0.5, mapSizeZ * 0.1} - }, - nameLong = "North", - nameShort = "N" - } - startBoxConfig[1] = { - boxes = {{ - {0, mapSizeZ * 0.8}, - {0, mapSizeZ}, - {mapSizeX, mapSizeZ}, - {mapSizeX, mapSizeZ * 0.8} - }}, - startpoints = { - {mapSizeX * 0.5, mapSizeZ * 0.9} - }, - nameLong = "South", - nameShort = "S" - } + return out +end + +local function transformArrangement(arrangement) + local config = {} + local mapSizeX, mapSizeZ = Game.mapSizeX, Game.mapSizeZ + local scaleX, scaleZ = mapSizeX / 200, mapSizeZ / 200 + + for i, box in ipairs(arrangement.startboxes) do + local allyTeamID = i - 1 + local poly = expandPoly(box.poly) + + local elmoPolygon = {} + local sumX, sumZ = 0, 0 + for j, p in ipairs(poly) do + local x = p[1] * scaleX + local z = p[2] * scaleZ + if p[3] ~= nil then + elmoPolygon[j] = { x, z, p[3] } else - startBoxConfig[0] = { - boxes = {{ - {0, 0}, - {0, mapSizeZ}, - {mapSizeX * 0.2, mapSizeZ}, - {mapSizeX * 0.2, 0}, - }}, - startpoints = { - {mapSizeX * 0.1, mapSizeZ * 0.5} - }, - nameLong = "West", - nameShort = "W" - } - startBoxConfig[1] = { - boxes = {{ - {mapSizeX * 0.8, 0}, - {mapSizeX * 0.8, mapSizeZ - 1}, - {mapSizeX, mapSizeZ - 1}, - {mapSizeX, 0}, - }}, - startpoints = { - {mapSizeX * 0.9, mapSizeZ * 0.5} - }, - nameLong = "East", - nameShort = "E" - } + elmoPolygon[j] = { x, z } + end + sumX = sumX + x + sumZ = sumZ + z + end + + local count = #elmoPolygon + local centerX = sumX / count + local centerZ = sumZ / count + local nameLong, nameShort = GetStartboxName(centerX / mapSizeX, centerZ / mapSizeZ) + + config[allyTeamID] = { + boxes = { elmoPolygon }, + startpoints = { { centerX, centerZ } }, + nameLong = nameLong, + nameShort = nameShort, + } + end + + return config +end + +local function buildFallback() + local mapSizeX = Game.mapSizeX + local mapSizeZ = Game.mapSizeZ + + if mapSizeZ > mapSizeX then + return { + [0] = { + boxes = {{ + {0, 0}, + {0, mapSizeZ * 0.2}, + {mapSizeX, mapSizeZ * 0.2}, + {mapSizeX, 0}, + }}, + startpoints = {{ mapSizeX * 0.5, mapSizeZ * 0.1 }}, + nameLong = "North", + nameShort = "N", + }, + [1] = { + boxes = {{ + {0, mapSizeZ * 0.8}, + {0, mapSizeZ}, + {mapSizeX, mapSizeZ}, + {mapSizeX, mapSizeZ * 0.8}, + }}, + startpoints = {{ mapSizeX * 0.5, mapSizeZ * 0.9 }}, + nameLong = "South", + nameShort = "S", + }, + } + end + + return { + [0] = { + boxes = {{ + {0, 0}, + {0, mapSizeZ}, + {mapSizeX * 0.2, mapSizeZ}, + {mapSizeX * 0.2, 0}, + }}, + startpoints = {{ mapSizeX * 0.1, mapSizeZ * 0.5 }}, + nameLong = "West", + nameShort = "W", + }, + [1] = { + boxes = {{ + {mapSizeX * 0.8, 0}, + {mapSizeX * 0.8, mapSizeZ - 1}, + {mapSizeX, mapSizeZ - 1}, + {mapSizeX, 0}, + }}, + startpoints = {{ mapSizeX * 0.9, mapSizeZ * 0.5 }}, + nameLong = "East", + nameShort = "E", + }, + } +end + +local function ParseBoxes() + local numTeams = getActiveAllyTeamCount() + + local modoptions = Spring.GetModOptions() + local parsedOverride = decodeModoption(modoptions.mapmetadata_startbox_override) + local parsedSet = decodeModoption(modoptions.mapmetadata_startboxes_set) + + local arrangement, configSource = resolveArrangement(parsedOverride, parsedSet, numTeams) + + local startBoxConfig + if arrangement then + startBoxConfig = transformArrangement(arrangement) + else + startBoxConfig = buildFallback() + configSource = "fallback" + end + + for _, entry in pairs(startBoxConfig) do + local boxes = entry.boxes + if boxes then + for i = 1, #boxes do + local poly = boxes[i] + local tessellated = SplineLib.TessellateRing(poly) + tessellated.anchors = poly + boxes[i] = tessellated end end end - -- fix rendering z-fighting local maxZ = Game.mapSizeZ - 1 - for boxid, box in pairs(startBoxConfig) do + for _, box in pairs(startBoxConfig) do local boxes = box.boxes for i = 1, #boxes do local boxRow = boxes[i] @@ -152,7 +310,7 @@ local function ParseBoxes () end end - return startBoxConfig + return startBoxConfig, configSource, isExplicitSource(configSource) end return ParseBoxes diff --git a/luaui/Widgets/map_startbox.lua b/luaui/Widgets/map_startbox.lua index edd2ce56110..fc06b762c2b 100644 --- a/luaui/Widgets/map_startbox.lua +++ b/luaui/Widgets/map_startbox.lua @@ -802,12 +802,37 @@ local function InitStartPolygons() if Spring.GetGaiaTeamID() then gaiaAllyTeamID = select(6, spGetTeamInfo(Spring.GetGaiaTeamID() , false)) end - for i, teamID in ipairs(Spring.GetAllyTeamList()) do - if teamID ~= gaiaAllyTeamID then - --and teamID ~= scavengerAIAllyTeamID and teamID ~= raptorsAIAllyTeamID then - local xn, zn, xp, zp = Spring.GetAllyTeamStartBox(teamID) - --spEcho("Allyteam",teamID,"startbox",xn, zn, xp, zp) - StartPolygons[teamID] = {{xn, zn}, {xp, zn}, {xp, zp}, {xn, zp}} + + -- Polygon overlays render only for explicit modoption sources. When the + -- hardcoded fallback fires we defer to the engine startrect path below so + -- the lobby/host's rectangles remain authoritative. + local configLoaded = false + local ok, ParseBoxes = pcall(VFS.Include, "luarules/gadgets/include/startbox_utilities.lua") + if ok and ParseBoxes then + local pok, startBoxConfig, _, isExplicit = pcall(ParseBoxes) + if pok and startBoxConfig and isExplicit then + local activeAllyTeams = {} + for _, atID in ipairs(Spring.GetAllyTeamList()) do + activeAllyTeams[atID] = true + end + for allyTeamID, entry in pairs(startBoxConfig) do + if allyTeamID ~= gaiaAllyTeamID and activeAllyTeams[allyTeamID] and entry.boxes then + for _, polygon in ipairs(entry.boxes) do + StartPolygons[#StartPolygons + 1] = {team = allyTeamID, poly = polygon} + end + configLoaded = true + end + end + end + end + + -- fall back to engine AABB if no polygon configs were loaded + if not configLoaded then + for i, teamID in ipairs(Spring.GetAllyTeamList()) do + if teamID ~= gaiaAllyTeamID then + local xn, zn, xp, zp = Spring.GetAllyTeamStartBox(teamID) + StartPolygons[#StartPolygons + 1] = {team = teamID, poly = {{xn, zn}, {xp, zn}, {xp, zp}, {xn, zp}}} + end end end @@ -825,17 +850,35 @@ local function InitStartPolygons() local y1 = mathRandom(0, Game.mapSizeZ / 5) polygon[#polygon+1] = {x0+x1, y0+y1} end - StartPolygons[#StartPolygons+1] = polygon + StartPolygons[#StartPolygons+1] = {team = i, poly = polygon} end end - --Case we start with only one team(no enemies) - --The shader doesn't like that so we have to let it think there are more then one - if(#StartPolygons == 0) then - StartPolygons[#StartPolygons+1] = StartPolygons[#StartPolygons] + local numStartPolygons = #StartPolygons + + -- The shader requires at least 2 entries to avoid edge cases + if numStartPolygons < 2 and numStartPolygons > 0 then + StartPolygons[#StartPolygons + 1] = StartPolygons[1] + numStartPolygons = numStartPolygons + 1 end - shaderSourceCache.shaderConfig.NUM_BOXES = #StartPolygons + -- Sort so same-team polygons are not adjacent in the buffer. + -- The shader merges consecutive same-teamID vertex blocks into one polygon, + -- so we interleave by sorting on (index within team, teamID). + local teamIndex = {} + for i = 1, numStartPolygons do + local t = StartPolygons[i].team + teamIndex[t] = (teamIndex[t] or 0) + 1 + StartPolygons[i].sortKey = teamIndex[t] + end + table.sort(StartPolygons, function(a, b) + if a.sortKey ~= b.sortKey then + return a.sortKey < b.sortKey + end + return a.team < b.team + end) + + shaderSourceCache.shaderConfig.NUM_BOXES = numStartPolygons local minY, maxY = Spring.GetGroundExtremes() local waterlevel = (Spring.GetModOption and Spring.GetModOptions().map_waterlevel) or 0 @@ -849,12 +892,12 @@ local function InitStartPolygons() local numvertices = 0 local bufferdata = {} local numPolygons = 0 - for teamID, polygon in pairs(StartPolygons) do + for _, entry in ipairs(StartPolygons) do numPolygons = numPolygons + 1 + local polygon = entry.poly + local teamID = entry.team local numPoints = #polygon - local xn, zn, xp, zp = Spring.GetAllyTeamStartBox(teamID) - --spEcho("teamID", teamID, "at " ,xn, zn, xp, zp) - for vertexID, vertex in ipairs(polygon) do + for _, vertex in ipairs(polygon) do local x, z = vertex[1], vertex[2] bufferdata[#bufferdata+1] = teamID bufferdata[#bufferdata+1] = numPoints diff --git a/modoptions.lua b/modoptions.lua index f9908832e2f..1f27afcb911 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -2067,6 +2067,24 @@ Example: Armada VS Cortex VS Legion: 273 or 100 010 001 or 256 + 16 + 1]], type = "string", def = "", }, + { + key = "mapmetadata_startboxes_set", + name = "Map Metadata: Startboxes Set", + desc = "Per-team-count startbox arrangements (rect or polygon). Format is: base64url(zlib(json))", + hidden = true, + section = "mapmetadata", + type = "string", + def = "", + }, + { + key = "mapmetadata_startbox_override", + name = "Map Metadata: Startbox Override", + desc = "Custom startbox arrangement that overrides the set when its team count matches. Format is: base64url(zlib(json))", + hidden = true, + section = "mapmetadata", + type = "string", + def = "", + }, --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Cheats diff --git a/spec/common/lib_polygon_spec.lua b/spec/common/lib_polygon_spec.lua new file mode 100644 index 00000000000..b5b637e5d7f --- /dev/null +++ b/spec/common/lib_polygon_spec.lua @@ -0,0 +1,146 @@ +local PolygonLib = VFS.Include("common/lib_polygon.lua") + +describe("lib_polygon", function() + + describe("PointInPolygon", function() + local square = { + {0, 0}, + {100, 0}, + {100, 100}, + {0, 100}, + } + + it("returns true for a point inside a square", function() + assert.is_true(PolygonLib.PointInPolygon(50, 50, square)) + end) + + it("returns false for a point outside a square", function() + assert.is_false(PolygonLib.PointInPolygon(150, 50, square)) + assert.is_false(PolygonLib.PointInPolygon(50, 150, square)) + assert.is_false(PolygonLib.PointInPolygon(-10, 50, square)) + assert.is_false(PolygonLib.PointInPolygon(50, -10, square)) + end) + + it("returns true for a point near the center of a large polygon", function() + -- rotated diamond shape + local diamond = { + {50, 0}, + {100, 50}, + {50, 100}, + {0, 50}, + } + assert.is_true(PolygonLib.PointInPolygon(50, 50, diamond)) + end) + + it("returns false for a point in the corner outside a diamond", function() + local diamond = { + {50, 0}, + {100, 50}, + {50, 100}, + {0, 50}, + } + assert.is_false(PolygonLib.PointInPolygon(5, 5, diamond)) + assert.is_false(PolygonLib.PointInPolygon(95, 5, diamond)) + end) + + it("handles a concave L-shaped polygon", function() + -- L-shape: top-left quadrant missing + local lShape = { + {0, 0}, + {50, 0}, + {50, 50}, + {100, 50}, + {100, 100}, + {0, 100}, + } + -- inside the bottom part + assert.is_true(PolygonLib.PointInPolygon(75, 75, lShape)) + -- inside the left part + assert.is_true(PolygonLib.PointInPolygon(25, 25, lShape)) + -- outside in the concave "notch" area (top-right quadrant) + assert.is_false(PolygonLib.PointInPolygon(75, 25, lShape)) + end) + + it("returns false for degenerate inputs", function() + assert.is_false(PolygonLib.PointInPolygon(50, 50, {})) + assert.is_false(PolygonLib.PointInPolygon(50, 50, {{0, 0}})) + assert.is_false(PolygonLib.PointInPolygon(50, 50, {{0, 0}, {100, 0}})) + end) + + it("works with large map-scale coordinates", function() + local bigBox = { + {0, 0}, + {16384, 0}, + {16384, 3276}, + {0, 3276}, + } + assert.is_true(PolygonLib.PointInPolygon(8192, 1638, bigBox)) + assert.is_false(PolygonLib.PointInPolygon(8192, 5000, bigBox)) + end) + end) + + describe("PointInStartbox", function() + it("returns true when point is inside any sub-polygon", function() + local entry = { + boxes = { + {{0, 0}, {100, 0}, {100, 100}, {0, 100}}, + {{200, 200}, {300, 200}, {300, 300}, {200, 300}}, + } + } + assert.is_true(PolygonLib.PointInStartbox(50, 50, entry)) + assert.is_true(PolygonLib.PointInStartbox(250, 250, entry)) + end) + + it("returns false when point is outside all sub-polygons", function() + local entry = { + boxes = { + {{0, 0}, {100, 0}, {100, 100}, {0, 100}}, + {{200, 200}, {300, 200}, {300, 300}, {200, 300}}, + } + } + assert.is_false(PolygonLib.PointInStartbox(150, 150, entry)) + end) + + it("returns false for nil or missing entry", function() + assert.is_false(PolygonLib.PointInStartbox(50, 50, nil)) + assert.is_false(PolygonLib.PointInStartbox(50, 50, {})) + end) + end) + + describe("GetStartboxBounds", function() + it("returns bounding box of a single polygon", function() + local entry = { + boxes = { + {{10, 20}, {90, 20}, {90, 80}, {10, 80}}, + } + } + local xmin, zmin, xmax, zmax = PolygonLib.GetStartboxBounds(entry) + assert.are.equal(10, xmin) + assert.are.equal(20, zmin) + assert.are.equal(90, xmax) + assert.are.equal(80, zmax) + end) + + it("returns combined bounding box of multiple sub-polygons", function() + local entry = { + boxes = { + {{0, 0}, {50, 0}, {50, 50}, {0, 50}}, + {{200, 300}, {400, 300}, {400, 500}, {200, 500}}, + } + } + local xmin, zmin, xmax, zmax = PolygonLib.GetStartboxBounds(entry) + assert.are.equal(0, xmin) + assert.are.equal(0, zmin) + assert.are.equal(400, xmax) + assert.are.equal(500, zmax) + end) + + it("returns zeros for nil or missing entry", function() + local xmin, zmin, xmax, zmax = PolygonLib.GetStartboxBounds(nil) + assert.are.equal(0, xmin) + assert.are.equal(0, zmin) + assert.are.equal(0, xmax) + assert.are.equal(0, zmax) + end) + end) +end) diff --git a/spec/common/lib_spline_spec.lua b/spec/common/lib_spline_spec.lua new file mode 100644 index 00000000000..2d0d5729001 --- /dev/null +++ b/spec/common/lib_spline_spec.lua @@ -0,0 +1,131 @@ +local SplineLib = VFS.Include("common/lib_spline.lua") +local PolygonLib = VFS.Include("common/lib_polygon.lua") + +local function pointsApproxEqual(a, b, eps) + eps = eps or 1e-6 + return math.abs(a[1] - b[1]) <= eps and math.abs(a[2] - b[2]) <= eps +end + +describe("lib_spline", function() + + describe("TessellateRing on plain polygons (no strengths)", function() + it("returns the anchor points unchanged when no strengths are provided", function() + local anchors = { {0, 0}, {100, 0}, {100, 100}, {0, 100} } + local poly = SplineLib.TessellateRing(anchors) + assert.are.equal(4, #poly) + assert.is_true(pointsApproxEqual(poly[1], {0, 0})) + assert.is_true(pointsApproxEqual(poly[2], {100, 0})) + assert.is_true(pointsApproxEqual(poly[3], {100, 100})) + assert.is_true(pointsApproxEqual(poly[4], {0, 100})) + end) + + it("returns the anchor points unchanged when all strengths are zero", function() + local anchors = { {0, 0, 0}, {100, 0, 0}, {100, 100, 0}, {0, 100, 0} } + local poly = SplineLib.TessellateRing(anchors) + assert.are.equal(4, #poly) + assert.is_true(pointsApproxEqual(poly[1], {0, 0})) + assert.is_true(pointsApproxEqual(poly[2], {100, 0})) + assert.is_true(pointsApproxEqual(poly[3], {100, 100})) + assert.is_true(pointsApproxEqual(poly[4], {0, 100})) + end) + + it("anchor without strength next to anchor with strength stays a sharp corner", function() + -- adjacency 1->2, 2->3, 3->4, 4->1 + -- anchor 1 strength missing (treated as 0), anchor 2 strength 1.0 + -- edge 1->2 tension = (0 + 1)/2 = 0.5 (curved) + -- edge 4->1 tension = (1 + 0)/2 = 0.5 (curved) + -- edges 2->3 and 3->4: anchors 3,4 missing → 0 → linear + local anchors = { {0, 0}, {100, 0, 1}, {100, 100, 1}, {0, 100} } + local poly = SplineLib.TessellateRing(anchors, { segments = 4 }) + -- linear edges contribute 1 vertex; curved edges contribute 4 + -- edge 1->2 curved (4), 2->3 curved (4), 3->4 linear (1), 4->1 curved (4) + -- wait recompute: anchor strengths [0, 1, 1, 0] + -- edge 1->2: (0+1)/2 = 0.5, curved + -- edge 2->3: (1+1)/2 = 1, curved + -- edge 3->4: (1+0)/2 = 0.5, curved + -- edge 4->1: (0+0)/2 = 0, linear + -- Output: 4 + 4 + 4 + 1 = 13 vertices + assert.are.equal(13, #poly) + end) + end) + + describe("TessellateRing with positive strength", function() + it("subdivides curved edges into `segments` samples", function() + local anchors = { {0, 0, 1}, {100, 0, 1}, {100, 100, 1}, {0, 100, 1} } + local poly = SplineLib.TessellateRing(anchors, { segments = 8 }) + -- 4 anchors * 8 samples per edge = 32 vertices when fully splined + assert.are.equal(32, #poly) + end) + + it("anchor positions are preserved on the tessellated curve", function() + local anchors = { {0, 0, 1}, {100, 0, 1}, {100, 100, 1}, {0, 100, 1} } + local poly = SplineLib.TessellateRing(anchors, { segments = 5 }) + -- the first vertex emitted in each per-edge group is the anchor itself + assert.is_true(pointsApproxEqual(poly[1], {0, 0})) + assert.is_true(pointsApproxEqual(poly[1 + 5], {100, 0})) + assert.is_true(pointsApproxEqual(poly[1 + 10], {100, 100})) + assert.is_true(pointsApproxEqual(poly[1 + 15], {0, 100})) + end) + + it("mixed strength only subdivides edges whose endpoints have any strength", function() + -- two adjacent anchors with strength 0 produce a linear edge between them. + -- adjacency ordering: 1->2, 2->3, 3->4, 4->1 + local anchors = { + {0, 0, 0}, + {100, 0, 0}, + {100, 100, 1}, + {0, 100, 1}, + } + local poly = SplineLib.TessellateRing(anchors, { segments = 6 }) + -- edges with tensions: (0+0)/2=0, (0+1)/2=0.5, (1+1)/2=1, (1+0)/2=0.5 + -- linear edge contributes 1 vertex; curved edges contribute 6. + -- total = 1 + 6 + 6 + 6 = 19 + assert.are.equal(19, #poly) + end) + end) + + describe("integration with PolygonLib", function() + it("tessellated spline polygon is valid input for PointInPolygon", function() + local anchors = { {0, 0, 1}, {100, 0, 1}, {100, 100, 1}, {0, 100, 1} } + local poly = SplineLib.TessellateRing(anchors) + -- center of the anchor square should still be inside the spline polygon + assert.is_true(PolygonLib.PointInPolygon(50, 50, poly)) + assert.is_false(PolygonLib.PointInPolygon(500, 500, poly)) + end) + + it("zero-strength tessellation is point-equivalent to the input polygon", function() + local anchors = { {0, 0, 0}, {100, 0, 0}, {100, 100, 0}, {0, 100, 0} } + local splineOut = SplineLib.TessellateRing(anchors) + local plain = { {0, 0}, {100, 0}, {100, 100}, {0, 100} } + -- both should agree on a sample of test points (containment-equivalent) + local samples = { {50, 50}, {-1, -1}, {101, 50}, {25, 25}, {99, 99}, {50, 200} } + for _, s in ipairs(samples) do + assert.are.equal( + PolygonLib.PointInPolygon(s[1], s[2], splineOut), + PolygonLib.PointInPolygon(s[1], s[2], plain) + ) + end + end) + end) + + describe("degenerate inputs", function() + it("returns empty result for empty input", function() + local poly = SplineLib.TessellateRing({}) + assert.are.equal(0, #poly) + end) + + it("passes through 1- and 2-anchor inputs without curving", function() + local one = SplineLib.TessellateRing({ {5, 7, 1} }) + assert.are.equal(1, #one) + local two = SplineLib.TessellateRing({ {0, 0, 1}, {10, 10, 1} }) + assert.are.equal(2, #two) + end) + + it("clamps out-of-range strength values", function() + local anchors = { {0, 0, -5}, {100, 0, 99}, {100, 100, 0.5}, {0, 100, 0.5} } + -- should not error; values get clamped to [0, 1] + local poly = SplineLib.TessellateRing(anchors, { segments = 4 }) + assert.is_true(#poly >= 4) + end) + end) +end)