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 c77df5ca6f
8 changed files with 1470 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin/
obj/
release-out/

239
Beams.cs Normal file
View file

@ -0,0 +1,239 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace NoGoZones;
// Preview and zone outline drawing. CSS 1.0.375 has no debug-overlay natives, so every line is a real env_beam
// entity (same pattern jRandomSkills uses live). Beams are networked to every client, not just
// the marking admin.
public class BeamGroup
{
// Handles, not CBeam wrappers: a wrapper's pointer goes stale once the entity is gone,
// a handle just stops resolving.
private readonly List<CHandle<CBeam>> _beams = [];
// Wall fill lines that Animate moves and recolours.
private readonly List<FillRow> _rows = [];
// One fill line on a wall face, as two beams either side of the face's centre so a row passing
// the sign can open a gap around it without changing its beam count.
private sealed record FillRow(
CHandle<CBeam>? Left, CHandle<CBeam>? Right, Func<float, float, Vector> At,
float U0, float U1, float Cu, float Cv, float Hole, float V0, float Span, float Offset, int Index, int Count,
Color Color);
public void Line(Vector start, Vector end, Color color, float width) => Spawn(start, end, color, width);
private CHandle<CBeam>? Spawn(Vector start, Vector end, Color color, float width)
{
var beam = Utilities.CreateEntityByName<CBeam>("env_beam");
if (beam == null || !beam.IsValid)
return null;
beam.Render = color;
beam.Width = width;
beam.Teleport(start, new QAngle(0, 0, 0), new Vector(0, 0, 0));
beam.EndPos.X = end.X;
beam.EndPos.Y = end.Y;
beam.EndPos.Z = end.Z;
beam.DispatchSpawn();
var handle = new CHandle<CBeam>(beam.EntityHandle.Raw);
_beams.Add(handle);
return handle;
}
// A small 3-axis cross plus a tall vertical line so the point is findable from a distance.
public void Marker(Vector p, Color color, float width)
{
const float half = 8f;
Line(new Vector(p.X - half, p.Y, p.Z), new Vector(p.X + half, p.Y, p.Z), color, width);
Line(new Vector(p.X, p.Y - half, p.Z), new Vector(p.X, p.Y + half, p.Z), color, width);
Line(new Vector(p.X, p.Y, p.Z - half), new Vector(p.X, p.Y, p.Z + 48f), color, width);
}
// The 12 edges of a (possibly turned) box.
public void Box(ZoneShape shape, Color color, float width)
{
var (mins, maxs) = (shape.Mins, shape.Maxs);
Vector C(bool x, bool y, bool z) =>
shape.ToWorld(new Point3(x ? maxs.X : mins.X, y ? maxs.Y : mins.Y, z ? maxs.Z : mins.Z)).ToVector();
foreach (var z in new[] { false, true })
{
Line(C(false, false, z), C(true, false, z), color, width);
Line(C(true, false, z), C(true, true, z), color, width);
Line(C(true, true, z), C(false, true, z), color, width);
Line(C(false, true, z), C(false, false, z), color, width);
}
foreach (var (x, y) in new[] { (false, false), (true, false), (true, true), (false, true) })
Line(C(x, y, false), C(x, y, true), color, width);
}
// Floor zones: a rectangle crossed corner to corner, flat just above lowZ. Wall zones: a
// rectangle standing on the wall plane from lowZ to the top, filled with horizontal lines
// (animated by Animate) and carrying a no-entry sign in the middle.
public void Outline(ZoneShape shape, float lowZ, Color color, NoGoZonesConfig config)
{
var (mins, maxs) = (shape.Mins, shape.Maxs);
var width = config.BeamWidth;
if (!shape.IsWall)
{
var z = lowZ + 2f; // keep the beams from sinking into the floor
var c = new Point3[]
{ new(mins.X, mins.Y, z), new(maxs.X, mins.Y, z), new(maxs.X, maxs.Y, z), new(mins.X, maxs.Y, z) };
var w = c.Select(p => shape.ToWorld(p).ToVector()).ToArray();
for (var i = 0; i < 4; i++)
Line(w[i], w[(i + 1) % 4], color, width);
Line(w[0], w[2], color, width);
Line(w[1], w[3], color, width);
return;
}
// Work in the face's 2D coordinates: u along the wall (local X), v up (Z).
var y = (mins.Y + maxs.Y) / 2f;
Vector At(float u, float v) => shape.ToWorld(new Point3(u, y, v)).ToVector();
float u0 = mins.X, u1 = maxs.X, v0 = lowZ, v1 = maxs.Z;
float cu = (u0 + u1) / 2f, cv = (v0 + v1) / 2f;
var radius = config.ShowSign ? MathF.Min(config.SignSize, 0.8f * MathF.Min(u1 - u0, v1 - v0)) / 2f : 0f;
var hole = radius > 0 ? radius + 2f : 0f; // keep the fill and X a little clear of the sign
// Border.
Line(At(u0, v0), At(u1, v0), color, width);
Line(At(u1, v0), At(u1, v1), color, width);
Line(At(u1, v1), At(u0, v1), color, width);
Line(At(u0, v1), At(u0, v0), color, width);
// Fill: horizontal lines across the face in a dimmer shade, split around the sign. Evenly
// spaced over the wall's height so scrolling wraps seamlessly.
if (config.FillSpacing > 0)
{
var fb = Math.Clamp(config.FillBrightness, 0f, 1f);
var fill = Color.FromArgb(color.A, (int)(color.R * fb), (int)(color.G * fb), (int)(color.B * fb));
var count = Math.Max(1, (int)MathF.Floor((v1 - v0) / config.FillSpacing));
var span = (v1 - v0) / count;
for (var k = 0; k < count; k++)
{
var placeholder = At(u0, v0);
var row = new FillRow(
Spawn(placeholder, placeholder, fill, config.FillWidth),
Spawn(placeholder, placeholder, fill, config.FillWidth),
At, u0, u1, cu, cv, hole, v0, (v1 - v0), k * span + span / 2f, k, count, fill);
Place(row, 0f);
_rows.Add(row);
}
}
if (radius > 0)
Sign(cu, cv, radius, At);
}
// A no-entry sign: a red disc of stacked horizontal beams with a white bar across the middle.
private void Sign(float cu, float cv, float radius, Func<float, float, Vector> at)
{
var red = Color.FromArgb(255, 220, 0, 0);
var white = Color.FromArgb(255, 255, 255, 255);
// Rows overlap a little so the disc reads as solid.
var rowWidth = MathF.Max(2f, radius / 6f);
var step = rowWidth * 0.75f;
float barHalfWidth = radius * 0.75f, barHalfHeight = radius * 0.2f;
for (var dv = -radius + step / 2f; dv < radius; dv += step)
{
var half = MathF.Sqrt(radius * radius - dv * dv);
var v = cv + dv;
if (MathF.Abs(dv) <= barHalfHeight)
{
Line(at(cu - half, v), at(cu - barHalfWidth, v), red, rowWidth);
Line(at(cu - barHalfWidth, v), at(cu + barHalfWidth, v), white, rowWidth);
Line(at(cu + barHalfWidth, v), at(cu + half, v), red, rowWidth);
}
else
{
Line(at(cu - half, v), at(cu + half, v), red, rowWidth);
}
}
}
public void Clear()
{
foreach (var handle in _beams)
{
var beam = handle.Value;
if (beam is { IsValid: true })
beam.Remove();
}
_beams.Clear();
_rows.Clear();
}
// After a map change the entities are already gone; just drop the handles.
public void Forget()
{
_beams.Clear();
_rows.Clear();
}
// Scrolls the wall fill lines (FillScrollSpeed units/s, wrapping within the wall) and/or runs a
// brightness wave up them (FillPulse). Called from a repeating timer.
public void Animate(float time, NoGoZonesConfig config)
{
var scroll = config.FillScrollSpeed != 0f;
if (_rows.Count == 0 || (!scroll && !config.FillPulse))
return;
foreach (var row in _rows)
{
if (scroll)
Place(row, time * config.FillScrollSpeed);
if (config.FillPulse)
{
var period = MathF.Max(config.PulsePeriod, 0.1f);
var wave = 0.5f + 0.5f * MathF.Sin(2f * MathF.PI * (time / period - (float)row.Index / row.Count));
var min = Math.Clamp(config.PulseMinBrightness, 0f, 1f);
var b = min + (1f - min) * wave;
var color = Color.FromArgb(row.Color.A, (int)(row.Color.R * b), (int)(row.Color.G * b), (int)(row.Color.B * b));
Recolor(row.Left, color);
Recolor(row.Right, color);
}
}
}
// Puts a fill row at its scrolled height and re-cuts it around the sign.
private static void Place(FillRow row, float scrolled)
{
var v = row.V0 + Mod(row.Offset + scrolled, row.Span);
var dv = v - row.Cv;
var du = row.Hole > 0 && MathF.Abs(dv) < row.Hole ? MathF.Sqrt(row.Hole * row.Hole - dv * dv) : 0f;
// Left half runs u0 -> centre (minus the hole), right half centre (plus the hole) -> u1; a
// half the hole swallows collapses to zero length rather than disappearing.
var leftEnd = MathF.Max(row.U0, row.Cu - du);
var rightStart = MathF.Min(row.U1, row.Cu + du);
Move(row.Left, row.At(row.U0, v), row.At(leftEnd, v));
Move(row.Right, row.At(rightStart, v), row.At(row.U1, v));
}
private static float Mod(float a, float m) => ((a % m) + m) % m;
private static void Move(CHandle<CBeam>? handle, Vector start, Vector end)
{
if (handle?.Value is not { IsValid: true } beam)
return;
beam.Teleport(start, null, null);
beam.EndPos.X = end.X;
beam.EndPos.Y = end.Y;
beam.EndPos.Z = end.Z;
Utilities.SetStateChanged(beam, "CBeam", "m_vecEndPos");
}
private static void Recolor(CHandle<CBeam>? handle, Color color)
{
if (handle?.Value is not { IsValid: true } beam)
return;
beam.Render = color;
Utilities.SetStateChanged(beam, "CBaseModelEntity", "m_clrRender");
}
}

188
Blocker.cs Normal file
View file

@ -0,0 +1,188 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace NoGoZones;
// Logical barrier: CSS can't give a spawned entity a custom-size collision shape (see README), so
// zones are enforced by moving players back out every tick.
//
// Works on the player's origin against the zone box grown by the player's hull (Minkowski sum),
// so the whole hull is kept out, not just the feet. Each tick it compares last tick's origin to
// this tick's: if the step crossed into a zone, the player is put back on the face they came
// through, keeping their movement along the face (so walls slide, and the top can be stood on).
// Sweeping the step also stops fast players from skipping over a thin zone between two ticks.
public class Blocker
{
private const float Epsilon = 0.05f;
// Used if a pawn's collision property isn't readable.
private static readonly Point3 DefaultHullMins = new(-16f, -16f, 0f);
private static readonly Point3 DefaultHullMaxs = new(16f, 16f, 72f);
private readonly Dictionary<uint, Point3> _lastOrigin = [];
public void Reset() => _lastOrigin.Clear();
public void Forget(uint pawnIndex) => _lastOrigin.Remove(pawnIndex);
public void Tick(IReadOnlyList<Zone> zones, bool ignoreNoclip)
{
if (zones.Count == 0)
{
_lastOrigin.Clear();
return;
}
foreach (var player in Utilities.GetPlayers())
{
if (player is not { IsValid: true, PawnIsAlive: true })
continue;
var pawn = player.PlayerPawn.Value;
if (pawn is not { IsValid: true } || pawn.AbsOrigin is not { } absOrigin)
continue;
var origin = Point3.From(absOrigin);
var index = pawn.Index;
if (ignoreNoclip && pawn.MoveType == MoveType_t.MOVETYPE_NOCLIP)
{
_lastOrigin[index] = origin;
continue;
}
var (hullMins, hullMaxs) = Hull(pawn);
var previous = _lastOrigin.TryGetValue(index, out var last) ? last : origin;
var velocity = pawn.AbsVelocity;
var v = new Point3(velocity.X, velocity.Y, velocity.Z);
var moved = false;
// Horizontal hull half-widths; the hull is centred on the origin in X/Y.
float hx = MathF.Max(MathF.Abs(hullMins.X), MathF.Abs(hullMaxs.X));
float hy = MathF.Max(MathF.Abs(hullMins.Y), MathF.Abs(hullMaxs.Y));
foreach (var zone in zones)
{
if (!zone.Active)
continue;
// Work in the zone's own frame. A turned zone sees the (world-aligned) hull as wider,
// so grow it by the hull's extent along each local axis.
var shape = zone.Shape;
float c = MathF.Abs(shape.Cos), s = MathF.Abs(shape.Sin);
float ex = hx * c + hy * s, ey = hx * s + hy * c;
// Region the origin must stay out of.
var lo = new Point3(shape.Mins.X - ex, shape.Mins.Y - ey, shape.Mins.Z - hullMaxs.Z);
var hi = new Point3(shape.Maxs.X + ex, shape.Maxs.Y + ey, shape.Maxs.Z - hullMins.Z);
var localOrigin = shape.ToLocal(origin);
var localV = shape.ToLocal(v);
if (Resolve(shape.ToLocal(previous), ref localOrigin, ref localV, lo, hi))
{
origin = shape.ToWorld(localOrigin);
v = shape.ToWorld(localV);
moved = true;
}
}
if (moved)
pawn.Teleport(origin.ToVector(), null, v.ToVector());
_lastOrigin[index] = origin;
}
}
private static (Point3, Point3) Hull(CCSPlayerPawn pawn)
{
var collision = pawn.Collision;
if (collision == null)
return (DefaultHullMins, DefaultHullMaxs);
var mins = Point3.From(collision.Mins);
var maxs = Point3.From(collision.Maxs);
// A zero-size hull means the property wasn't populated; don't let that shrink the zone.
return maxs.X - mins.X < 1f ? (DefaultHullMins, DefaultHullMaxs) : (mins, maxs);
}
private static bool Inside(Point3 p, Point3 lo, Point3 hi) =>
p.X > lo.X && p.X < hi.X && p.Y > lo.Y && p.Y < hi.Y && p.Z > lo.Z && p.Z < hi.Z;
// Returns true if origin/velocity were changed.
private static bool Resolve(Point3 from, ref Point3 to, ref Point3 v, Point3 lo, Point3 hi)
{
if (Inside(from, lo, hi))
{
// Already inside last tick (zone created on top of them, spawn, teleport): push out
// along the shallowest face.
if (!Inside(to, lo, hi))
return false;
PushOutShallowest(ref to, ref v, lo, hi);
return true;
}
// Slab test on the segment from -> to: find where it enters the box, and through which face.
float tEnter = 0f, tExit = 1f;
int axis = -1;
bool fromLow = false;
for (var a = 0; a < 3; a++)
{
float p0 = Get(from, a), d = Get(to, a) - p0, l = Get(lo, a), h = Get(hi, a);
if (MathF.Abs(d) < 1e-6f)
{
if (p0 <= l || p0 >= h)
return false; // parallel to this slab and outside it
continue;
}
float t1 = (l - p0) / d, t2 = (h - p0) / d;
bool enterLow = t1 < t2;
if (!enterLow)
(t1, t2) = (t2, t1);
if (t1 > tEnter)
{
tEnter = t1;
axis = a;
fromLow = enterLow;
}
tExit = MathF.Min(tExit, t2);
if (tEnter > tExit)
return false;
}
if (axis < 0)
return false;
// Clamp just outside the entry face and kill velocity into it; the other two axes keep
// this tick's movement, so the player slides along the face.
Set(ref to, axis, fromLow ? Get(lo, axis) - Epsilon : Get(hi, axis) + Epsilon);
var va = Get(v, axis);
if (fromLow ? va > 0 : va < 0)
Set(ref v, axis, 0f);
return true;
}
private static void PushOutShallowest(ref Point3 p, ref Point3 v, Point3 lo, Point3 hi)
{
int best = 0;
bool toLow = true;
float bestDepth = float.MaxValue;
for (var a = 0; a < 3; a++)
{
float down = Get(p, a) - Get(lo, a), up = Get(hi, a) - Get(p, a);
if (down < bestDepth) { bestDepth = down; best = a; toLow = true; }
if (up < bestDepth) { bestDepth = up; best = a; toLow = false; }
}
Set(ref p, best, toLow ? Get(lo, best) - Epsilon : Get(hi, best) + Epsilon);
Set(ref v, best, 0f);
}
private static float Get(Point3 p, int axis) => axis switch { 0 => p.X, 1 => p.Y, _ => p.Z };
private static void Set(ref Point3 p, int axis, float value)
{
switch (axis)
{
case 0: p.X = value; break;
case 1: p.Y = value; break;
default: p.Z = value; break;
}
}
}

502
NoGoZones.cs Normal file
View file

@ -0,0 +1,502 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace NoGoZones;
public class NoGoZones : BasePlugin, IPluginConfig<NoGoZonesConfig>
{
public override string ModuleName => "NoGoZones";
public override string ModuleVersion => "0.14.0";
public override string ModuleAuthor => "astra";
public override string ModuleDescription => "Admin-marked box zones that players can't enter";
private const string Permission = "@css/root";
private const float TraceDistance = 8192f;
private const int NearCount = 3;
private class Session
{
public required string Name { get; init; }
public Rgb Color { get; set; }
public List<Point3> Points { get; } = [];
public BeamGroup Beams { get; } = new();
public ZoneShape? Box { get; set; }
// Set by css_zone_height; replaces the configured height rules.
public float? TopZ { get; set; }
}
public NoGoZonesConfig Config { get; set; } = new();
// Keyed by SteamID64 so several admins can mark different zones at once.
private readonly Dictionary<ulong, Session> _sessions = [];
private List<Zone> _zones = [];
private readonly Blocker _blocker = new();
private readonly BeamGroup _zoneBeams = new();
public void OnConfigParsed(NoGoZonesConfig config) => Config = config;
public override void Load(bool hotReload)
{
RegisterListener<Listeners.OnTick>(() => _blocker.Tick(_zones, Config.IgnoreNoclip));
RegisterListener<Listeners.OnMapStart>(OnMapStart);
RegisterListener<Listeners.OnMapEnd>(OnMapEnd);
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect);
AddTimer(0.1f, () => _zoneBeams.Animate(Server.CurrentTime, Config), TimerFlags.REPEAT);
// The round restart removes spawned entities, zone outlines included, so redraw each round.
RegisterEventHandler<EventRoundStart>((_, _) =>
{
DrawZones();
return HookResult.Continue;
});
if (hotReload)
{
LoadZones(Server.MapName);
DrawZones();
}
}
public override void Unload(bool hotReload)
{
foreach (var session in _sessions.Values)
session.Beams.Clear();
_sessions.Clear();
_zoneBeams.Clear();
_blocker.Reset();
}
private void OnMapStart(string map)
{
// Entities from the old map are gone; sessions and their beams go with them.
foreach (var session in _sessions.Values)
session.Beams.Forget();
_sessions.Clear();
_zoneBeams.Forget();
_blocker.Reset();
LoadZones(map);
}
private void OnMapEnd()
{
foreach (var session in _sessions.Values)
session.Beams.Forget();
_sessions.Clear();
_zones = [];
_zoneBeams.Forget();
_blocker.Reset();
}
private HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
{
var player = @event.Userid;
if (player is { IsValid: true } && _sessions.Remove(player.SteamID, out var session))
session.Beams.Clear();
return HookResult.Continue;
}
private void DrawZones()
{
_zoneBeams.Clear();
if (!Config.ShowZones)
return;
foreach (var zone in _zones.Where(z => z.Active))
_zoneBeams.Outline(zone.Shape, zone.LowZ, ToColor(zone.Color), Config);
}
private bool LoadZones(string map)
{
try
{
_zones = ZoneStore.Load(map);
_blocker.Reset();
Logger.LogInformation("Loaded {Count} zone(s) for {Map}", _zones.Count, map);
return true;
}
catch (Exception ex)
{
// Keep whatever was active rather than silently dropping every zone on a bad edit.
Logger.LogError(ex, "Failed to load zones for {Map} from {Path}", map, ZoneStore.PathFor(map));
return false;
}
}
[ConsoleCommand("css_zone_start", "Start marking a no-go zone")]
[CommandHelper(minArgs: 1, usage: "<name>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneStart(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true })
return;
var name = command.GetArg(1).Trim();
if (_zones.Any(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
Reply(player, $"A zone named {ChatColors.Yellow}{name}{ChatColors.Default} already exists on this map. Remove it first.");
return;
}
if (_sessions.Any(kv => kv.Key != player.SteamID && kv.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
Reply(player, $"Another admin is already marking {ChatColors.Yellow}{name}{ChatColors.Default}.");
return;
}
if (_sessions.Remove(player.SteamID, out var old))
old.Beams.Clear();
_sessions[player.SteamID] = new Session { Name = name, Color = Config.DefaultColor };
Reply(player, $"Marking {ChatColors.Yellow}{name}{ChatColors.Default}. Aim and run {ChatColors.Green}css_zone_mark{ChatColors.Default} 4 times.");
}
[ConsoleCommand("css_zone_color", "Set the preview color of your zone")]
[CommandHelper(minArgs: 3, usage: "<r> <g> <b>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneColor(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true } || GetSession(player) is not { } session)
return;
if (!byte.TryParse(command.GetArg(1), out var r) || !byte.TryParse(command.GetArg(2), out var g) ||
!byte.TryParse(command.GetArg(3), out var b))
{
Reply(player, "Color values must be 0-255.");
return;
}
session.Color = new Rgb(r, g, b);
Redraw(session);
Reply(player, $"Color set to {r} {g} {b}.");
}
[ConsoleCommand("css_zone_mark", "Mark a zone corner at your crosshair")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneMark(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true } || GetSession(player) is not { } session)
return;
if (session.Points.Count >= 4)
{
Reply(player, $"All 4 points are marked. Use {ChatColors.Green}css_zone_confirm{ChatColors.Default} or {ChatColors.Green}css_zone_cancel{ChatColors.Default}.");
return;
}
if (TraceCrosshair(player) is not { } hit)
return;
session.Points.Add(hit);
session.Beams.Marker(hit.ToVector(), ToColor(session.Color), Config.BeamWidth);
Reply(player, $"Point {session.Points.Count}/4 at {hit.X:F0} {hit.Y:F0} {hit.Z:F0}.");
if (session.Points.Count == 4)
ShowPreview(player, session);
}
[ConsoleCommand("css_zone_height", "Set your zone's top: units above the lowest point, or your crosshair")]
[CommandHelper(minArgs: 0, usage: "[units]", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneHeight(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true } || GetSession(player) is not { } session)
return;
if (session.Points.Count < 4)
{
Reply(player, $"Mark all 4 points first ({session.Points.Count}/4).");
return;
}
var lowZ = session.Points.Min(p => p.Z);
float top;
if (command.ArgCount > 1)
{
if (!float.TryParse(command.GetArg(1), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var units) || units <= 0)
{
Reply(player, "Height must be a positive number of units.");
return;
}
top = lowZ + units;
}
else
{
// No argument: the top goes where the crosshair is, e.g. the underside of a tunnel roof.
if (TraceCrosshair(player) is not { } hit)
return;
if (hit.Z <= lowZ + 1f)
{
Reply(player, "Aim above the lowest marked point to set the top.");
return;
}
top = hit.Z;
}
session.TopZ = top;
session.Beams.Clear();
foreach (var p in session.Points)
session.Beams.Marker(p.ToVector(), ToColor(session.Color), Config.BeamWidth);
ShowPreview(player, session);
}
[ConsoleCommand("css_zone_confirm", "Save the previewed zone")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneConfirm(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true } || GetSession(player) is not { } session)
return;
if (session.Box is not { } box)
{
Reply(player, $"Mark all 4 points first ({session.Points.Count}/4).");
return;
}
if (_zones.Any(z => z.Name.Equals(session.Name, StringComparison.OrdinalIgnoreCase)))
{
Reply(player, $"A zone named {ChatColors.Yellow}{session.Name}{ChatColors.Default} was saved meanwhile. Cancel and pick another name.");
return;
}
var zone = new Zone
{
Name = session.Name,
Points = [..session.Points],
Mins = box.Mins,
Maxs = box.Maxs,
Yaw = box.Yaw,
IsWall = box.IsWall,
Color = session.Color,
};
var updated = new List<Zone>(_zones) { zone };
try
{
ZoneStore.Save(Server.MapName, updated);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to save zone {Name}", zone.Name);
Reply(player, $"{ChatColors.Red}Saving failed{ChatColors.Default}, see the server console. The preview is kept.");
return;
}
_zones = updated;
session.Beams.Clear();
DrawZones();
_sessions.Remove(player.SteamID);
Reply(player, $"Zone {ChatColors.Yellow}{zone.Name}{ChatColors.Default} saved and active.");
}
[ConsoleCommand("css_zone_cancel", "Discard your in-progress zone")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneCancel(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true } || GetSession(player) is not { } session)
return;
session.Beams.Clear();
_sessions.Remove(player.SteamID);
Reply(player, $"Discarded {ChatColors.Yellow}{session.Name}{ChatColors.Default}.");
}
[ConsoleCommand("css_zone_near", "List the zones closest to you")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
[RequiresPermissions(Permission)]
public void OnZoneNear(CCSPlayerController? player, CommandInfo command)
{
if (player is not { IsValid: true })
return;
if (player.PlayerPawn.Value is not { IsValid: true } pawn || pawn.AbsOrigin is not { } origin)
{
Reply(player, "You need to be alive to find nearby zones.");
return;
}
// Inactive zones too: they're invisible, which is exactly when you'd need their name.
var here = Point3.From(origin);
var nearest = _zones
.Select(z => (Zone: z, Distance: z.Shape.DistanceTo(here)))
.OrderBy(x => x.Distance)
.Take(NearCount)
.ToList();
if (nearest.Count == 0)
{
Reply(player, $"No zones on {Server.MapName}.");
return;
}
foreach (var (zone, distance) in nearest)
Reply(player, $"{ChatColors.Yellow}{zone.Name}{ChatColors.Default}: {distance:F0} units, " +
$"{(zone.Active ? "active" : $"{ChatColors.Grey}inactive{ChatColors.Default}")}, {(zone.IsWall ? "wall" : "floor")}");
}
[ConsoleCommand("css_zone_active", "Turn a saved zone on or off")]
[CommandHelper(minArgs: 2, usage: "<name> <1|0>", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions(Permission)]
public void OnZoneActive(CCSPlayerController? player, CommandInfo command)
{
var name = command.GetArg(1).Trim();
var zone = _zones.FirstOrDefault(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (zone == null)
{
command.ReplyToCommand($"[NoGoZones] No zone named {name} on this map.");
return;
}
bool active;
switch (command.GetArg(2).Trim().ToLowerInvariant())
{
case "1" or "on" or "true": active = true; break;
case "0" or "off" or "false": active = false; break;
default:
command.ReplyToCommand("[NoGoZones] Use 1 (active) or 0 (inactive).");
return;
}
var previous = zone.Active;
zone.Active = active;
try
{
ZoneStore.Save(Server.MapName, _zones);
}
catch (Exception ex)
{
zone.Active = previous;
Logger.LogError(ex, "Failed to save zones after toggling {Name}", zone.Name);
command.ReplyToCommand("[NoGoZones] Saving failed, see the server console. Zone unchanged.");
return;
}
DrawZones();
command.ReplyToCommand($"[NoGoZones] {zone.Name} is now {(active ? "active" : "inactive")}.");
}
[ConsoleCommand("css_zone_remove", "Remove a saved zone")]
[CommandHelper(minArgs: 1, usage: "<name>", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions(Permission)]
public void OnZoneRemove(CCSPlayerController? player, CommandInfo command)
{
var name = command.GetArg(1).Trim();
var zone = _zones.FirstOrDefault(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (zone == null)
{
command.ReplyToCommand($"[NoGoZones] No zone named {name} on this map.");
return;
}
var updated = _zones.Where(z => z != zone).ToList();
try
{
ZoneStore.Save(Server.MapName, updated);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to save zones after removing {Name}", zone.Name);
command.ReplyToCommand("[NoGoZones] Saving failed, see the server console. Zone left active.");
return;
}
_zones = updated;
DrawZones();
command.ReplyToCommand($"[NoGoZones] Removed {zone.Name}.");
}
[ConsoleCommand("css_zone_list", "List zones on this map")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions(Permission)]
public void OnZoneList(CCSPlayerController? player, CommandInfo command)
{
if (_zones.Count == 0)
{
command.ReplyToCommand($"[NoGoZones] No zones on {Server.MapName}.");
return;
}
command.ReplyToCommand($"[NoGoZones] {_zones.Count} zone(s) on {Server.MapName}:");
foreach (var z in _zones)
command.ReplyToCommand($" {z.Name}: {(z.Active ? "active" : "inactive")}, {(z.IsWall ? "wall" : "floor")}, {Size(z.Shape)}");
}
[ConsoleCommand("css_zone_reload", "Reload zones for this map from disk")]
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions(Permission)]
public void OnZoneReload(CCSPlayerController? player, CommandInfo command)
{
var loaded = LoadZones(Server.MapName);
DrawZones();
command.ReplyToCommand(loaded
? $"[NoGoZones] Loaded {_zones.Count} zone(s) for {Server.MapName}."
: "[NoGoZones] Reload failed (bad JSON?), see the server console. Previous zones kept.");
}
private Session? GetSession(CCSPlayerController player)
{
if (_sessions.TryGetValue(player.SteamID, out var session))
return session;
Reply(player, $"No zone in progress. Start one with {ChatColors.Green}css_zone_start <name>{ChatColors.Default}.");
return null;
}
private void ShowPreview(CCSPlayerController player, Session session)
{
var shape = ZoneShape.Compute(session.Points, Config, session.TopZ);
session.Box = shape;
session.Beams.Box(shape, ToColor(session.Color), Config.BeamWidth);
Reply(player, $"Preview ({(shape.IsWall ? "wall" : "floor")}): {Size(shape)}. " +
$"{ChatColors.Green}css_zone_height{ChatColors.Default} to change the top, " +
$"{ChatColors.Green}css_zone_confirm{ChatColors.Default} to save, {ChatColors.Green}css_zone_cancel{ChatColors.Default} to discard.");
}
// Point under the player's crosshair, world geometry only (aiming past a player doesn't hit them).
private static Point3? TraceCrosshair(CCSPlayerController player)
{
var pawn = player.PlayerPawn.Value;
if (pawn is not { IsValid: true } || pawn.AbsOrigin is not { } origin)
{
Reply(player, "You need to be alive to aim.");
return null;
}
var eye = new Vector(origin.X, origin.Y, origin.Z + pawn.ViewOffset.Z);
var end = eye + Forward(pawn.EyeAngles) * TraceDistance;
var result = Trace.TraceEndShape(eye, end, pawn, new TraceOptions { InteractsWith = Masks.ShotBrushOnly });
if (!result.DidHit())
{
Reply(player, "Nothing under your crosshair within range.");
return null;
}
// HitPoint is only filled in when the engine reports an exact hit; EndPos (where the ray
// stopped) is always set and is the same spot for a line trace.
return Point3.From(result.HasExactHitPoint ? result.HitPoint : result.EndPos);
}
private void Redraw(Session session)
{
session.Beams.Clear();
var color = ToColor(session.Color);
foreach (var p in session.Points)
session.Beams.Marker(p.ToVector(), color, Config.BeamWidth);
if (session.Box is { } box)
session.Beams.Box(box, color, Config.BeamWidth);
}
private static string Size(ZoneShape s) =>
$"{s.Maxs.X - s.Mins.X:F0} x {s.Maxs.Y - s.Mins.Y:F0} x {s.Maxs.Z - s.Mins.Z:F0}";
private static Color ToColor(Rgb c) => Color.FromArgb(255, c.R, c.G, c.B);
private static Vector Forward(QAngle angles)
{
var pitch = angles.X * MathF.PI / 180f;
var yaw = angles.Y * MathF.PI / 180f;
return new Vector(MathF.Cos(pitch) * MathF.Cos(yaw), MathF.Cos(pitch) * MathF.Sin(yaw), -MathF.Sin(pitch));
}
private static void Reply(CCSPlayerController player, string message) =>
player.PrintToChat($" {ChatColors.Red}[NoGoZones]{ChatColors.Default} {message}");
}

17
NoGoZones.csproj Normal file
View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>NoGoZones</AssemblyName>
<RootNamespace>NoGoZones</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.375">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

217
README.md Normal file
View file

@ -0,0 +1,217 @@
# NoGoZones
Admins mark box-shaped zones in-game with their crosshair. Players can't enter a confirmed zone.
Confirmed zones stay marked on the ground: a rectangle over the zone's footprint with an X through
it, in the zone's color. Set `ShowZones` to `false` to hide them. Blocking works either way.
Built for CounterStrikeSharp **1.0.375** (`net10.0`) on Metamod:Source 2.0.0.1469. No other
dependencies.
## Install
Build with `cd plugins/NoGoZones && ../../build.sh`. Then copy the contents of `compiled/NoGoZones/`
to `game/csgo/addons/counterstrikesharp/plugins/NoGoZones/` on the server.
Or install a release, which unpacks into `game/csgo/`:
```
tar -xzf NoGoZones-<tag>.tar.gz -C /srv/cs2/game/csgo
```
To publish one, commit, bump `ModuleVersion`, then run `FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>`.
It rebuilds the plugin from the committed source in the SDK container, tags HEAD, and uploads
`NoGoZones-<tag>.tar.gz` to the git.zio.sh release. The tarball contains no configs, so an upgrade
never touches the server's settings or zones.
Files the plugin writes, under `addons/counterstrikesharp/configs/plugins/NoGoZones/`:
- `NoGoZones.json`: settings. It's created with defaults on first load.
- `zones/<map>.json`: that map's zones, **one file per map**. It's easier to edit by hand than one
combined file, and a broken edit only affects one map. Characters that aren't valid in a file
name, including `/` from workshop map names, are replaced with `_`.
Zones for the current map are loaded on map start and enforced right away.
## Permission
Every command needs **`@css/root`**. In this server's rank groups, that means `#rank/owner`.
`css_zone_remove`, `css_zone_list` and `css_zone_reload` also work from the server console or RCON.
## Workflow
```
css_zone_start <name> begin marking a zone (one session per admin, by SteamID)
css_zone_color <r> <g> <b> optional, preview color (default red, 255 0 0)
css_zone_mark x4: marks the point under your crosshair and draws a marker there
after the 4th: draws the box outline as a preview (nothing saved yet)
css_zone_height [units] optional, after the 4th mark: set the box top and redraw the preview.
With a number: that many units above the lowest point.
With no argument: at your crosshair (aim at a tunnel roof's underside).
css_zone_confirm save the box to this map's file and start blocking it
css_zone_cancel discard the session and its preview instead
```
Managing saved zones:
```
css_zone_list zones on the current map
css_zone_near the 3 zones nearest to you, with distance and on/off state (finds a name to edit)
css_zone_remove <name> delete a zone and stop blocking it
css_zone_active <name> <1|0> turn a zone on or off without deleting it (also on/off, true/false)
css_zone_reload re-read this map's file (after editing it by hand)
```
If `css_zone_reload` hits invalid JSON, it logs the error and keeps the zones that were already
active. Several admins can mark different zones at once. A session ends on confirm, on cancel, when
its admin disconnects, or on map change.
## How the box is built
The plugin fits a plane through the 4 points. That decides the kind of zone:
**Floor zone.** The plane is closer to horizontal: you marked the zone's footprint on the ground.
- **X/Y:** the min and max of the points, axis-aligned. Mark the points in any order.
- **Z (height):**
- The bottom is `BottomMargin` (8) below the lowest point.
- The top is whichever is higher: `MinHeight` (128) above the lowest point, or `TopMargin` (16)
above the highest point. That's taller than a crouch-jump.
- **Outline:** a rectangle with an X, flat, just above the lowest point.
**Wall zone.** The plane is closer to vertical: you marked a face, e.g. two points at the foot of a
tunnel mouth and two higher up its sides.
- **Shape:** a slab `WallThickness` (16) deep, turned to lie in that face. It doesn't need to line
up with the map's axes.
- **Width:** the spread of the points along the face.
- **Height:** from `BottomMargin` below the lowest point up to the highest point.
- **Outline:** a rectangle standing on the face itself, with no X.
- **Fill:** the face is filled with dimmer horizontal lines, about every `FillSpacing` units, so it
reads as a wall.
- The lines scroll up at `FillScrollSpeed` units/s and wrap around. A negative value scrolls
down, and 0 keeps them still.
- With `FillPulse`, a brightness wave runs up them once every `PulsePeriod` seconds.
- **Sign:** a no-entry sign (a red disc with a white bar) sits in the middle, up to `SignSize`
units across. The fill lines open a gap around it as they pass.
**Changing the top.** `css_zone_height` replaces the top for either kind. Use it to stop a floor
zone under a tunnel roof, or to raise a wall zone above where you could aim.
`NoGoZones.json` settings: `MinHeight`, `TopMargin`, `BottomMargin`, `WallThickness`, `DefaultColor` (`{"R":..,"G":..,"B":..}`),
`BeamWidth`, `FillSpacing` (12; 0 turns the fill off), `FillWidth` (4), `FillBrightness` (0.8), `FillScrollSpeed` (12), `FillPulse` (true), `PulsePeriod` (1.5),
`PulseMinBrightness` (0.6), `ShowSign`, `SignSize` (48),
`ShowZones` (default `true`: draw the rectangle with an X over each saved zone),
`IgnoreNoclip` (default `true`: noclipping players pass through).
Each zone in the file stores:
- `Points`: the 4 raw marks.
- The computed box: `Mins`/`Maxs`, `Yaw` and `IsWall`.
- `Color`.
- `Active`: `false` means the zone is kept but doesn't block or draw. It's set by
`css_zone_active`, and older files without it count as active.
`Mins`/`Maxs` are in the zone's own frame, rotated by `Yaw` degrees about Z. For floor zones `Yaw`
is 0, so they're plain world coordinates. The blocker uses only the box, so a hand edit to it takes
effect after `css_zone_reload`. Editing `Points` doesn't recompute the box.
## Design decisions and API limitations
### Crosshair trace
This uses `Trace.TraceEndShape` from `CounterStrikeSharp.API.Modules.Utils` (native in 1.0.375),
with no Ray-Trace library.
- The ray runs from the eye (`AbsOrigin + ViewOffset.Z`) 8192 units along the view direction.
- It uses the mask `Masks.ShotBrushOnly` and ignores the admin's own pawn. So it hits world
geometry, not players.
- The marked point is `HitPoint` if `HasExactHitPoint` is set, otherwise `EndPos`. CSS's
`TraceResult` only guarantees the former when the engine reports an exact hit.
### Preview drawing: `env_beam` entities
The 1.0.375 source has **no debug-overlay or draw-line natives** at all. The only way to draw a line
in the world is a real `env_beam` entity (`CBeam`): set `Render` (color), `Width`, the start via
`Teleport` and `EndPos`, then `DispatchSpawn`. jRandomSkills already draws its trace beams this way
on this server.
- Each point marker is 3 beams: a small cross, plus a vertical line so it's visible from a
distance.
- The box preview is 12 beams, one per edge.
- They're removed on confirm or cancel.
- A confirmed floor zone is drawn as 6 beams: a rectangle plus its two diagonals, flat over
the footprint just above the floor.
- A wall zone stands on the marked face as a border, plus the fill lines and the sign. That's
about 30–60 beams, depending on its height.
- Each fill line is two beams, either side of the sign, so a line can open a gap as it scrolls
past the sign without spawning or removing beams.
- A 0.1 s timer moves the lines (`Teleport` for the start, `m_vecEndPos` for the end) and
recolours them (`m_clrRender`). Every change is flagged with `SetStateChanged`, so clients
receive it.
- That's about 10 network updates a second per fill beam: a few thousand entity updates a
second across a map full of walls. Set `FillScrollSpeed` to 0 and `FillPulse` to false for
static walls with no updates at all.
- The sign is beams too: a stack of overlapping horizontal rows forming the disc, with the middle
rows split red/white/red for the bar. A real image would need a custom texture on every client,
and clients only get that through a Workshop addon.
- The round restart removes spawned entities, so the outlines are redrawn at every round start.
They're also redrawn after confirm, remove and reload.
**Limitation:** beams are ordinary networked entities, so **every player sees the preview and the
zone outlines**, not only admins. Hiding them from other players would need a `CheckTransmit` filter. That
isn't implemented.
### Blocking: per-tick movement block
**Why not a single custom-size solid:**
- `CCollisionProperty` exposes `Mins`, `Maxs`, `SolidType` and `SolidFlags` as writable schema
fields, but CSS has **no native to rebuild an entity's physics shape** afterwards.
- CS2 player movement collides against the VPhysics shape built from the model at spawn, so
writing the bounds doesn't make a wall.
- Brush entities get their shape from brush models compiled into the map, and those can't be
created at runtime.
**Tried and removed (0.7.0–0.10.0): scaled `dev_cube` walls.**
These are the findings from live tests:
- A per-axis scale isn't possible. The Hammer-style `scales` keyvalue is ignored, and the scene
node only has a single `m_flScale`.
- A uniform scale does scale the collision, and bullets pass through.
So a zone could be filled with overlapping uniformly scaled cubes. The problem is that a cube's
side is capped by the zone's thinnest dimension. That made thin walls either need hundreds of
cubes, or be thickened until they stuck out past the marked face. It was dropped in favour of
beams plus the per-tick block.
The per-tick block works like this. On every tick (`Listeners.OnTick`), for each living player:
1. Work in the zone's own frame, so turned wall zones use the same math. Grow the zone box by
the player's own collision hull (`Collision.Mins`/`Maxs`, so crouching counts). For a turned
zone, it grows by the hull's width along each of the zone's axes. Then check the player's origin against the grown box, which keeps the whole body out,
not just the feet.
2. Sweep from last tick's origin to this tick's. If that step entered the box, move the player
back just outside the face they crossed, and zero their velocity into that face. Movement
along the face is kept, so players slide along the walls. Because the whole step is checked, a
fast player (a speed skill, for example) can't skip through a thin zone between two ticks.
3. If a player is already inside (a zone confirmed on top of them, or a spawn inside one), push
them out through the nearest face.
Tradeoffs compared with real collision:
- **It's corrected after the fact, not prevented.** The engine moves the player first, and the
plugin puts them back in the same tick. Normally this looks like a wall, but under latency the
client can predict a few units into the zone and then snap back. Expect a slight rubber-band
feel at the edge, especially at high speed or with high ping.
- **Only players are blocked.** Bullets, grenades, the C4, chickens and physics props go straight
through. Nothing is there for them to hit.
- **The top of a zone isn't a real floor.** A player on top is held up each tick but isn't
"on ground" to the engine. They can stand there, but can't jump off it normally, and may show
the falling animation. The 128-unit default height makes the top unreachable by jumping.
- **Cost:** it runs every tick, with a cost of players × zones box checks. That's negligible for
realistic zone counts.
## Untested
This compiles against CSS 1.0.375. It has **not been run on the live server** yet. In-game, check:
- beam visibility and color
- how solid the wall feels, including at the edges and with speed skills
- whether players standing on top of a zone behave acceptably

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;
}

115
release.sh Executable file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Build NoGoZones, package it as one tarball and publish it as a Forgejo release.
#
# ./release.sh <tag> e.g. ./release.sh v0.14.0
#
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
# the same tag replaces that release's attachment.
#
# Asset: NoGoZones-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf NoGoZones-<tag>.tar.gz -C /srv/cs2/game/csgo
#
# addons/counterstrikesharp/plugins/NoGoZones/ NoGoZones.dll, .pdb, .deps.json
#
# No configs/: the plugin writes NoGoZones.json itself when it is missing, and zones/<map>.json is
# written by admins in-game, so an upgrade never touches the server's settings or zones.
set -euo pipefail
FORGEJO_URL="https://git.zio.sh"
OWNER="cs2"
REPO="NoGoZones"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
# (ExcludeAssets=runtime); the server already provides it.
PLUGIN_FILES=(
NoGoZones.dll
NoGoZones.pdb
NoGoZones.deps.json
)
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. v0.14.0)" >&2
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
exit 1
fi
for tool in podman jq curl git; do
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
done
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
exit 1
fi
# The tag should name the version the plugin reports in `css_plugins list`.
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' NoGoZones.cs)"
if [[ "${TAG#v}" != "$version" ]]; then
echo "Tag $TAG doesn't match ModuleVersion $version in NoGoZones.cs." >&2
exit 1
fi
# --- Build -----------------------------------------------------------------------
OUT="release-out"
PUBLISH="$OUT/publish"
rm -rf bin obj "$OUT"
podman run --rm -v "$(pwd)":/src:Z -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
# --- Stage (layout of game/csgo/) and package ----------------------------------------
STAGE="$OUT/stage"
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/NoGoZones"
mkdir -p "$PLUGIN_DIR"
for file in "${PLUGIN_FILES[@]}"; do
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
done
TARBALL="NoGoZones-${TAG}.tar.gz"
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
rm -rf "$STAGE" "$PUBLISH"
echo "Packaged:"
tar -tzf "$OUT/$TARBALL"
# --- Tag ---------------------------------------------------------------------------
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "refs/tags/$TAG"
# --- Release -----------------------------------------------------------------------
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
if [[ -z "$release_json" ]]; then
body="$(git log -1 --format=%B "$TAG")"
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
echo "Created release $TAG"
else
echo "Release $TAG already exists, replacing its attachment"
fi
release_id="$(jq -r '.id' <<<"$release_json")"
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
if [[ -n "$existing_id" ]]; then
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
fi
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
echo "Uploaded $TARBALL"
echo
echo "Download URL:"
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"