NoGoZones 0.14.0: admin-marked no-go zones for CS2

CounterStrikeSharp 1.0.375 plugin. Admins mark 4 points with their crosshair (native
Trace.TraceEndShape), preview the box with env_beam lines, and confirm it into a per-map
zone file. Points in a near-vertical plane make a wall zone, turned to match the face.

- Players are kept out by a per-tick movement block (swept against the zone box grown by
  the player's hull), since CSS can't give a spawned entity custom-size collision.
- Wall zones are drawn as a border with horizontal fill lines (optionally scrolling and
  pulsing) and a beam-built no-entry sign; floor zones as a rectangle with an X.
- Commands: css_zone_start/color/mark/height/confirm/cancel, css_zone_list/near/remove/
  active/reload, all gated on @css/root.
- release.sh builds from the committed source and publishes NoGoZones-<tag>.tar.gz.
This commit is contained in:
Astra 2026-09-25 14:18:26 +01:00
commit 006949d01c
8 changed files with 1470 additions and 0 deletions

189
Zone.cs Normal file
View file

@ -0,0 +1,189 @@
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<Point3> 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<Point3> 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<Point3> 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/<map>.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<Zone> Load(string map)
{
var path = PathFor(map);
if (!File.Exists(path))
return [];
return JsonSerializer.Deserialize<List<Zone>>(File.ReadAllText(path), JsonOptions) ?? [];
}
public static void Save(string map, List<Zone> 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;
}