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

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