commit 006949d01c9c811a3e06f5aea0a0205509861c05 Author: Astra Date: Fri Sep 25 14:18:26 2026 +0100 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-.tar.gz. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a3935e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +release-out/ diff --git a/Beams.cs b/Beams.cs new file mode 100644 index 0000000..6ef05b0 --- /dev/null +++ b/Beams.cs @@ -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> _beams = []; + // Wall fill lines that Animate moves and recolours. + private readonly List _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? Left, CHandle? Right, Func 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? Spawn(Vector start, Vector end, Color color, float width) + { + var beam = Utilities.CreateEntityByName("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(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 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? 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? handle, Color color) + { + if (handle?.Value is not { IsValid: true } beam) + return; + beam.Render = color; + Utilities.SetStateChanged(beam, "CBaseModelEntity", "m_clrRender"); + } +} diff --git a/Blocker.cs b/Blocker.cs new file mode 100644 index 0000000..dee7956 --- /dev/null +++ b/Blocker.cs @@ -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 _lastOrigin = []; + + public void Reset() => _lastOrigin.Clear(); + + public void Forget(uint pawnIndex) => _lastOrigin.Remove(pawnIndex); + + public void Tick(IReadOnlyList 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; + } + } +} diff --git a/NoGoZones.cs b/NoGoZones.cs new file mode 100644 index 0000000..93a655f --- /dev/null +++ b/NoGoZones.cs @@ -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 +{ + 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}"); +} diff --git a/NoGoZones.csproj b/NoGoZones.csproj new file mode 100644 index 0000000..931e73b --- /dev/null +++ b/NoGoZones.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + NoGoZones + NoGoZones + + + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc9dd5d --- /dev/null +++ b/README.md @@ -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-.tar.gz -C /srv/cs2/game/csgo +``` + +To publish one, commit, bump `ModuleVersion`, then run `FORGEJO_TOKEN=... ./release.sh v`. +It rebuilds the plugin from the committed source in the SDK container, tags HEAD, and uploads +`NoGoZones-.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/.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 begin marking a zone (one session per admin, by SteamID) +css_zone_color 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 delete a zone and stop blocking it +css_zone_active <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 diff --git a/Zone.cs b/Zone.cs new file mode 100644 index 0000000..2fdd4b1 --- /dev/null +++ b/Zone.cs @@ -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 Points { get; set; } = []; + // Box in the zone's own frame (world rotated by -Yaw about Z). Yaw is 0 for floor zones and for + // every zone saved before wall zones existed, so there Mins/Maxs are plain world coordinates. + public Point3 Mins { get; set; } + public Point3 Maxs { get; set; } + public float Yaw { get; set; } + public bool IsWall { get; set; } + public Rgb Color { get; set; } + // Inactive zones stay saved but neither block nor draw. Missing in older files, so defaults on. + public bool Active { get; set; } = true; + + [JsonIgnore] + public ZoneShape Shape => new(Mins, Maxs, Yaw, IsWall); + + // Lowest marked point, where the outline is drawn. Falls back to the box bottom if Points was + // emptied by hand. + [JsonIgnore] + public float LowZ => Points.Count > 0 ? Points.Min(p => p.Z) : Mins.Z; +} + +// A box that can be turned about Z: Mins/Maxs live in a local frame rotated by Yaw degrees. +public readonly record struct ZoneShape(Point3 Mins, Point3 Maxs, float Yaw, bool IsWall) +{ + private readonly float _cos = MathF.Cos(Yaw * MathF.PI / 180f); + private readonly float _sin = MathF.Sin(Yaw * MathF.PI / 180f); + + public float Cos => _cos; + public float Sin => _sin; + + public Point3 ToLocal(Point3 w) => new(w.X * _cos + w.Y * _sin, -w.X * _sin + w.Y * _cos, w.Z); + // Distance from a world point to the nearest part of the box; 0 if inside. + public float DistanceTo(Point3 world) + { + var p = ToLocal(world); + float dx = MathF.Max(0, MathF.Max(Mins.X - p.X, p.X - Maxs.X)); + float dy = MathF.Max(0, MathF.Max(Mins.Y - p.Y, p.Y - Maxs.Y)); + float dz = MathF.Max(0, MathF.Max(Mins.Z - p.Z, p.Z - Maxs.Z)); + return MathF.Sqrt(dx * dx + dy * dy + dz * dz); + } + + public Point3 ToWorld(Point3 l) => new(l.X * _cos - l.Y * _sin, l.X * _sin + l.Y * _cos, l.Z); + + // Two kinds of zone, told apart by the plane through the 4 points: + // - Floor (plane closer to horizontal): an axis-aligned box over the points' footprint. Z runs + // from BottomMargin below the lowest point up to whichever is higher: MinHeight above the + // lowest point, or TopMargin above the highest. + // - Wall (plane closer to vertical, e.g. marked on a tunnel mouth): a WallThickness-deep slab + // turned to lie in that plane, spanning the points along the face and from BottomMargin below + // the lowest point up to the highest. + // An explicit topZ (css_zone_height) replaces the top in both cases. + public static ZoneShape Compute(IReadOnlyList points, NoGoZonesConfig config, float? topZ = null) + { + float lowZ = points.Min(p => p.Z), highZ = points.Max(p => p.Z); + + if (WallNormal(points) is { } n) + { + // Local X runs along the face, local Y straight through it. + var yaw = MathF.Atan2(n.X, -n.Y) * 180f / MathF.PI; + var frame = new ZoneShape(default, default, yaw, true); + var local = points.Select(frame.ToLocal).ToList(); + float midY = local.Average(p => p.Y), half = config.WallThickness / 2f; + return frame with + { + Mins = new Point3(local.Min(p => p.X), midY - half, lowZ - config.BottomMargin), + Maxs = new Point3(local.Max(p => p.X), midY + half, topZ ?? highZ), + }; + } + + return new ZoneShape( + new Point3(points.Min(p => p.X), points.Min(p => p.Y), lowZ - config.BottomMargin), + new Point3(points.Max(p => p.X), points.Max(p => p.Y), + topZ ?? Math.Max(lowZ + config.MinHeight, highZ + config.TopMargin)), + 0f, false); + } + + // Horizontal unit normal of the points' plane if it's a wall, otherwise null. Uses the largest + // cross product among the three pairs from the first point, so marking order doesn't matter. + private static Point3? WallNormal(IReadOnlyList points) + { + var p0 = points[0]; + Point3 Sub(Point3 a) => new(a.X - p0.X, a.Y - p0.Y, a.Z - p0.Z); + static Point3 Cross(Point3 a, Point3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); + static float Len(Point3 v) => MathF.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z); + + var best = default(Point3); + for (var i = 1; i < points.Count; i++) + for (var j = i + 1; j < points.Count; j++) + { + var c = Cross(Sub(points[i]), Sub(points[j])); + if (Len(c) > Len(best)) + best = c; + } + + var len = Len(best); + if (len < 1f || MathF.Abs(best.Z / len) >= 0.5f) + return null; // all in a line, or nearer horizontal than vertical: a floor zone + var h = MathF.Sqrt(best.X * best.X + best.Y * best.Y); + return new Point3(best.X / h, best.Y / h, 0f); + } +} + +// One JSON file per map: configs/plugins/NoGoZones/zones/.json, holding a list of zones. +public static class ZoneStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + IncludeFields = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + public static string ZonesDirectory => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", + "configs", "plugins", "NoGoZones", "zones"); + + public static string PathFor(string map) + { + // Workshop maps can carry path separators in their name. + var safe = string.Concat(map.Select(c => Path.GetInvalidFileNameChars().Contains(c) || c == '/' ? '_' : c)); + return Path.Combine(ZonesDirectory, $"{safe}.json"); + } + + public static List Load(string map) + { + var path = PathFor(map); + if (!File.Exists(path)) + return []; + return JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions) ?? []; + } + + public static void Save(string map, List zones) + { + Directory.CreateDirectory(ZonesDirectory); + var path = PathFor(map); + // Write-then-rename so a crash mid-write never leaves a truncated zone file behind. + File.WriteAllText(path + ".tmp", JsonSerializer.Serialize(zones, JsonOptions)); + File.Move(path + ".tmp", path, overwrite: true); + } +} + +public class NoGoZonesConfig : BasePluginConfig +{ + [JsonPropertyName("MinHeight")] public float MinHeight { get; set; } = 128f; + [JsonPropertyName("TopMargin")] public float TopMargin { get; set; } = 16f; + // Depth of a wall zone as marked (outline, per-tick block), measured through the face. + [JsonPropertyName("WallThickness")] public float WallThickness { get; set; } = 16f; + [JsonPropertyName("BottomMargin")] public float BottomMargin { get; set; } = 8f; + [JsonPropertyName("DefaultColor")] public Rgb DefaultColor { get; set; } = new(255, 0, 0); + [JsonPropertyName("BeamWidth")] public float BeamWidth { get; set; } = 1.5f; + // Wall zones: horizontal fill lines every FillSpacing units (0 = off), FillWidth wide. + [JsonPropertyName("FillSpacing")] public float FillSpacing { get; set; } = 12f; + [JsonPropertyName("FillWidth")] public float FillWidth { get; set; } = 4f; + // Fill lines' brightness relative to the zone colour (0-1). Beams draw additively, so dim lines + // all but vanish against bright walls, especially from a distance. + [JsonPropertyName("FillBrightness")] public float FillBrightness { get; set; } = 0.8f; + // Fill lines scroll up at this many units/s (negative = down, 0 = still), and/or carry a + // brightness wave upwards, one cycle every PulsePeriod seconds. + [JsonPropertyName("FillScrollSpeed")] public float FillScrollSpeed { get; set; } = 12f; + [JsonPropertyName("FillPulse")] public bool FillPulse { get; set; } = true; + [JsonPropertyName("PulsePeriod")] public float PulsePeriod { get; set; } = 1.5f; + // Dimmest point of the pulse, as a fraction of FillBrightness (0-1). + [JsonPropertyName("PulseMinBrightness")] public float PulseMinBrightness { get; set; } = 0.6f; + // Wall zones: a no-entry sign in the middle, up to SignSize units across. + [JsonPropertyName("ShowSign")] public bool ShowSign { get; set; } = true; + [JsonPropertyName("SignSize")] public float SignSize { get; set; } = 48f; + // Draw confirmed zones (outline; wall zones also get the fill lines and sign). + [JsonPropertyName("ShowZones")] public bool ShowZones { get; set; } = true; + // Let noclipping players (admins flying around) pass through zones. + [JsonPropertyName("IgnoreNoclip")] public bool IgnoreNoclip { get; set; } = true; +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..734739d --- /dev/null +++ b/release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build NoGoZones, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh 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-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf NoGoZones-.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/.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 (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"