diff --git a/NOTICE b/NOTICE index 83f370ec..28e0d697 100644 --- a/NOTICE +++ b/NOTICE @@ -99,10 +99,6 @@ include, the following software: - The .NET runtime and Microsoft.Extensions packages (https://github.com/dotnet), MIT License. -apps/utility-sw/hud/radars/metadata.json, the map radar calibration, is -vendored from boltobserv by boltgolt -(https://github.com/boltgolt/boltobserv), GNU GPL version 3. - The gamedata validator (apps/gamedata-validator) is adapted from swiftly-solution/gamedata-validator (https://github.com/swiftly-solution/gamedata-validator), whose repository diff --git a/apps/utility-sw/hud/README.md b/apps/utility-sw/hud/README.md index 2e707fbc..9675eb08 100644 --- a/apps/utility-sw/hud/README.md +++ b/apps/utility-sw/hud/README.md @@ -43,7 +43,8 @@ asserts against the files in this directory that: stylesheet; - the one-of-N class groups (`x0…x63`, `y0…y63`, `p0…p10`) are declared for exactly the counts `HudSlots` will pick between; -- every map with a `#radar.` rule also has calibration in `radars/`; +- every map with a `#radar.` rule also has calibration in `radars/`, and + every level that calibration names has a `#radar..` image rule; - every `PracticeStepColors` name has a chip and a swatch rule, and none of them is ever green or red — those two belong to the aim ramp; - `nade3_edit` contains no geometry field, so editing where a smoke lands can @@ -116,14 +117,22 @@ clone is ~570 MB and would otherwise be copied into the pod on every `up`. The addon ships **no raster images**. The minimap draws on CS2's own `s2r://panorama/images/overheadmaps/_radar.psd`, because a texture compiled by PanoramaCompiler's experimental image path spawned an entity that rendered -nothing. The web panel's radars are SimpleRadar — a drop-in replacement for -Valve's, same framing — so `radars/metadata.json` projects correctly onto either -and the two UIs still agree to the pixel. That is why the swap cost nothing, and -why `scripts/sync-radars.sh` mirrors the calibration only. - -Known limit: SimpleRadar puts Nuke's and Vertigo's two levels on one image and -Valve ships them as separate `_lower_radar` files, so lower-level markers on -those two maps are offset. The other eight are exact. +nothing. `radars/metadata.json` is built from the files that frame those +images, CS2's `game/csgo/resource/overviews/.txt`, by +`scripts/sync-radars.sh `; run it again when Valve redraws a +radar. It is not mirrored from the web panel, which frames its radars its own +way. + +Nuke, Train and Vertigo draw their lower floor on an image of its own, +`_lower_radar.psd`, in the same frame as the main one, and the overview's +`verticalsections` say below which height it applies. The picker swaps image the +way CS2's radar does, by the floor the player is standing on (the main one when +they have no position), and draws every spot on either: a detonation height says +little about which floor a throw is for, and one floor's throws are often thrown +from the other. That the lower images load by that path is inferred from the +VPK, which ships `de_nuke_lower_radar_psd.vtex_c` beside the main one; it has not +yet been seen in game. Check it after the Workshop publish, or with the item +unmounted: a mounted item draws the published `nadehud2.css` over a local build. Icons are SVG. One of CS2's own draws from the stylesheet as `background-image: url("s2r://panorama/images/icons/equipment/molotov.vsvg")` diff --git a/apps/utility-sw/hud/panorama/styles/custom_game/nadehud2.css b/apps/utility-sw/hud/panorama/styles/custom_game/nadehud2.css index 9cf42513..2e4ec3fe 100644 --- a/apps/utility-sw/hud/panorama/styles/custom_game/nadehud2.css +++ b/apps/utility-sw/hud/panorama/styles/custom_game/nadehud2.css @@ -2631,6 +2631,9 @@ #radar.de_overpass { background-image: url("s2r://panorama/images/overheadmaps/de_overpass_radar.psd"); } #radar.de_train { background-image: url("s2r://panorama/images/overheadmaps/de_train_radar.psd"); } #radar.de_vertigo { background-image: url("s2r://panorama/images/overheadmaps/de_vertigo_radar.psd"); } +#radar.de_nuke.lower { background-image: url("s2r://panorama/images/overheadmaps/de_nuke_lower_radar.psd"); } +#radar.de_train.lower { background-image: url("s2r://panorama/images/overheadmaps/de_train_lower_radar.psd"); } +#radar.de_vertigo.lower { background-image: url("s2r://panorama/images/overheadmaps/de_vertigo_lower_radar.psd"); } /* The last five throws at this lineup, oldest on the left: a streak building is easier to see than a number counting up. */ diff --git a/apps/utility-sw/hud/radars/metadata.json b/apps/utility-sw/hud/radars/metadata.json index bef3634b..48eb0fc1 100644 --- a/apps/utility-sw/hud/radars/metadata.json +++ b/apps/utility-sw/hud/radars/metadata.json @@ -1,140 +1,90 @@ { - "_comment": "Mirrored from web/public/radars/metadata.json by scripts/sync-radars.sh. Do not edit here.", + "_comment": "Built from CS2's resource/overviews/.txt by scripts/sync-radars.sh: resolution = scale, offset = (-pos_x, 1024 * scale - pos_y), levels from verticalsections. Do not edit here.", "de_ancient": { - "resolution": 4.26, + "resolution": 5, "offset": { - "x": 2590, - "y": 2520 - }, - "zRange": { - "min": -95, - "max": 220 + "x": 2953, + "y": 2956 } }, "de_anubis": { - "resolution": 5.25, + "resolution": 5.22, "offset": { - "x": 2830, - "y": 2030 - }, - "zRange": { - "min": -85, - "max": 180 + "x": 2796, + "y": 2017.28 } }, "de_cache": { - "resolution": 5.54, + "resolution": 5.5, "offset": { - "x": 2020, - "y": 2390 + "x": 2000, + "y": 2382 } }, "de_dust2": { "resolution": 4.4, "offset": { - "x": 2470, - "y": 1255 - }, - "zRange": { - "min": -50, - "max": 185 + "x": 2476, + "y": 1266.6 } }, "de_inferno": { - "resolution": 4.91, + "resolution": 4.9, "offset": { - "x": 2090, - "y": 1150 - }, - "zRange": { - "min": 73, - "max": 310 + "x": 2087, + "y": 1147.6 } }, "de_mirage": { - "resolution": 5.02, + "resolution": 5, "offset": { - "x": 3240, - "y": 3410 - }, - "zRange": { - "min": -300, - "max": 20 + "x": 3230, + "y": 3407 } }, "de_nuke": { - "resolution": 6.98, + "resolution": 7, "offset": { - "x": 3290, - "y": 5990 - }, - "zRange": { - "min": -480, - "max": -20 + "x": 3453, + "y": 4281 }, - "splits": [ + "levels": [ { - "bounds": { - "top": -482, - "bottom": -2500 - }, - "offset": { - "x": 0, - "y": -46 - }, - "zRange": { - "min": -770, - "max": -480 - } + "name": "lower", + "maxZ": -495 } ] }, "de_overpass": { - "resolution": 5.18, + "resolution": 5.2, "offset": { - "x": 4830, - "y": 3540 - }, - "zRange": { - "min": 10, - "max": 500 + "x": 4831, + "y": 3543.8 } }, "de_train": { - "resolution": 4.74, + "resolution": 4.082077, "offset": { - "x": 2730, - "y": 2360 + "x": 2308, + "y": 2102.046848 }, - "zRange": { - "min": -320, - "max": 15 - } + "levels": [ + { + "name": "lower", + "maxZ": -50 + } + ] }, "de_vertigo": { - "resolution": 4.96, + "resolution": 4, "offset": { - "x": 3890, - "y": 3800 - }, - "zRange": { - "min": 11680, - "max": 11920 + "x": 3168, + "y": 2334 }, - "splits": [ + "levels": [ { - "bounds": { - "top": 11550, - "bottom": 11000 - }, - "offset": { - "x": 0.2, - "y": -42.6 - }, - "zRange": { - "min": 11550, - "max": 11680 - } + "name": "lower", + "maxZ": 11700 } ] } diff --git a/apps/utility-sw/src/Hud/NadePickTemplate.cs b/apps/utility-sw/src/Hud/NadePickTemplate.cs index 5f767755..de6b7620 100644 --- a/apps/utility-sw/src/Hud/NadePickTemplate.cs +++ b/apps/utility-sw/src/Hud/NadePickTemplate.cs @@ -40,6 +40,11 @@ public record Marker( // Null on a map with no radar to draw: the list has the panel to itself. public string? MapClass { get; set; } + + // One of RadarMaps.Levels while the player stands on that floor, null for + // the map's main image. + public string? Level { get; set; } + public string Focus { get; set; } = ""; public NadePickTemplate Add(Row row) @@ -118,6 +123,11 @@ public override void Apply(HudSurface surface) surface.Class("radar", map, map == MapClass); } + foreach (string level in RadarMaps.Levels) + { + surface.Class("radar", level, level == Level); + } + for (int index = 0; index < HudSlots.MapMarkers; index++) { int slot = index + 1; diff --git a/apps/utility-sw/src/Hud/PracticeHud.cs b/apps/utility-sw/src/Hud/PracticeHud.cs index 9f2cd47c..716ddb2c 100644 --- a/apps/utility-sw/src/Hud/PracticeHud.cs +++ b/apps/utility-sw/src/Hud/PracticeHud.cs @@ -34,7 +34,7 @@ private class MenuState private readonly Dictionary _menus = new(); - // Loaded once from hud/radars/metadata.json, mirrored from the web panel. + // Loaded once from hud/radars/metadata.json, built from CS2's overviews. private Dictionary? _radars; // The target a player picked off the map, so the list knows what to filter @@ -541,6 +541,12 @@ private bool RenderPicker(IPlayer player) LineupRecord? loaded = _system.StateFor(steamId).Loaded; Vec3? at = PracticeSystem.Where(player)?.feet_position; + // The floor the player is standing on, which is how CS2's own radar + // picks its image, and the main one while they have no position. + string? level = calibration == null || at == null + ? null + : RadarProjection.Level(at.Value.z, calibration); + var template = new NadePickTemplate { Title = Narrowing(steamId) ?? "Pick a lineup", @@ -555,6 +561,7 @@ private bool RenderPicker(IPlayer player) MapClass = calibration == null ? null : RadarProjection.NormalizeMapName(_library.Map), + Level = level, Focus = selected == null ? "click a spot for the throws that land there" : "click it again to let go of it", diff --git a/apps/utility-sw/test/HudLayoutContractTests.cs b/apps/utility-sw/test/HudLayoutContractTests.cs index a0e2c1a2..9152aea8 100644 --- a/apps/utility-sw/test/HudLayoutContractTests.cs +++ b/apps/utility-sw/test/HudLayoutContractTests.cs @@ -257,6 +257,11 @@ public void EveryClassThePickerTogglesIsStyled() toggles.Add(("radar", map)); } + foreach (string level in RadarMaps.Levels) + { + toggles.Add(("radar", level)); + } + for (int marker = 1; marker <= HudSlots.MapMarkers; marker++) { toggles.Add(($"m{marker}", "hidden")); @@ -694,6 +699,30 @@ public void EveryRadarMapHasAnImageAStyleAndCalibration() } } + // Without a rule of its own a level keeps showing the main image while the + // player stands below it. A level RadarMaps.Levels does not list is never + // switched to at all. + [Fact] + public void EveryRadarLevelHasItsOwnImage() + { + string styles = Styles(); + Dictionary loaded = RadarMaps.Load( + Path.Combine(HudRoot(), "radars", "metadata.json") + ); + + foreach ((string map, RadarCalibration calibration) in loaded) + { + foreach (RadarLevel level in calibration.Levels) + { + Assert.Contains(level.Name, RadarMaps.Levels); + Assert.Contains( + $"#radar.{map}.{level.Name} {{ background-image: url(\"s2r://panorama/images/overheadmaps/{map}_{level.Name}_radar.psd\"); }}", + styles + ); + } + } + } + [Fact] public void EveryElementTheRunTogglesClassesOnExists() { diff --git a/apps/utility-sw/test/RadarProjectionTests.cs b/apps/utility-sw/test/RadarProjectionTests.cs index 100be236..5cad081a 100644 --- a/apps/utility-sw/test/RadarProjectionTests.cs +++ b/apps/utility-sw/test/RadarProjectionTests.cs @@ -4,17 +4,43 @@ namespace FiveStack.Tests; -// Pinned against web/composables/useRadarProjection.ts. If these move, the panel -// and the HUD have stopped agreeing about where a lineup lands. +// Pinned against CS2's resource/overviews/.txt. If these move, markers no +// longer land where CS2's own radar image draws the place. public class RadarProjectionTests { + private static string ShippedCalibration() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + string candidate = Path.Combine( + directory.FullName, "apps", "utility-sw", "hud", "radars", "metadata.json" + ); + + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("apps/utility-sw/hud/radars/metadata.json not found above the test output"); + } + + private static Dictionary Shipped() + { + return RadarMaps.Load(ShippedCalibration()); + } + private static RadarCalibration Mirage() { return new RadarCalibration { - Resolution = 5.02f, - OffsetX = 3240f, - OffsetY = 3410f, + Resolution = 5f, + OffsetX = 3230f, + OffsetY = 1024f * 5f - 1713f, }; } @@ -22,19 +48,10 @@ private static RadarCalibration Nuke() { return new RadarCalibration { - Resolution = 6.98f, - OffsetX = 3290f, - OffsetY = 5990f, - Splits = new List - { - new RadarSplit - { - BoundsTop = -482f, - BoundsBottom = -2500f, - OffsetX = 0f, - OffsetY = -46f, - }, - }, + Resolution = 7f, + OffsetX = 3453f, + OffsetY = 1024f * 7f - 2887f, + Levels = new List { new RadarLevel { Name = "lower", MaxZ = -495f } }, }; } @@ -44,20 +61,39 @@ private static RadarCalibration Nuke() [InlineData(" de_inferno ", "de_inferno")] [InlineData("de_ancient_night", "de_ancient")] [InlineData(null, "")] - public void MapNamesNormaliseLikeTheWeb(string? input, string expected) + public void MapNamesNormalise(string? input, string expected) { Assert.Equal(expected, RadarProjection.NormalizeMapName(input)); } - // x = (world.x + offsetX) / resolution - // y = 1024 - (world.y + offsetY) / resolution - [Fact] - public void ProjectsWithTheSameArithmeticAsTheWeb() + // pos_x, pos_y and scale exactly as each overview file states them. The + // overview's top left corner is pixel (0, 0), and anything else is + // ((x - pos_x) / scale, (pos_y - y) / scale) on the 1024 square image. + [Theory] + [InlineData("de_ancient", -2953f, 2164f, 5f)] + [InlineData("de_anubis", -2796f, 3328f, 5.22f)] + [InlineData("de_cache", -2000f, 3250f, 5.5f)] + [InlineData("de_dust2", -2476f, 3239f, 4.4f)] + [InlineData("de_inferno", -2087f, 3870f, 4.9f)] + [InlineData("de_mirage", -3230f, 1713f, 5f)] + [InlineData("de_nuke", -3453f, 2887f, 7f)] + [InlineData("de_overpass", -4831f, 1781f, 5.2f)] + [InlineData("de_train", -2308f, 2078f, 4.082077f)] + [InlineData("de_vertigo", -3168f, 1762f, 4f)] + public void ShippedCalibrationIsValvesOverview(string map, float posX, float posY, float scale) { - (float x, float y) = RadarProjection.Project(new Vec3(0f, 0f, 0f), Mirage()); + RadarCalibration calibration = Shipped()[map]; - Assert.Equal(3240f / 5.02f, x, 2); - Assert.Equal(1024f - 3410f / 5.02f, y, 2); + (float cornerX, float cornerY) = RadarProjection.Project(new Vec3(posX, posY, 0f), calibration); + + Assert.Equal(0f, cornerX, 2); + Assert.Equal(0f, cornerY, 2); + + var point = new Vec3(-512f, 768f, 0f); + (float x, float y) = RadarProjection.Project(point, calibration); + + Assert.Equal((point.x - posX) / scale, x, 2); + Assert.Equal((posY - point.y) / scale, y, 2); } // The y flip is the easiest thing to get backwards, and getting it backwards @@ -82,29 +118,52 @@ public void EastIsRight() Assert.True(right > left); } + [Theory] + [InlineData(-300f, null)] + [InlineData(-495f, null)] + [InlineData(-496f, "lower")] + [InlineData(-770f, "lower")] + public void NukeIsOnItsLowerLevelBelowValvesAltitude(float z, string? expected) + { + Assert.Equal(expected, RadarProjection.Level(z, Nuke())); + } + [Fact] - public void NukeLowerLevelShiftsOntoItsOwnHalf() + public void AMapWithoutLevelsIsAlwaysOnItsMainImage() { - RadarCalibration nuke = Nuke(); - var upper = new Vec3(0f, 0f, -100f); - var lower = new Vec3(0f, 0f, -1000f); + Assert.Null(RadarProjection.Level(-5000f, Mirage())); + Assert.Null(RadarProjection.Level(5000f, Mirage())); + } - (_, float upperY) = RadarProjection.Project(upper, nuke); - (_, float lowerY) = RadarProjection.Project(lower, nuke); + [Fact] + public void APointIsOnTheNearestLevelAboveIt() + { + var calibration = new RadarCalibration + { + Resolution = 5f, + Levels = new List + { + new RadarLevel { Name = "lower", MaxZ = 0f }, + new RadarLevel { Name = "basement", MaxZ = -500f }, + }, + }; - // -46% of the image on a y that is then flipped, so the lower level ends - // up BELOW the upper one on screen. - Assert.Equal(upperY + 0.46f * RadarProjection.Pixels, lowerY, 2); + Assert.Null(RadarProjection.Level(100f, calibration)); + Assert.Equal("lower", RadarProjection.Level(-100f, calibration)); + Assert.Equal("basement", RadarProjection.Level(-600f, calibration)); } + // Valve draws a lower floor on its own image in the same frame, so the + // height picks the image and never moves the point. [Fact] - public void HeightOutsideTheSplitBandIsUnshifted() + public void ALowerLevelProjectsInTheSameFrame() { RadarCalibration nuke = Nuke(); - Assert.Equal((0f, 0f), RadarProjection.Split(-100f, nuke.Splits)); - Assert.Equal((0f, -46f), RadarProjection.Split(-1000f, nuke.Splits)); - Assert.Equal((0f, 0f), RadarProjection.Split(-3000f, nuke.Splits)); + Assert.Equal( + RadarProjection.Project(new Vec3(600f, -900f, -400f), nuke), + RadarProjection.Project(new Vec3(600f, -900f, -760f), nuke) + ); } [Fact] @@ -126,26 +185,7 @@ public void NormalisedStaysOnTheImage() [Fact] public void TheShippedCalibrationParses() { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - string? path = null; - - while (directory != null && path == null) - { - string candidate = Path.Combine( - directory.FullName, "apps", "utility-sw", "hud", "radars", "metadata.json" - ); - - if (File.Exists(candidate)) - { - path = candidate; - } - - directory = directory.Parent; - } - - Assert.NotNull(path); - - Dictionary loaded = RadarMaps.Load(path!); + Dictionary loaded = Shipped(); foreach (string map in RadarMaps.All) { @@ -153,7 +193,33 @@ public void TheShippedCalibrationParses() Assert.True(loaded[map].Resolution > 0f, $"{map}: resolution is zero"); } - Assert.Equal(2, loaded["de_nuke"].Splits.Count + loaded["de_vertigo"].Splits.Count); + Assert.Equal(RadarMaps.All.Count, loaded.Count); + } + + // verticalsections, as each overview file states it: the lower image is + // drawn below its AltitudeMax. + [Theory] + [InlineData("de_nuke", -495f)] + [InlineData("de_train", -50f)] + [InlineData("de_vertigo", 11700f)] + public void ShippedLowerLevelsAreValves(string map, float maxZ) + { + RadarLevel level = Assert.Single(Shipped()[map].Levels); + + Assert.Equal("lower", level.Name); + Assert.Equal(maxZ, level.MaxZ); + } + + [Fact] + public void OnlyNukeTrainAndVertigoHaveALowerLevel() + { + string[] levelled = Shipped() + .Where(entry => entry.Value.Levels.Count > 0) + .Select(entry => entry.Key) + .OrderBy(map => map, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(new[] { "de_nuke", "de_train", "de_vertigo" }, levelled); } [Fact] diff --git a/scripts/sync-radars.sh b/scripts/sync-radars.sh index ee54fce6..28f96bab 100755 --- a/scripts/sync-radars.sh +++ b/scripts/sync-radars.sh @@ -1,36 +1,71 @@ #!/usr/bin/env bash -# Re-mirrors the radar CALIBRATION from the web panel. +# Builds the HUD's radar calibration from Valve's overview files, CS2's +# game/csgo/resource/overviews/.txt. They frame the images the HUD draws +# on (s2r://panorama/images/overheadmaps/_radar.psd), which no other +# calibration matches. # -# Not the images. The HUD draws on CS2's own overheadmaps -# (s2r://panorama/images/overheadmaps/_radar.psd) because a texture -# compiled by PanoramaCompiler's experimental image path never loaded in game. -# The web's radars are SimpleRadar, a drop-in replacement for Valve's, so the -# same calibration projects correctly onto either -- which is the only reason -# dropping the images cost nothing. +# scripts/sync-radars.sh (or set CS2_OVERVIEWS) # -# The calibration itself still has to match the panel exactly: a lineup that -# lands on Window on the site and on Palace in game is worse than no map at all. +# resolution is Valve's scale and offset is (-pos_x, 1024 * scale - pos_y), so a +# world point lands at ((x - pos_x) / scale, (pos_y - y) / scale) on the 1024 +# square image. Every verticalsections entry but default becomes a level: CS2 +# swaps to __radar.psd while the player is below its AltitudeMax. set -euo pipefail -WEB="${WEB_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../web" && pwd)}" -HUD="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/apps/utility-sw/hud" -SRC="${WEB}/public/radars/metadata.json" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OVERVIEWS="${1:-${CS2_OVERVIEWS:-}}" -[ -f "$SRC" ] || { echo "no radar calibration at $SRC (set WEB_REPO)" >&2; exit 1; } +[ -d "$OVERVIEWS" ] || { echo "usage: $0 (or set CS2_OVERVIEWS)" >&2; exit 1; } -mkdir -p "${HUD}/radars" +python3 -I - "$OVERVIEWS" "${ROOT}/shared/dotnet/FiveStack.Utilities/RadarMaps.cs" "${ROOT}/apps/utility-sw/hud/radars/metadata.json" <<'PY' +import json, os, re, sys + +overviews, radar_maps, dst = sys.argv[1:4] + +def parse(text): + tokens = re.findall(r'"([^"]*)"|([{}])', re.sub(r'//[^\n]*', '', text)) + + def block(i): + out = {} + while i < len(tokens) and tokens[i][1] != '}': + key, (value, brace) = tokens[i][0].lower(), tokens[i + 1] + if brace == '{': + out[key], i = block(i + 2) + else: + out[key], i = value, i + 2 + return out, i + 1 + + return block(0)[0] + +def number(value): + n = round(float(value), 6) + return int(n) if n == int(n) else n + +# The maps RadarMaps.All draws, so the file never calibrates one the HUD has no +# image rule for. +maps = re.findall(r'"([a-z0-9_]+)"', re.search(r'All = new\[\]\s*\{([^}]*)\}', open(radar_maps).read()).group(1)) +out = {"_comment": "Built from CS2's resource/overviews/.txt by scripts/sync-radars.sh: resolution = scale, offset = (-pos_x, 1024 * scale - pos_y), levels from verticalsections. Do not edit here."} + +for name in maps: + path = os.path.join(overviews, f"{name}.txt") + if not os.path.isfile(path): + sys.exit(f"no overview for {name} at {path}") + + overview = next(iter(parse(open(path).read()).values())) + scale = float(overview["scale"]) + entry = { + "resolution": number(scale), + "offset": {"x": number(-float(overview["pos_x"])), "y": number(1024 * scale - float(overview["pos_y"]))}, + } + levels = [ + {"name": level, "maxZ": number(section["altitudemax"])} + for level, section in overview.get("verticalsections", {}).items() + if level != "default" + ] + if levels: + entry["levels"] = levels + out[name] = entry -python3 - "$SRC" "${HUD}/radars/metadata.json" <<'PY' -import json, sys -src, dst = sys.argv[1], sys.argv[2] -d = json.load(open(src)); d.pop("_comment", None) -out = {"_comment": "Mirrored from web/public/radars/metadata.json by scripts/sync-radars.sh. Do not edit here.", **d} open(dst, "w").write(json.dumps(out, indent=2) + "\n") -print(f"calibration: {len(d)} maps") +print(f"calibration: {len(maps)} maps from {overviews}") PY - -# The images are deliberately absent; a stale copy would be compiled into the -# addon and silently add ~2 MB of textures nothing references. -if [ -d "${HUD}/panorama/images" ]; then - echo "warning: ${HUD}/panorama/images exists but nothing references it -- delete it" >&2 -fi diff --git a/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs b/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs index b92b0aa5..9e6830f3 100644 --- a/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs +++ b/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs @@ -195,11 +195,13 @@ public static List VerifyToggles( // Either scoping counts: a rule on the element's own id // (#radar.de_mirage) selects it just as surely as one on a class it - // carries (.nh-row.selected). + // carries (.nh-row.selected), and so does one that needs another + // class beside it (#radar.de_nuke.lower). + const string alongside = "(?:\\.[A-Za-z0-9_-]+)*"; bool styled = - new Regex($"#{Regex.Escape(elementId)}\\.{Regex.Escape(name)}\\b").IsMatch(css) + new Regex($"#{Regex.Escape(elementId)}{alongside}\\.{Regex.Escape(name)}\\b").IsMatch(css) || classes.Any(carried => - new Regex($"\\.{Regex.Escape(carried)}\\.{Regex.Escape(name)}\\b").IsMatch(css) + new Regex($"\\.{Regex.Escape(carried)}{alongside}\\.{Regex.Escape(name)}\\b").IsMatch(css) ); if (!styled) diff --git a/shared/dotnet/FiveStack.Utilities/RadarMaps.cs b/shared/dotnet/FiveStack.Utilities/RadarMaps.cs index 5a6c3a64..1fcc2866 100644 --- a/shared/dotnet/FiveStack.Utilities/RadarMaps.cs +++ b/shared/dotnet/FiveStack.Utilities/RadarMaps.cs @@ -3,13 +3,13 @@ namespace FiveStack.Utilities; -// The radar calibration the HUD projects with, mirrored from the web panel by -// scripts/sync-radars.sh. Only maps listed here can draw a minimap; everything -// else falls back to the list. +// The radar calibration the HUD projects with, built from CS2's own overview +// files by scripts/sync-radars.sh. Only maps listed here can draw a minimap; +// everything else falls back to the list. public static class RadarMaps { - // Kept in step with the stylesheet's #radar. rules and the images in - // panorama/images/nadehud. HudLayoutContractTests asserts all three agree. + // Kept in step with the stylesheet's #radar. rules and metadata.json. + // HudLayoutContractTests asserts all three agree. public static readonly IReadOnlyList All = new[] { "de_ancient", @@ -24,6 +24,10 @@ public static class RadarMaps "de_vertigo", }; + // The levels CS2 draws from an image of their own, as a class on #radar + // beside the map's: #radar.de_nuke.lower is de_nuke_lower_radar.psd. + public static readonly IReadOnlyList Levels = new[] { "lower" }; + // Marker colour classes. The engine's spelling for a molotov is Molotov and // for an HE is HighExplosive; the stylesheet uses short names. public static readonly IReadOnlyList Types = new[] { "smoke", "flash", "molly", "he" }; @@ -103,26 +107,20 @@ public static Dictionary Load(string path) OffsetY = offsetY.GetSingle(), }; - if (element.TryGetProperty("splits", out JsonElement splits)) + if (element.TryGetProperty("levels", out JsonElement levels)) { - foreach (JsonElement split in splits.EnumerateArray()) + foreach (JsonElement level in levels.EnumerateArray()) { if ( - !split.TryGetProperty("bounds", out JsonElement bounds) - || !split.TryGetProperty("offset", out JsonElement splitOffset) + !level.TryGetProperty("name", out JsonElement name) + || !level.TryGetProperty("maxZ", out JsonElement maxZ) ) { continue; } - calibration.Splits.Add( - new RadarSplit - { - BoundsTop = bounds.GetProperty("top").GetSingle(), - BoundsBottom = bounds.GetProperty("bottom").GetSingle(), - OffsetX = splitOffset.GetProperty("x").GetSingle(), - OffsetY = splitOffset.GetProperty("y").GetSingle(), - } + calibration.Levels.Add( + new RadarLevel { Name = name.GetString() ?? "", MaxZ = maxZ.GetSingle() } ); } } diff --git a/shared/dotnet/FiveStack.Utilities/RadarProjection.cs b/shared/dotnet/FiveStack.Utilities/RadarProjection.cs index 767af62c..9de1b2d1 100644 --- a/shared/dotnet/FiveStack.Utilities/RadarProjection.cs +++ b/shared/dotnet/FiveStack.Utilities/RadarProjection.cs @@ -2,16 +2,14 @@ namespace FiveStack.Utilities; -// A straight port of web/composables/useRadarProjection.ts. The panel and the -// in-game HUD draw the same lineups onto the same radar images, so they have to -// agree to the pixel -- a lineup that lands on Window on the site and on Palace -// in game is worse than no map at all. Any change here belongs in both. -public class RadarSplit +// Valve's overview frame, the one CS2's own radar images are drawn in: pos_x and +// pos_y are the world point at the image's top left corner and scale is world +// units per pixel of a 1024 square. metadata.json stores that as resolution = +// scale and offset = (-pos_x, 1024 * scale - pos_y). +public class RadarLevel { - public float BoundsTop { get; set; } - public float BoundsBottom { get; set; } - public float OffsetX { get; set; } - public float OffsetY { get; set; } + public string Name { get; set; } = ""; + public float MaxZ { get; set; } } public class RadarCalibration @@ -19,14 +17,11 @@ public class RadarCalibration public float Resolution { get; set; } public float OffsetX { get; set; } public float OffsetY { get; set; } - public List Splits { get; set; } = new List(); + public List Levels { get; set; } = new List(); } public static class RadarProjection { - // The web renders into a 1024 square regardless of the source image size, - // and the calibration is expressed against that, not against the png. - public const float Canvas = 1024f; public const float Pixels = 1024f; public static string NormalizeMapName(string? name) @@ -38,38 +33,29 @@ public static string NormalizeMapName(string? name) : trimmed; } - // Nuke and Vertigo stack two playable levels on one image; a point inside a - // split's height band shifts by a percentage of the image, which is what - // puts the lower level on its own half. - public static (float X, float Y) Split(float z, IReadOnlyList? splits) + // A level is a floor CS2 draws on an image of its own, in the same frame as + // the main one, for anything below its maxZ. Null is the main image. + public static string? Level(float z, RadarCalibration calibration) { - if (splits == null) - { - return (0f, 0f); - } + RadarLevel? below = null; - foreach (RadarSplit split in splits) + foreach (RadarLevel level in calibration.Levels) { - if (z > split.BoundsBottom && z < split.BoundsTop) + if (z < level.MaxZ && (below == null || level.MaxZ < below.MaxZ)) { - return (split.OffsetX, split.OffsetY); + below = level; } } - return (0f, 0f); + return below?.Name; } public static (float X, float Y) Project(Vec3 point, RadarCalibration calibration) { - (float dx, float dy) = Split(point.z, calibration.Splits); - - float gameX = point.x + calibration.OffsetX; - float gameY = point.y + calibration.OffsetY; - - float pixelX = gameX / calibration.Resolution + dx / 100f * Pixels; - float pixelYFromBottom = gameY / calibration.Resolution + dy / 100f * Pixels; - - return (pixelX * (Canvas / Pixels), Canvas - pixelYFromBottom * (Canvas / Pixels)); + return ( + (point.x + calibration.OffsetX) / calibration.Resolution, + Pixels - (point.y + calibration.OffsetY) / calibration.Resolution + ); } // 0..1 across the image, which is what a one-of-N class grid needs. Points @@ -79,7 +65,7 @@ public static (float X, float Y) Normalized(Vec3 point, RadarCalibration calibra { (float x, float y) = Project(point, calibration); - return (Math.Clamp(x / Canvas, 0f, 1f), Math.Clamp(y / Canvas, 0f, 1f)); + return (Math.Clamp(x / Pixels, 0f, 1f), Math.Clamp(y / Pixels, 0f, 1f)); } public static (int Column, int Row) Cell(