using System.Text.Json; using System.Text.Json.Serialization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using Vector = CounterStrikeSharp.API.Modules.Utils.Vector; namespace NoGoZones; // Plain float triple for JSON; CSS's Vector wraps native memory and doesn't serialize. public record struct Point3(float X, float Y, float Z) { public static Point3 From(Vector v) => new(v.X, v.Y, v.Z); public Vector ToVector() => new(X, Y, Z); } public record struct Rgb(byte R, byte G, byte B); public class Zone { public string Name { get; set; } = ""; public List Points { get; set; } = []; // Box in the zone's own frame (world rotated by -Yaw about Z). Yaw is 0 for floor zones and for // every zone saved before wall zones existed, so there Mins/Maxs are plain world coordinates. public Point3 Mins { get; set; } public Point3 Maxs { get; set; } public float Yaw { get; set; } public bool IsWall { get; set; } public Rgb Color { get; set; } // Inactive zones stay saved but neither block nor draw. Missing in older files, so defaults on. public bool Active { get; set; } = true; [JsonIgnore] public ZoneShape Shape => new(Mins, Maxs, Yaw, IsWall); // Lowest marked point, where the outline is drawn. Falls back to the box bottom if Points was // emptied by hand. [JsonIgnore] public float LowZ => Points.Count > 0 ? Points.Min(p => p.Z) : Mins.Z; } // A box that can be turned about Z: Mins/Maxs live in a local frame rotated by Yaw degrees. public readonly record struct ZoneShape(Point3 Mins, Point3 Maxs, float Yaw, bool IsWall) { private readonly float _cos = MathF.Cos(Yaw * MathF.PI / 180f); private readonly float _sin = MathF.Sin(Yaw * MathF.PI / 180f); public float Cos => _cos; public float Sin => _sin; public Point3 ToLocal(Point3 w) => new(w.X * _cos + w.Y * _sin, -w.X * _sin + w.Y * _cos, w.Z); // Distance from a world point to the nearest part of the box; 0 if inside. public float DistanceTo(Point3 world) { var p = ToLocal(world); float dx = MathF.Max(0, MathF.Max(Mins.X - p.X, p.X - Maxs.X)); float dy = MathF.Max(0, MathF.Max(Mins.Y - p.Y, p.Y - Maxs.Y)); float dz = MathF.Max(0, MathF.Max(Mins.Z - p.Z, p.Z - Maxs.Z)); return MathF.Sqrt(dx * dx + dy * dy + dz * dz); } public Point3 ToWorld(Point3 l) => new(l.X * _cos - l.Y * _sin, l.X * _sin + l.Y * _cos, l.Z); // Two kinds of zone, told apart by the plane through the 4 points: // - Floor (plane closer to horizontal): an axis-aligned box over the points' footprint. Z runs // from BottomMargin below the lowest point up to whichever is higher: MinHeight above the // lowest point, or TopMargin above the highest. // - Wall (plane closer to vertical, e.g. marked on a tunnel mouth): a WallThickness-deep slab // turned to lie in that plane, spanning the points along the face and from BottomMargin below // the lowest point up to the highest. // An explicit topZ (css_zone_height) replaces the top in both cases. public static ZoneShape Compute(IReadOnlyList points, NoGoZonesConfig config, float? topZ = null) { float lowZ = points.Min(p => p.Z), highZ = points.Max(p => p.Z); if (WallNormal(points) is { } n) { // Local X runs along the face, local Y straight through it. var yaw = MathF.Atan2(n.X, -n.Y) * 180f / MathF.PI; var frame = new ZoneShape(default, default, yaw, true); var local = points.Select(frame.ToLocal).ToList(); float midY = local.Average(p => p.Y), half = config.WallThickness / 2f; return frame with { Mins = new Point3(local.Min(p => p.X), midY - half, lowZ - config.BottomMargin), Maxs = new Point3(local.Max(p => p.X), midY + half, topZ ?? highZ), }; } return new ZoneShape( new Point3(points.Min(p => p.X), points.Min(p => p.Y), lowZ - config.BottomMargin), new Point3(points.Max(p => p.X), points.Max(p => p.Y), topZ ?? Math.Max(lowZ + config.MinHeight, highZ + config.TopMargin)), 0f, false); } // Horizontal unit normal of the points' plane if it's a wall, otherwise null. Uses the largest // cross product among the three pairs from the first point, so marking order doesn't matter. private static Point3? WallNormal(IReadOnlyList points) { var p0 = points[0]; Point3 Sub(Point3 a) => new(a.X - p0.X, a.Y - p0.Y, a.Z - p0.Z); static Point3 Cross(Point3 a, Point3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); static float Len(Point3 v) => MathF.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z); var best = default(Point3); for (var i = 1; i < points.Count; i++) for (var j = i + 1; j < points.Count; j++) { var c = Cross(Sub(points[i]), Sub(points[j])); if (Len(c) > Len(best)) best = c; } var len = Len(best); if (len < 1f || MathF.Abs(best.Z / len) >= 0.5f) return null; // all in a line, or nearer horizontal than vertical: a floor zone var h = MathF.Sqrt(best.X * best.X + best.Y * best.Y); return new Point3(best.X / h, best.Y / h, 0f); } } // One JSON file per map: configs/plugins/NoGoZones/zones/.json, holding a list of zones. public static class ZoneStore { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, IncludeFields = false, DefaultIgnoreCondition = JsonIgnoreCondition.Never, }; public static string ZonesDirectory => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", "configs", "plugins", "NoGoZones", "zones"); public static string PathFor(string map) { // Workshop maps can carry path separators in their name. var safe = string.Concat(map.Select(c => Path.GetInvalidFileNameChars().Contains(c) || c == '/' ? '_' : c)); return Path.Combine(ZonesDirectory, $"{safe}.json"); } public static List Load(string map) { var path = PathFor(map); if (!File.Exists(path)) return []; return JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions) ?? []; } public static void Save(string map, List zones) { Directory.CreateDirectory(ZonesDirectory); var path = PathFor(map); // Write-then-rename so a crash mid-write never leaves a truncated zone file behind. File.WriteAllText(path + ".tmp", JsonSerializer.Serialize(zones, JsonOptions)); File.Move(path + ".tmp", path, overwrite: true); } } public class NoGoZonesConfig : BasePluginConfig { [JsonPropertyName("MinHeight")] public float MinHeight { get; set; } = 128f; [JsonPropertyName("TopMargin")] public float TopMargin { get; set; } = 16f; // Depth of a wall zone as marked (outline, per-tick block), measured through the face. [JsonPropertyName("WallThickness")] public float WallThickness { get; set; } = 16f; [JsonPropertyName("BottomMargin")] public float BottomMargin { get; set; } = 8f; [JsonPropertyName("DefaultColor")] public Rgb DefaultColor { get; set; } = new(255, 0, 0); [JsonPropertyName("BeamWidth")] public float BeamWidth { get; set; } = 1.5f; // Wall zones: horizontal fill lines every FillSpacing units (0 = off), FillWidth wide. [JsonPropertyName("FillSpacing")] public float FillSpacing { get; set; } = 12f; [JsonPropertyName("FillWidth")] public float FillWidth { get; set; } = 4f; // Fill lines' brightness relative to the zone colour (0-1). Beams draw additively, so dim lines // all but vanish against bright walls, especially from a distance. [JsonPropertyName("FillBrightness")] public float FillBrightness { get; set; } = 0.8f; // Fill lines scroll up at this many units/s (negative = down, 0 = still), and/or carry a // brightness wave upwards, one cycle every PulsePeriod seconds. [JsonPropertyName("FillScrollSpeed")] public float FillScrollSpeed { get; set; } = 12f; [JsonPropertyName("FillPulse")] public bool FillPulse { get; set; } = true; [JsonPropertyName("PulsePeriod")] public float PulsePeriod { get; set; } = 1.5f; // Dimmest point of the pulse, as a fraction of FillBrightness (0-1). [JsonPropertyName("PulseMinBrightness")] public float PulseMinBrightness { get; set; } = 0.6f; // Wall zones: a no-entry sign in the middle, up to SignSize units across. [JsonPropertyName("ShowSign")] public bool ShowSign { get; set; } = true; [JsonPropertyName("SignSize")] public float SignSize { get; set; } = 48f; // Draw confirmed zones (outline; wall zones also get the fill lines and sign). [JsonPropertyName("ShowZones")] public bool ShowZones { get; set; } = true; // Let noclipping players (admins flying around) pass through zones. [JsonPropertyName("IgnoreNoclip")] public bool IgnoreNoclip { get; set; } = true; }