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 { 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 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 _sessions = []; private List _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(() => _blocker.Tick(_zones, Config.IgnoreNoclip)); RegisterListener(OnMapStart); RegisterListener(OnMapEnd); RegisterEventHandler(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((_, _) => { 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: "", 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: " ", 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(_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: " <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: "", 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 {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}"); }