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:
commit
c77df5ca6f
8 changed files with 1470 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bin/
|
||||
obj/
|
||||
release-out/
|
||||
239
Beams.cs
Normal file
239
Beams.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using System.Drawing;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.Utils;
|
||||
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
|
||||
|
||||
namespace NoGoZones;
|
||||
|
||||
// Preview and zone outline drawing. CSS 1.0.375 has no debug-overlay natives, so every line is a real env_beam
|
||||
// entity (same pattern jRandomSkills uses live). Beams are networked to every client, not just
|
||||
// the marking admin.
|
||||
public class BeamGroup
|
||||
{
|
||||
// Handles, not CBeam wrappers: a wrapper's pointer goes stale once the entity is gone,
|
||||
// a handle just stops resolving.
|
||||
private readonly List<CHandle<CBeam>> _beams = [];
|
||||
// Wall fill lines that Animate moves and recolours.
|
||||
private readonly List<FillRow> _rows = [];
|
||||
|
||||
// One fill line on a wall face, as two beams either side of the face's centre so a row passing
|
||||
// the sign can open a gap around it without changing its beam count.
|
||||
private sealed record FillRow(
|
||||
CHandle<CBeam>? Left, CHandle<CBeam>? Right, Func<float, float, Vector> At,
|
||||
float U0, float U1, float Cu, float Cv, float Hole, float V0, float Span, float Offset, int Index, int Count,
|
||||
Color Color);
|
||||
|
||||
public void Line(Vector start, Vector end, Color color, float width) => Spawn(start, end, color, width);
|
||||
|
||||
private CHandle<CBeam>? Spawn(Vector start, Vector end, Color color, float width)
|
||||
{
|
||||
var beam = Utilities.CreateEntityByName<CBeam>("env_beam");
|
||||
if (beam == null || !beam.IsValid)
|
||||
return null;
|
||||
|
||||
beam.Render = color;
|
||||
beam.Width = width;
|
||||
beam.Teleport(start, new QAngle(0, 0, 0), new Vector(0, 0, 0));
|
||||
beam.EndPos.X = end.X;
|
||||
beam.EndPos.Y = end.Y;
|
||||
beam.EndPos.Z = end.Z;
|
||||
beam.DispatchSpawn();
|
||||
|
||||
var handle = new CHandle<CBeam>(beam.EntityHandle.Raw);
|
||||
_beams.Add(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// A small 3-axis cross plus a tall vertical line so the point is findable from a distance.
|
||||
public void Marker(Vector p, Color color, float width)
|
||||
{
|
||||
const float half = 8f;
|
||||
Line(new Vector(p.X - half, p.Y, p.Z), new Vector(p.X + half, p.Y, p.Z), color, width);
|
||||
Line(new Vector(p.X, p.Y - half, p.Z), new Vector(p.X, p.Y + half, p.Z), color, width);
|
||||
Line(new Vector(p.X, p.Y, p.Z - half), new Vector(p.X, p.Y, p.Z + 48f), color, width);
|
||||
}
|
||||
|
||||
// The 12 edges of a (possibly turned) box.
|
||||
public void Box(ZoneShape shape, Color color, float width)
|
||||
{
|
||||
var (mins, maxs) = (shape.Mins, shape.Maxs);
|
||||
Vector C(bool x, bool y, bool z) =>
|
||||
shape.ToWorld(new Point3(x ? maxs.X : mins.X, y ? maxs.Y : mins.Y, z ? maxs.Z : mins.Z)).ToVector();
|
||||
|
||||
foreach (var z in new[] { false, true })
|
||||
{
|
||||
Line(C(false, false, z), C(true, false, z), color, width);
|
||||
Line(C(true, false, z), C(true, true, z), color, width);
|
||||
Line(C(true, true, z), C(false, true, z), color, width);
|
||||
Line(C(false, true, z), C(false, false, z), color, width);
|
||||
}
|
||||
foreach (var (x, y) in new[] { (false, false), (true, false), (true, true), (false, true) })
|
||||
Line(C(x, y, false), C(x, y, true), color, width);
|
||||
}
|
||||
|
||||
// Floor zones: a rectangle crossed corner to corner, flat just above lowZ. Wall zones: a
|
||||
// rectangle standing on the wall plane from lowZ to the top, filled with horizontal lines
|
||||
// (animated by Animate) and carrying a no-entry sign in the middle.
|
||||
public void Outline(ZoneShape shape, float lowZ, Color color, NoGoZonesConfig config)
|
||||
{
|
||||
var (mins, maxs) = (shape.Mins, shape.Maxs);
|
||||
var width = config.BeamWidth;
|
||||
if (!shape.IsWall)
|
||||
{
|
||||
var z = lowZ + 2f; // keep the beams from sinking into the floor
|
||||
var c = new Point3[]
|
||||
{ new(mins.X, mins.Y, z), new(maxs.X, mins.Y, z), new(maxs.X, maxs.Y, z), new(mins.X, maxs.Y, z) };
|
||||
var w = c.Select(p => shape.ToWorld(p).ToVector()).ToArray();
|
||||
for (var i = 0; i < 4; i++)
|
||||
Line(w[i], w[(i + 1) % 4], color, width);
|
||||
Line(w[0], w[2], color, width);
|
||||
Line(w[1], w[3], color, width);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work in the face's 2D coordinates: u along the wall (local X), v up (Z).
|
||||
var y = (mins.Y + maxs.Y) / 2f;
|
||||
Vector At(float u, float v) => shape.ToWorld(new Point3(u, y, v)).ToVector();
|
||||
float u0 = mins.X, u1 = maxs.X, v0 = lowZ, v1 = maxs.Z;
|
||||
float cu = (u0 + u1) / 2f, cv = (v0 + v1) / 2f;
|
||||
|
||||
var radius = config.ShowSign ? MathF.Min(config.SignSize, 0.8f * MathF.Min(u1 - u0, v1 - v0)) / 2f : 0f;
|
||||
var hole = radius > 0 ? radius + 2f : 0f; // keep the fill and X a little clear of the sign
|
||||
|
||||
// Border.
|
||||
Line(At(u0, v0), At(u1, v0), color, width);
|
||||
Line(At(u1, v0), At(u1, v1), color, width);
|
||||
Line(At(u1, v1), At(u0, v1), color, width);
|
||||
Line(At(u0, v1), At(u0, v0), color, width);
|
||||
|
||||
// Fill: horizontal lines across the face in a dimmer shade, split around the sign. Evenly
|
||||
// spaced over the wall's height so scrolling wraps seamlessly.
|
||||
if (config.FillSpacing > 0)
|
||||
{
|
||||
var fb = Math.Clamp(config.FillBrightness, 0f, 1f);
|
||||
var fill = Color.FromArgb(color.A, (int)(color.R * fb), (int)(color.G * fb), (int)(color.B * fb));
|
||||
var count = Math.Max(1, (int)MathF.Floor((v1 - v0) / config.FillSpacing));
|
||||
var span = (v1 - v0) / count;
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var placeholder = At(u0, v0);
|
||||
var row = new FillRow(
|
||||
Spawn(placeholder, placeholder, fill, config.FillWidth),
|
||||
Spawn(placeholder, placeholder, fill, config.FillWidth),
|
||||
At, u0, u1, cu, cv, hole, v0, (v1 - v0), k * span + span / 2f, k, count, fill);
|
||||
Place(row, 0f);
|
||||
_rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (radius > 0)
|
||||
Sign(cu, cv, radius, At);
|
||||
}
|
||||
|
||||
// A no-entry sign: a red disc of stacked horizontal beams with a white bar across the middle.
|
||||
private void Sign(float cu, float cv, float radius, Func<float, float, Vector> at)
|
||||
{
|
||||
var red = Color.FromArgb(255, 220, 0, 0);
|
||||
var white = Color.FromArgb(255, 255, 255, 255);
|
||||
// Rows overlap a little so the disc reads as solid.
|
||||
var rowWidth = MathF.Max(2f, radius / 6f);
|
||||
var step = rowWidth * 0.75f;
|
||||
float barHalfWidth = radius * 0.75f, barHalfHeight = radius * 0.2f;
|
||||
|
||||
for (var dv = -radius + step / 2f; dv < radius; dv += step)
|
||||
{
|
||||
var half = MathF.Sqrt(radius * radius - dv * dv);
|
||||
var v = cv + dv;
|
||||
if (MathF.Abs(dv) <= barHalfHeight)
|
||||
{
|
||||
Line(at(cu - half, v), at(cu - barHalfWidth, v), red, rowWidth);
|
||||
Line(at(cu - barHalfWidth, v), at(cu + barHalfWidth, v), white, rowWidth);
|
||||
Line(at(cu + barHalfWidth, v), at(cu + half, v), red, rowWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
Line(at(cu - half, v), at(cu + half, v), red, rowWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var handle in _beams)
|
||||
{
|
||||
var beam = handle.Value;
|
||||
if (beam is { IsValid: true })
|
||||
beam.Remove();
|
||||
}
|
||||
_beams.Clear();
|
||||
_rows.Clear();
|
||||
}
|
||||
|
||||
// After a map change the entities are already gone; just drop the handles.
|
||||
public void Forget()
|
||||
{
|
||||
_beams.Clear();
|
||||
_rows.Clear();
|
||||
}
|
||||
|
||||
// Scrolls the wall fill lines (FillScrollSpeed units/s, wrapping within the wall) and/or runs a
|
||||
// brightness wave up them (FillPulse). Called from a repeating timer.
|
||||
public void Animate(float time, NoGoZonesConfig config)
|
||||
{
|
||||
var scroll = config.FillScrollSpeed != 0f;
|
||||
if (_rows.Count == 0 || (!scroll && !config.FillPulse))
|
||||
return;
|
||||
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
if (scroll)
|
||||
Place(row, time * config.FillScrollSpeed);
|
||||
if (config.FillPulse)
|
||||
{
|
||||
var period = MathF.Max(config.PulsePeriod, 0.1f);
|
||||
var wave = 0.5f + 0.5f * MathF.Sin(2f * MathF.PI * (time / period - (float)row.Index / row.Count));
|
||||
var min = Math.Clamp(config.PulseMinBrightness, 0f, 1f);
|
||||
var b = min + (1f - min) * wave;
|
||||
var color = Color.FromArgb(row.Color.A, (int)(row.Color.R * b), (int)(row.Color.G * b), (int)(row.Color.B * b));
|
||||
Recolor(row.Left, color);
|
||||
Recolor(row.Right, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puts a fill row at its scrolled height and re-cuts it around the sign.
|
||||
private static void Place(FillRow row, float scrolled)
|
||||
{
|
||||
var v = row.V0 + Mod(row.Offset + scrolled, row.Span);
|
||||
var dv = v - row.Cv;
|
||||
var du = row.Hole > 0 && MathF.Abs(dv) < row.Hole ? MathF.Sqrt(row.Hole * row.Hole - dv * dv) : 0f;
|
||||
// Left half runs u0 -> centre (minus the hole), right half centre (plus the hole) -> u1; a
|
||||
// half the hole swallows collapses to zero length rather than disappearing.
|
||||
var leftEnd = MathF.Max(row.U0, row.Cu - du);
|
||||
var rightStart = MathF.Min(row.U1, row.Cu + du);
|
||||
Move(row.Left, row.At(row.U0, v), row.At(leftEnd, v));
|
||||
Move(row.Right, row.At(rightStart, v), row.At(row.U1, v));
|
||||
}
|
||||
|
||||
private static float Mod(float a, float m) => ((a % m) + m) % m;
|
||||
|
||||
private static void Move(CHandle<CBeam>? handle, Vector start, Vector end)
|
||||
{
|
||||
if (handle?.Value is not { IsValid: true } beam)
|
||||
return;
|
||||
beam.Teleport(start, null, null);
|
||||
beam.EndPos.X = end.X;
|
||||
beam.EndPos.Y = end.Y;
|
||||
beam.EndPos.Z = end.Z;
|
||||
Utilities.SetStateChanged(beam, "CBeam", "m_vecEndPos");
|
||||
}
|
||||
|
||||
private static void Recolor(CHandle<CBeam>? handle, Color color)
|
||||
{
|
||||
if (handle?.Value is not { IsValid: true } beam)
|
||||
return;
|
||||
beam.Render = color;
|
||||
Utilities.SetStateChanged(beam, "CBaseModelEntity", "m_clrRender");
|
||||
}
|
||||
}
|
||||
188
Blocker.cs
Normal file
188
Blocker.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
|
||||
|
||||
namespace NoGoZones;
|
||||
|
||||
// Logical barrier: CSS can't give a spawned entity a custom-size collision shape (see README), so
|
||||
// zones are enforced by moving players back out every tick.
|
||||
//
|
||||
// Works on the player's origin against the zone box grown by the player's hull (Minkowski sum),
|
||||
// so the whole hull is kept out, not just the feet. Each tick it compares last tick's origin to
|
||||
// this tick's: if the step crossed into a zone, the player is put back on the face they came
|
||||
// through, keeping their movement along the face (so walls slide, and the top can be stood on).
|
||||
// Sweeping the step also stops fast players from skipping over a thin zone between two ticks.
|
||||
public class Blocker
|
||||
{
|
||||
private const float Epsilon = 0.05f;
|
||||
|
||||
// Used if a pawn's collision property isn't readable.
|
||||
private static readonly Point3 DefaultHullMins = new(-16f, -16f, 0f);
|
||||
private static readonly Point3 DefaultHullMaxs = new(16f, 16f, 72f);
|
||||
|
||||
private readonly Dictionary<uint, Point3> _lastOrigin = [];
|
||||
|
||||
public void Reset() => _lastOrigin.Clear();
|
||||
|
||||
public void Forget(uint pawnIndex) => _lastOrigin.Remove(pawnIndex);
|
||||
|
||||
public void Tick(IReadOnlyList<Zone> zones, bool ignoreNoclip)
|
||||
{
|
||||
if (zones.Count == 0)
|
||||
{
|
||||
_lastOrigin.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var player in Utilities.GetPlayers())
|
||||
{
|
||||
if (player is not { IsValid: true, PawnIsAlive: true })
|
||||
continue;
|
||||
var pawn = player.PlayerPawn.Value;
|
||||
if (pawn is not { IsValid: true } || pawn.AbsOrigin is not { } absOrigin)
|
||||
continue;
|
||||
|
||||
var origin = Point3.From(absOrigin);
|
||||
var index = pawn.Index;
|
||||
|
||||
if (ignoreNoclip && pawn.MoveType == MoveType_t.MOVETYPE_NOCLIP)
|
||||
{
|
||||
_lastOrigin[index] = origin;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (hullMins, hullMaxs) = Hull(pawn);
|
||||
var previous = _lastOrigin.TryGetValue(index, out var last) ? last : origin;
|
||||
var velocity = pawn.AbsVelocity;
|
||||
var v = new Point3(velocity.X, velocity.Y, velocity.Z);
|
||||
var moved = false;
|
||||
|
||||
// Horizontal hull half-widths; the hull is centred on the origin in X/Y.
|
||||
float hx = MathF.Max(MathF.Abs(hullMins.X), MathF.Abs(hullMaxs.X));
|
||||
float hy = MathF.Max(MathF.Abs(hullMins.Y), MathF.Abs(hullMaxs.Y));
|
||||
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
if (!zone.Active)
|
||||
continue;
|
||||
|
||||
// Work in the zone's own frame. A turned zone sees the (world-aligned) hull as wider,
|
||||
// so grow it by the hull's extent along each local axis.
|
||||
var shape = zone.Shape;
|
||||
float c = MathF.Abs(shape.Cos), s = MathF.Abs(shape.Sin);
|
||||
float ex = hx * c + hy * s, ey = hx * s + hy * c;
|
||||
|
||||
// Region the origin must stay out of.
|
||||
var lo = new Point3(shape.Mins.X - ex, shape.Mins.Y - ey, shape.Mins.Z - hullMaxs.Z);
|
||||
var hi = new Point3(shape.Maxs.X + ex, shape.Maxs.Y + ey, shape.Maxs.Z - hullMins.Z);
|
||||
|
||||
var localOrigin = shape.ToLocal(origin);
|
||||
var localV = shape.ToLocal(v);
|
||||
if (Resolve(shape.ToLocal(previous), ref localOrigin, ref localV, lo, hi))
|
||||
{
|
||||
origin = shape.ToWorld(localOrigin);
|
||||
v = shape.ToWorld(localV);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (moved)
|
||||
pawn.Teleport(origin.ToVector(), null, v.ToVector());
|
||||
|
||||
_lastOrigin[index] = origin;
|
||||
}
|
||||
}
|
||||
|
||||
private static (Point3, Point3) Hull(CCSPlayerPawn pawn)
|
||||
{
|
||||
var collision = pawn.Collision;
|
||||
if (collision == null)
|
||||
return (DefaultHullMins, DefaultHullMaxs);
|
||||
var mins = Point3.From(collision.Mins);
|
||||
var maxs = Point3.From(collision.Maxs);
|
||||
// A zero-size hull means the property wasn't populated; don't let that shrink the zone.
|
||||
return maxs.X - mins.X < 1f ? (DefaultHullMins, DefaultHullMaxs) : (mins, maxs);
|
||||
}
|
||||
|
||||
private static bool Inside(Point3 p, Point3 lo, Point3 hi) =>
|
||||
p.X > lo.X && p.X < hi.X && p.Y > lo.Y && p.Y < hi.Y && p.Z > lo.Z && p.Z < hi.Z;
|
||||
|
||||
// Returns true if origin/velocity were changed.
|
||||
private static bool Resolve(Point3 from, ref Point3 to, ref Point3 v, Point3 lo, Point3 hi)
|
||||
{
|
||||
if (Inside(from, lo, hi))
|
||||
{
|
||||
// Already inside last tick (zone created on top of them, spawn, teleport): push out
|
||||
// along the shallowest face.
|
||||
if (!Inside(to, lo, hi))
|
||||
return false;
|
||||
PushOutShallowest(ref to, ref v, lo, hi);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slab test on the segment from -> to: find where it enters the box, and through which face.
|
||||
float tEnter = 0f, tExit = 1f;
|
||||
int axis = -1;
|
||||
bool fromLow = false;
|
||||
for (var a = 0; a < 3; a++)
|
||||
{
|
||||
float p0 = Get(from, a), d = Get(to, a) - p0, l = Get(lo, a), h = Get(hi, a);
|
||||
if (MathF.Abs(d) < 1e-6f)
|
||||
{
|
||||
if (p0 <= l || p0 >= h)
|
||||
return false; // parallel to this slab and outside it
|
||||
continue;
|
||||
}
|
||||
float t1 = (l - p0) / d, t2 = (h - p0) / d;
|
||||
bool enterLow = t1 < t2;
|
||||
if (!enterLow)
|
||||
(t1, t2) = (t2, t1);
|
||||
if (t1 > tEnter)
|
||||
{
|
||||
tEnter = t1;
|
||||
axis = a;
|
||||
fromLow = enterLow;
|
||||
}
|
||||
tExit = MathF.Min(tExit, t2);
|
||||
if (tEnter > tExit)
|
||||
return false;
|
||||
}
|
||||
if (axis < 0)
|
||||
return false;
|
||||
|
||||
// Clamp just outside the entry face and kill velocity into it; the other two axes keep
|
||||
// this tick's movement, so the player slides along the face.
|
||||
Set(ref to, axis, fromLow ? Get(lo, axis) - Epsilon : Get(hi, axis) + Epsilon);
|
||||
var va = Get(v, axis);
|
||||
if (fromLow ? va > 0 : va < 0)
|
||||
Set(ref v, axis, 0f);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void PushOutShallowest(ref Point3 p, ref Point3 v, Point3 lo, Point3 hi)
|
||||
{
|
||||
int best = 0;
|
||||
bool toLow = true;
|
||||
float bestDepth = float.MaxValue;
|
||||
for (var a = 0; a < 3; a++)
|
||||
{
|
||||
float down = Get(p, a) - Get(lo, a), up = Get(hi, a) - Get(p, a);
|
||||
if (down < bestDepth) { bestDepth = down; best = a; toLow = true; }
|
||||
if (up < bestDepth) { bestDepth = up; best = a; toLow = false; }
|
||||
}
|
||||
Set(ref p, best, toLow ? Get(lo, best) - Epsilon : Get(hi, best) + Epsilon);
|
||||
Set(ref v, best, 0f);
|
||||
}
|
||||
|
||||
private static float Get(Point3 p, int axis) => axis switch { 0 => p.X, 1 => p.Y, _ => p.Z };
|
||||
|
||||
private static void Set(ref Point3 p, int axis, float value)
|
||||
{
|
||||
switch (axis)
|
||||
{
|
||||
case 0: p.X = value; break;
|
||||
case 1: p.Y = value; break;
|
||||
default: p.Z = value; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
502
NoGoZones.cs
Normal file
502
NoGoZones.cs
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
using System.Drawing;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||||
using CounterStrikeSharp.API.Modules.Admin;
|
||||
using CounterStrikeSharp.API.Modules.Commands;
|
||||
using CounterStrikeSharp.API.Modules.Timers;
|
||||
using CounterStrikeSharp.API.Modules.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
|
||||
|
||||
namespace NoGoZones;
|
||||
|
||||
public class NoGoZones : BasePlugin, IPluginConfig<NoGoZonesConfig>
|
||||
{
|
||||
public override string ModuleName => "NoGoZones";
|
||||
public override string ModuleVersion => "0.14.0";
|
||||
public override string ModuleAuthor => "astra";
|
||||
public override string ModuleDescription => "Admin-marked box zones that players can't enter";
|
||||
|
||||
private const string Permission = "@css/root";
|
||||
private const float TraceDistance = 8192f;
|
||||
private const int NearCount = 3;
|
||||
|
||||
private class Session
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public Rgb Color { get; set; }
|
||||
public List<Point3> Points { get; } = [];
|
||||
public BeamGroup Beams { get; } = new();
|
||||
public ZoneShape? Box { get; set; }
|
||||
// Set by css_zone_height; replaces the configured height rules.
|
||||
public float? TopZ { get; set; }
|
||||
}
|
||||
|
||||
public NoGoZonesConfig Config { get; set; } = new();
|
||||
|
||||
// Keyed by SteamID64 so several admins can mark different zones at once.
|
||||
private readonly Dictionary<ulong, Session> _sessions = [];
|
||||
private List<Zone> _zones = [];
|
||||
private readonly Blocker _blocker = new();
|
||||
private readonly BeamGroup _zoneBeams = new();
|
||||
|
||||
public void OnConfigParsed(NoGoZonesConfig config) => Config = config;
|
||||
|
||||
public override void Load(bool hotReload)
|
||||
{
|
||||
RegisterListener<Listeners.OnTick>(() => _blocker.Tick(_zones, Config.IgnoreNoclip));
|
||||
RegisterListener<Listeners.OnMapStart>(OnMapStart);
|
||||
RegisterListener<Listeners.OnMapEnd>(OnMapEnd);
|
||||
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect);
|
||||
AddTimer(0.1f, () => _zoneBeams.Animate(Server.CurrentTime, Config), TimerFlags.REPEAT);
|
||||
// The round restart removes spawned entities, zone outlines included, so redraw each round.
|
||||
RegisterEventHandler<EventRoundStart>((_, _) =>
|
||||
{
|
||||
DrawZones();
|
||||
return HookResult.Continue;
|
||||
});
|
||||
|
||||
if (hotReload)
|
||||
{
|
||||
LoadZones(Server.MapName);
|
||||
DrawZones();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Unload(bool hotReload)
|
||||
{
|
||||
foreach (var session in _sessions.Values)
|
||||
session.Beams.Clear();
|
||||
_sessions.Clear();
|
||||
_zoneBeams.Clear();
|
||||
_blocker.Reset();
|
||||
}
|
||||
|
||||
private void OnMapStart(string map)
|
||||
{
|
||||
// Entities from the old map are gone; sessions and their beams go with them.
|
||||
foreach (var session in _sessions.Values)
|
||||
session.Beams.Forget();
|
||||
_sessions.Clear();
|
||||
_zoneBeams.Forget();
|
||||
_blocker.Reset();
|
||||
LoadZones(map);
|
||||
}
|
||||
|
||||
private void OnMapEnd()
|
||||
{
|
||||
foreach (var session in _sessions.Values)
|
||||
session.Beams.Forget();
|
||||
_sessions.Clear();
|
||||
_zones = [];
|
||||
_zoneBeams.Forget();
|
||||
_blocker.Reset();
|
||||
}
|
||||
|
||||
private HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
|
||||
{
|
||||
var player = @event.Userid;
|
||||
if (player is { IsValid: true } && _sessions.Remove(player.SteamID, out var session))
|
||||
session.Beams.Clear();
|
||||
return HookResult.Continue;
|
||||
}
|
||||
|
||||
private void DrawZones()
|
||||
{
|
||||
_zoneBeams.Clear();
|
||||
if (!Config.ShowZones)
|
||||
return;
|
||||
foreach (var zone in _zones.Where(z => z.Active))
|
||||
_zoneBeams.Outline(zone.Shape, zone.LowZ, ToColor(zone.Color), Config);
|
||||
}
|
||||
|
||||
private bool LoadZones(string map)
|
||||
{
|
||||
try
|
||||
{
|
||||
_zones = ZoneStore.Load(map);
|
||||
_blocker.Reset();
|
||||
Logger.LogInformation("Loaded {Count} zone(s) for {Map}", _zones.Count, map);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep whatever was active rather than silently dropping every zone on a bad edit.
|
||||
Logger.LogError(ex, "Failed to load zones for {Map} from {Path}", map, ZoneStore.PathFor(map));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_start", "Start marking a no-go zone")]
|
||||
[CommandHelper(minArgs: 1, usage: "<name>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneStart(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true })
|
||||
return;
|
||||
|
||||
var name = command.GetArg(1).Trim();
|
||||
if (_zones.Any(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Reply(player, $"A zone named {ChatColors.Yellow}{name}{ChatColors.Default} already exists on this map. Remove it first.");
|
||||
return;
|
||||
}
|
||||
if (_sessions.Any(kv => kv.Key != player.SteamID && kv.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Reply(player, $"Another admin is already marking {ChatColors.Yellow}{name}{ChatColors.Default}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sessions.Remove(player.SteamID, out var old))
|
||||
old.Beams.Clear();
|
||||
|
||||
_sessions[player.SteamID] = new Session { Name = name, Color = Config.DefaultColor };
|
||||
Reply(player, $"Marking {ChatColors.Yellow}{name}{ChatColors.Default}. Aim and run {ChatColors.Green}css_zone_mark{ChatColors.Default} 4 times.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_color", "Set the preview color of your zone")]
|
||||
[CommandHelper(minArgs: 3, usage: "<r> <g> <b>", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneColor(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true } || GetSession(player) is not { } session)
|
||||
return;
|
||||
|
||||
if (!byte.TryParse(command.GetArg(1), out var r) || !byte.TryParse(command.GetArg(2), out var g) ||
|
||||
!byte.TryParse(command.GetArg(3), out var b))
|
||||
{
|
||||
Reply(player, "Color values must be 0-255.");
|
||||
return;
|
||||
}
|
||||
|
||||
session.Color = new Rgb(r, g, b);
|
||||
Redraw(session);
|
||||
Reply(player, $"Color set to {r} {g} {b}.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_mark", "Mark a zone corner at your crosshair")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneMark(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true } || GetSession(player) is not { } session)
|
||||
return;
|
||||
if (session.Points.Count >= 4)
|
||||
{
|
||||
Reply(player, $"All 4 points are marked. Use {ChatColors.Green}css_zone_confirm{ChatColors.Default} or {ChatColors.Green}css_zone_cancel{ChatColors.Default}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TraceCrosshair(player) is not { } hit)
|
||||
return;
|
||||
|
||||
session.Points.Add(hit);
|
||||
session.Beams.Marker(hit.ToVector(), ToColor(session.Color), Config.BeamWidth);
|
||||
Reply(player, $"Point {session.Points.Count}/4 at {hit.X:F0} {hit.Y:F0} {hit.Z:F0}.");
|
||||
|
||||
if (session.Points.Count == 4)
|
||||
ShowPreview(player, session);
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_height", "Set your zone's top: units above the lowest point, or your crosshair")]
|
||||
[CommandHelper(minArgs: 0, usage: "[units]", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneHeight(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true } || GetSession(player) is not { } session)
|
||||
return;
|
||||
if (session.Points.Count < 4)
|
||||
{
|
||||
Reply(player, $"Mark all 4 points first ({session.Points.Count}/4).");
|
||||
return;
|
||||
}
|
||||
|
||||
var lowZ = session.Points.Min(p => p.Z);
|
||||
float top;
|
||||
if (command.ArgCount > 1)
|
||||
{
|
||||
if (!float.TryParse(command.GetArg(1), System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var units) || units <= 0)
|
||||
{
|
||||
Reply(player, "Height must be a positive number of units.");
|
||||
return;
|
||||
}
|
||||
top = lowZ + units;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No argument: the top goes where the crosshair is, e.g. the underside of a tunnel roof.
|
||||
if (TraceCrosshair(player) is not { } hit)
|
||||
return;
|
||||
if (hit.Z <= lowZ + 1f)
|
||||
{
|
||||
Reply(player, "Aim above the lowest marked point to set the top.");
|
||||
return;
|
||||
}
|
||||
top = hit.Z;
|
||||
}
|
||||
|
||||
session.TopZ = top;
|
||||
session.Beams.Clear();
|
||||
foreach (var p in session.Points)
|
||||
session.Beams.Marker(p.ToVector(), ToColor(session.Color), Config.BeamWidth);
|
||||
ShowPreview(player, session);
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_confirm", "Save the previewed zone")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneConfirm(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true } || GetSession(player) is not { } session)
|
||||
return;
|
||||
if (session.Box is not { } box)
|
||||
{
|
||||
Reply(player, $"Mark all 4 points first ({session.Points.Count}/4).");
|
||||
return;
|
||||
}
|
||||
if (_zones.Any(z => z.Name.Equals(session.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Reply(player, $"A zone named {ChatColors.Yellow}{session.Name}{ChatColors.Default} was saved meanwhile. Cancel and pick another name.");
|
||||
return;
|
||||
}
|
||||
|
||||
var zone = new Zone
|
||||
{
|
||||
Name = session.Name,
|
||||
Points = [..session.Points],
|
||||
Mins = box.Mins,
|
||||
Maxs = box.Maxs,
|
||||
Yaw = box.Yaw,
|
||||
IsWall = box.IsWall,
|
||||
Color = session.Color,
|
||||
};
|
||||
var updated = new List<Zone>(_zones) { zone };
|
||||
try
|
||||
{
|
||||
ZoneStore.Save(Server.MapName, updated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to save zone {Name}", zone.Name);
|
||||
Reply(player, $"{ChatColors.Red}Saving failed{ChatColors.Default}, see the server console. The preview is kept.");
|
||||
return;
|
||||
}
|
||||
|
||||
_zones = updated;
|
||||
session.Beams.Clear();
|
||||
DrawZones();
|
||||
_sessions.Remove(player.SteamID);
|
||||
Reply(player, $"Zone {ChatColors.Yellow}{zone.Name}{ChatColors.Default} saved and active.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_cancel", "Discard your in-progress zone")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneCancel(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true } || GetSession(player) is not { } session)
|
||||
return;
|
||||
session.Beams.Clear();
|
||||
_sessions.Remove(player.SteamID);
|
||||
Reply(player, $"Discarded {ChatColors.Yellow}{session.Name}{ChatColors.Default}.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_near", "List the zones closest to you")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_ONLY)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneNear(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (player is not { IsValid: true })
|
||||
return;
|
||||
if (player.PlayerPawn.Value is not { IsValid: true } pawn || pawn.AbsOrigin is not { } origin)
|
||||
{
|
||||
Reply(player, "You need to be alive to find nearby zones.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Inactive zones too: they're invisible, which is exactly when you'd need their name.
|
||||
var here = Point3.From(origin);
|
||||
var nearest = _zones
|
||||
.Select(z => (Zone: z, Distance: z.Shape.DistanceTo(here)))
|
||||
.OrderBy(x => x.Distance)
|
||||
.Take(NearCount)
|
||||
.ToList();
|
||||
if (nearest.Count == 0)
|
||||
{
|
||||
Reply(player, $"No zones on {Server.MapName}.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (zone, distance) in nearest)
|
||||
Reply(player, $"{ChatColors.Yellow}{zone.Name}{ChatColors.Default}: {distance:F0} units, " +
|
||||
$"{(zone.Active ? "active" : $"{ChatColors.Grey}inactive{ChatColors.Default}")}, {(zone.IsWall ? "wall" : "floor")}");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_active", "Turn a saved zone on or off")]
|
||||
[CommandHelper(minArgs: 2, usage: "<name> <1|0>", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneActive(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
var name = command.GetArg(1).Trim();
|
||||
var zone = _zones.FirstOrDefault(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (zone == null)
|
||||
{
|
||||
command.ReplyToCommand($"[NoGoZones] No zone named {name} on this map.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool active;
|
||||
switch (command.GetArg(2).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "1" or "on" or "true": active = true; break;
|
||||
case "0" or "off" or "false": active = false; break;
|
||||
default:
|
||||
command.ReplyToCommand("[NoGoZones] Use 1 (active) or 0 (inactive).");
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = zone.Active;
|
||||
zone.Active = active;
|
||||
try
|
||||
{
|
||||
ZoneStore.Save(Server.MapName, _zones);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
zone.Active = previous;
|
||||
Logger.LogError(ex, "Failed to save zones after toggling {Name}", zone.Name);
|
||||
command.ReplyToCommand("[NoGoZones] Saving failed, see the server console. Zone unchanged.");
|
||||
return;
|
||||
}
|
||||
|
||||
DrawZones();
|
||||
command.ReplyToCommand($"[NoGoZones] {zone.Name} is now {(active ? "active" : "inactive")}.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_remove", "Remove a saved zone")]
|
||||
[CommandHelper(minArgs: 1, usage: "<name>", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneRemove(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
var name = command.GetArg(1).Trim();
|
||||
var zone = _zones.FirstOrDefault(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (zone == null)
|
||||
{
|
||||
command.ReplyToCommand($"[NoGoZones] No zone named {name} on this map.");
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = _zones.Where(z => z != zone).ToList();
|
||||
try
|
||||
{
|
||||
ZoneStore.Save(Server.MapName, updated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to save zones after removing {Name}", zone.Name);
|
||||
command.ReplyToCommand("[NoGoZones] Saving failed, see the server console. Zone left active.");
|
||||
return;
|
||||
}
|
||||
|
||||
_zones = updated;
|
||||
DrawZones();
|
||||
command.ReplyToCommand($"[NoGoZones] Removed {zone.Name}.");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_list", "List zones on this map")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneList(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (_zones.Count == 0)
|
||||
{
|
||||
command.ReplyToCommand($"[NoGoZones] No zones on {Server.MapName}.");
|
||||
return;
|
||||
}
|
||||
|
||||
command.ReplyToCommand($"[NoGoZones] {_zones.Count} zone(s) on {Server.MapName}:");
|
||||
foreach (var z in _zones)
|
||||
command.ReplyToCommand($" {z.Name}: {(z.Active ? "active" : "inactive")}, {(z.IsWall ? "wall" : "floor")}, {Size(z.Shape)}");
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_zone_reload", "Reload zones for this map from disk")]
|
||||
[CommandHelper(minArgs: 0, usage: "", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
|
||||
[RequiresPermissions(Permission)]
|
||||
public void OnZoneReload(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
var loaded = LoadZones(Server.MapName);
|
||||
DrawZones();
|
||||
command.ReplyToCommand(loaded
|
||||
? $"[NoGoZones] Loaded {_zones.Count} zone(s) for {Server.MapName}."
|
||||
: "[NoGoZones] Reload failed (bad JSON?), see the server console. Previous zones kept.");
|
||||
}
|
||||
|
||||
private Session? GetSession(CCSPlayerController player)
|
||||
{
|
||||
if (_sessions.TryGetValue(player.SteamID, out var session))
|
||||
return session;
|
||||
Reply(player, $"No zone in progress. Start one with {ChatColors.Green}css_zone_start <name>{ChatColors.Default}.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ShowPreview(CCSPlayerController player, Session session)
|
||||
{
|
||||
var shape = ZoneShape.Compute(session.Points, Config, session.TopZ);
|
||||
session.Box = shape;
|
||||
session.Beams.Box(shape, ToColor(session.Color), Config.BeamWidth);
|
||||
Reply(player, $"Preview ({(shape.IsWall ? "wall" : "floor")}): {Size(shape)}. " +
|
||||
$"{ChatColors.Green}css_zone_height{ChatColors.Default} to change the top, " +
|
||||
$"{ChatColors.Green}css_zone_confirm{ChatColors.Default} to save, {ChatColors.Green}css_zone_cancel{ChatColors.Default} to discard.");
|
||||
}
|
||||
|
||||
// Point under the player's crosshair, world geometry only (aiming past a player doesn't hit them).
|
||||
private static Point3? TraceCrosshair(CCSPlayerController player)
|
||||
{
|
||||
var pawn = player.PlayerPawn.Value;
|
||||
if (pawn is not { IsValid: true } || pawn.AbsOrigin is not { } origin)
|
||||
{
|
||||
Reply(player, "You need to be alive to aim.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var eye = new Vector(origin.X, origin.Y, origin.Z + pawn.ViewOffset.Z);
|
||||
var end = eye + Forward(pawn.EyeAngles) * TraceDistance;
|
||||
var result = Trace.TraceEndShape(eye, end, pawn, new TraceOptions { InteractsWith = Masks.ShotBrushOnly });
|
||||
if (!result.DidHit())
|
||||
{
|
||||
Reply(player, "Nothing under your crosshair within range.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// HitPoint is only filled in when the engine reports an exact hit; EndPos (where the ray
|
||||
// stopped) is always set and is the same spot for a line trace.
|
||||
return Point3.From(result.HasExactHitPoint ? result.HitPoint : result.EndPos);
|
||||
}
|
||||
|
||||
private void Redraw(Session session)
|
||||
{
|
||||
session.Beams.Clear();
|
||||
var color = ToColor(session.Color);
|
||||
foreach (var p in session.Points)
|
||||
session.Beams.Marker(p.ToVector(), color, Config.BeamWidth);
|
||||
if (session.Box is { } box)
|
||||
session.Beams.Box(box, color, Config.BeamWidth);
|
||||
}
|
||||
|
||||
private static string Size(ZoneShape s) =>
|
||||
$"{s.Maxs.X - s.Mins.X:F0} x {s.Maxs.Y - s.Mins.Y:F0} x {s.Maxs.Z - s.Mins.Z:F0}";
|
||||
|
||||
private static Color ToColor(Rgb c) => Color.FromArgb(255, c.R, c.G, c.B);
|
||||
|
||||
private static Vector Forward(QAngle angles)
|
||||
{
|
||||
var pitch = angles.X * MathF.PI / 180f;
|
||||
var yaw = angles.Y * MathF.PI / 180f;
|
||||
return new Vector(MathF.Cos(pitch) * MathF.Cos(yaw), MathF.Cos(pitch) * MathF.Sin(yaw), -MathF.Sin(pitch));
|
||||
}
|
||||
|
||||
private static void Reply(CCSPlayerController player, string message) =>
|
||||
player.PrintToChat($" {ChatColors.Red}[NoGoZones]{ChatColors.Default} {message}");
|
||||
}
|
||||
17
NoGoZones.csproj
Normal file
17
NoGoZones.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>NoGoZones</AssemblyName>
|
||||
<RootNamespace>NoGoZones</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
|
||||
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.375">
|
||||
<PrivateAssets>none</PrivateAssets>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
217
README.md
Normal file
217
README.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# NoGoZones
|
||||
|
||||
Admins mark box-shaped zones in-game with their crosshair. Players can't enter a confirmed zone.
|
||||
Confirmed zones stay marked on the ground: a rectangle over the zone's footprint with an X through
|
||||
it, in the zone's color. Set `ShowZones` to `false` to hide them. Blocking works either way.
|
||||
|
||||
Built for CounterStrikeSharp **1.0.375** (`net10.0`) on Metamod:Source 2.0.0.1469. No other
|
||||
dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
Build with `cd plugins/NoGoZones && ../../build.sh`. Then copy the contents of `compiled/NoGoZones/`
|
||||
to `game/csgo/addons/counterstrikesharp/plugins/NoGoZones/` on the server.
|
||||
|
||||
Or install a release, which unpacks into `game/csgo/`:
|
||||
|
||||
```
|
||||
tar -xzf NoGoZones-<tag>.tar.gz -C /srv/cs2/game/csgo
|
||||
```
|
||||
|
||||
To publish one, commit, bump `ModuleVersion`, then run `FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>`.
|
||||
It rebuilds the plugin from the committed source in the SDK container, tags HEAD, and uploads
|
||||
`NoGoZones-<tag>.tar.gz` to the git.zio.sh release. The tarball contains no configs, so an upgrade
|
||||
never touches the server's settings or zones.
|
||||
|
||||
Files the plugin writes, under `addons/counterstrikesharp/configs/plugins/NoGoZones/`:
|
||||
|
||||
- `NoGoZones.json`: settings. It's created with defaults on first load.
|
||||
- `zones/<map>.json`: that map's zones, **one file per map**. It's easier to edit by hand than one
|
||||
combined file, and a broken edit only affects one map. Characters that aren't valid in a file
|
||||
name, including `/` from workshop map names, are replaced with `_`.
|
||||
|
||||
Zones for the current map are loaded on map start and enforced right away.
|
||||
|
||||
## Permission
|
||||
|
||||
Every command needs **`@css/root`**. In this server's rank groups, that means `#rank/owner`.
|
||||
`css_zone_remove`, `css_zone_list` and `css_zone_reload` also work from the server console or RCON.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
css_zone_start <name> begin marking a zone (one session per admin, by SteamID)
|
||||
css_zone_color <r> <g> <b> optional, preview color (default red, 255 0 0)
|
||||
css_zone_mark x4: marks the point under your crosshair and draws a marker there
|
||||
after the 4th: draws the box outline as a preview (nothing saved yet)
|
||||
css_zone_height [units] optional, after the 4th mark: set the box top and redraw the preview.
|
||||
With a number: that many units above the lowest point.
|
||||
With no argument: at your crosshair (aim at a tunnel roof's underside).
|
||||
css_zone_confirm save the box to this map's file and start blocking it
|
||||
css_zone_cancel discard the session and its preview instead
|
||||
```
|
||||
|
||||
Managing saved zones:
|
||||
|
||||
```
|
||||
css_zone_list zones on the current map
|
||||
css_zone_near the 3 zones nearest to you, with distance and on/off state (finds a name to edit)
|
||||
css_zone_remove <name> delete a zone and stop blocking it
|
||||
css_zone_active <name> <1|0> turn a zone on or off without deleting it (also on/off, true/false)
|
||||
css_zone_reload re-read this map's file (after editing it by hand)
|
||||
```
|
||||
|
||||
If `css_zone_reload` hits invalid JSON, it logs the error and keeps the zones that were already
|
||||
active. Several admins can mark different zones at once. A session ends on confirm, on cancel, when
|
||||
its admin disconnects, or on map change.
|
||||
|
||||
## How the box is built
|
||||
|
||||
The plugin fits a plane through the 4 points. That decides the kind of zone:
|
||||
|
||||
**Floor zone.** The plane is closer to horizontal: you marked the zone's footprint on the ground.
|
||||
- **X/Y:** the min and max of the points, axis-aligned. Mark the points in any order.
|
||||
- **Z (height):**
|
||||
- The bottom is `BottomMargin` (8) below the lowest point.
|
||||
- The top is whichever is higher: `MinHeight` (128) above the lowest point, or `TopMargin` (16)
|
||||
above the highest point. That's taller than a crouch-jump.
|
||||
- **Outline:** a rectangle with an X, flat, just above the lowest point.
|
||||
|
||||
**Wall zone.** The plane is closer to vertical: you marked a face, e.g. two points at the foot of a
|
||||
tunnel mouth and two higher up its sides.
|
||||
- **Shape:** a slab `WallThickness` (16) deep, turned to lie in that face. It doesn't need to line
|
||||
up with the map's axes.
|
||||
- **Width:** the spread of the points along the face.
|
||||
- **Height:** from `BottomMargin` below the lowest point up to the highest point.
|
||||
- **Outline:** a rectangle standing on the face itself, with no X.
|
||||
- **Fill:** the face is filled with dimmer horizontal lines, about every `FillSpacing` units, so it
|
||||
reads as a wall.
|
||||
- The lines scroll up at `FillScrollSpeed` units/s and wrap around. A negative value scrolls
|
||||
down, and 0 keeps them still.
|
||||
- With `FillPulse`, a brightness wave runs up them once every `PulsePeriod` seconds.
|
||||
- **Sign:** a no-entry sign (a red disc with a white bar) sits in the middle, up to `SignSize`
|
||||
units across. The fill lines open a gap around it as they pass.
|
||||
|
||||
**Changing the top.** `css_zone_height` replaces the top for either kind. Use it to stop a floor
|
||||
zone under a tunnel roof, or to raise a wall zone above where you could aim.
|
||||
|
||||
`NoGoZones.json` settings: `MinHeight`, `TopMargin`, `BottomMargin`, `WallThickness`, `DefaultColor` (`{"R":..,"G":..,"B":..}`),
|
||||
`BeamWidth`, `FillSpacing` (12; 0 turns the fill off), `FillWidth` (4), `FillBrightness` (0.8), `FillScrollSpeed` (12), `FillPulse` (true), `PulsePeriod` (1.5),
|
||||
`PulseMinBrightness` (0.6), `ShowSign`, `SignSize` (48),
|
||||
`ShowZones` (default `true`: draw the rectangle with an X over each saved zone),
|
||||
`IgnoreNoclip` (default `true`: noclipping players pass through).
|
||||
|
||||
Each zone in the file stores:
|
||||
- `Points`: the 4 raw marks.
|
||||
- The computed box: `Mins`/`Maxs`, `Yaw` and `IsWall`.
|
||||
- `Color`.
|
||||
- `Active`: `false` means the zone is kept but doesn't block or draw. It's set by
|
||||
`css_zone_active`, and older files without it count as active.
|
||||
|
||||
`Mins`/`Maxs` are in the zone's own frame, rotated by `Yaw` degrees about Z. For floor zones `Yaw`
|
||||
is 0, so they're plain world coordinates. The blocker uses only the box, so a hand edit to it takes
|
||||
effect after `css_zone_reload`. Editing `Points` doesn't recompute the box.
|
||||
|
||||
## Design decisions and API limitations
|
||||
|
||||
### Crosshair trace
|
||||
|
||||
This uses `Trace.TraceEndShape` from `CounterStrikeSharp.API.Modules.Utils` (native in 1.0.375),
|
||||
with no Ray-Trace library.
|
||||
|
||||
- The ray runs from the eye (`AbsOrigin + ViewOffset.Z`) 8192 units along the view direction.
|
||||
- It uses the mask `Masks.ShotBrushOnly` and ignores the admin's own pawn. So it hits world
|
||||
geometry, not players.
|
||||
- The marked point is `HitPoint` if `HasExactHitPoint` is set, otherwise `EndPos`. CSS's
|
||||
`TraceResult` only guarantees the former when the engine reports an exact hit.
|
||||
|
||||
### Preview drawing: `env_beam` entities
|
||||
|
||||
The 1.0.375 source has **no debug-overlay or draw-line natives** at all. The only way to draw a line
|
||||
in the world is a real `env_beam` entity (`CBeam`): set `Render` (color), `Width`, the start via
|
||||
`Teleport` and `EndPos`, then `DispatchSpawn`. jRandomSkills already draws its trace beams this way
|
||||
on this server.
|
||||
|
||||
- Each point marker is 3 beams: a small cross, plus a vertical line so it's visible from a
|
||||
distance.
|
||||
- The box preview is 12 beams, one per edge.
|
||||
- They're removed on confirm or cancel.
|
||||
- A confirmed floor zone is drawn as 6 beams: a rectangle plus its two diagonals, flat over
|
||||
the footprint just above the floor.
|
||||
- A wall zone stands on the marked face as a border, plus the fill lines and the sign. That's
|
||||
about 30–60 beams, depending on its height.
|
||||
- Each fill line is two beams, either side of the sign, so a line can open a gap as it scrolls
|
||||
past the sign without spawning or removing beams.
|
||||
- A 0.1 s timer moves the lines (`Teleport` for the start, `m_vecEndPos` for the end) and
|
||||
recolours them (`m_clrRender`). Every change is flagged with `SetStateChanged`, so clients
|
||||
receive it.
|
||||
- That's about 10 network updates a second per fill beam: a few thousand entity updates a
|
||||
second across a map full of walls. Set `FillScrollSpeed` to 0 and `FillPulse` to false for
|
||||
static walls with no updates at all.
|
||||
- The sign is beams too: a stack of overlapping horizontal rows forming the disc, with the middle
|
||||
rows split red/white/red for the bar. A real image would need a custom texture on every client,
|
||||
and clients only get that through a Workshop addon.
|
||||
- The round restart removes spawned entities, so the outlines are redrawn at every round start.
|
||||
They're also redrawn after confirm, remove and reload.
|
||||
|
||||
**Limitation:** beams are ordinary networked entities, so **every player sees the preview and the
|
||||
zone outlines**, not only admins. Hiding them from other players would need a `CheckTransmit` filter. That
|
||||
isn't implemented.
|
||||
|
||||
### Blocking: per-tick movement block
|
||||
|
||||
**Why not a single custom-size solid:**
|
||||
|
||||
- `CCollisionProperty` exposes `Mins`, `Maxs`, `SolidType` and `SolidFlags` as writable schema
|
||||
fields, but CSS has **no native to rebuild an entity's physics shape** afterwards.
|
||||
- CS2 player movement collides against the VPhysics shape built from the model at spawn, so
|
||||
writing the bounds doesn't make a wall.
|
||||
- Brush entities get their shape from brush models compiled into the map, and those can't be
|
||||
created at runtime.
|
||||
|
||||
**Tried and removed (0.7.0–0.10.0): scaled `dev_cube` walls.**
|
||||
|
||||
These are the findings from live tests:
|
||||
|
||||
- A per-axis scale isn't possible. The Hammer-style `scales` keyvalue is ignored, and the scene
|
||||
node only has a single `m_flScale`.
|
||||
- A uniform scale does scale the collision, and bullets pass through.
|
||||
|
||||
So a zone could be filled with overlapping uniformly scaled cubes. The problem is that a cube's
|
||||
side is capped by the zone's thinnest dimension. That made thin walls either need hundreds of
|
||||
cubes, or be thickened until they stuck out past the marked face. It was dropped in favour of
|
||||
beams plus the per-tick block.
|
||||
|
||||
The per-tick block works like this. On every tick (`Listeners.OnTick`), for each living player:
|
||||
|
||||
1. Work in the zone's own frame, so turned wall zones use the same math. Grow the zone box by
|
||||
the player's own collision hull (`Collision.Mins`/`Maxs`, so crouching counts). For a turned
|
||||
zone, it grows by the hull's width along each of the zone's axes. Then check the player's origin against the grown box, which keeps the whole body out,
|
||||
not just the feet.
|
||||
2. Sweep from last tick's origin to this tick's. If that step entered the box, move the player
|
||||
back just outside the face they crossed, and zero their velocity into that face. Movement
|
||||
along the face is kept, so players slide along the walls. Because the whole step is checked, a
|
||||
fast player (a speed skill, for example) can't skip through a thin zone between two ticks.
|
||||
3. If a player is already inside (a zone confirmed on top of them, or a spawn inside one), push
|
||||
them out through the nearest face.
|
||||
|
||||
Tradeoffs compared with real collision:
|
||||
|
||||
- **It's corrected after the fact, not prevented.** The engine moves the player first, and the
|
||||
plugin puts them back in the same tick. Normally this looks like a wall, but under latency the
|
||||
client can predict a few units into the zone and then snap back. Expect a slight rubber-band
|
||||
feel at the edge, especially at high speed or with high ping.
|
||||
- **Only players are blocked.** Bullets, grenades, the C4, chickens and physics props go straight
|
||||
through. Nothing is there for them to hit.
|
||||
- **The top of a zone isn't a real floor.** A player on top is held up each tick but isn't
|
||||
"on ground" to the engine. They can stand there, but can't jump off it normally, and may show
|
||||
the falling animation. The 128-unit default height makes the top unreachable by jumping.
|
||||
- **Cost:** it runs every tick, with a cost of players × zones box checks. That's negligible for
|
||||
realistic zone counts.
|
||||
|
||||
## Untested
|
||||
|
||||
This compiles against CSS 1.0.375. It has **not been run on the live server** yet. In-game, check:
|
||||
- beam visibility and color
|
||||
- how solid the wall feels, including at the edges and with speed skills
|
||||
- whether players standing on top of a zone behave acceptably
|
||||
189
Zone.cs
Normal file
189
Zone.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
|
||||
|
||||
namespace NoGoZones;
|
||||
|
||||
// Plain float triple for JSON; CSS's Vector wraps native memory and doesn't serialize.
|
||||
public record struct Point3(float X, float Y, float Z)
|
||||
{
|
||||
public static Point3 From(Vector v) => new(v.X, v.Y, v.Z);
|
||||
public Vector ToVector() => new(X, Y, Z);
|
||||
}
|
||||
|
||||
public record struct Rgb(byte R, byte G, byte B);
|
||||
|
||||
public class Zone
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public List<Point3> Points { get; set; } = [];
|
||||
// Box in the zone's own frame (world rotated by -Yaw about Z). Yaw is 0 for floor zones and for
|
||||
// every zone saved before wall zones existed, so there Mins/Maxs are plain world coordinates.
|
||||
public Point3 Mins { get; set; }
|
||||
public Point3 Maxs { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public bool IsWall { get; set; }
|
||||
public Rgb Color { get; set; }
|
||||
// Inactive zones stay saved but neither block nor draw. Missing in older files, so defaults on.
|
||||
public bool Active { get; set; } = true;
|
||||
|
||||
[JsonIgnore]
|
||||
public ZoneShape Shape => new(Mins, Maxs, Yaw, IsWall);
|
||||
|
||||
// Lowest marked point, where the outline is drawn. Falls back to the box bottom if Points was
|
||||
// emptied by hand.
|
||||
[JsonIgnore]
|
||||
public float LowZ => Points.Count > 0 ? Points.Min(p => p.Z) : Mins.Z;
|
||||
}
|
||||
|
||||
// A box that can be turned about Z: Mins/Maxs live in a local frame rotated by Yaw degrees.
|
||||
public readonly record struct ZoneShape(Point3 Mins, Point3 Maxs, float Yaw, bool IsWall)
|
||||
{
|
||||
private readonly float _cos = MathF.Cos(Yaw * MathF.PI / 180f);
|
||||
private readonly float _sin = MathF.Sin(Yaw * MathF.PI / 180f);
|
||||
|
||||
public float Cos => _cos;
|
||||
public float Sin => _sin;
|
||||
|
||||
public Point3 ToLocal(Point3 w) => new(w.X * _cos + w.Y * _sin, -w.X * _sin + w.Y * _cos, w.Z);
|
||||
// Distance from a world point to the nearest part of the box; 0 if inside.
|
||||
public float DistanceTo(Point3 world)
|
||||
{
|
||||
var p = ToLocal(world);
|
||||
float dx = MathF.Max(0, MathF.Max(Mins.X - p.X, p.X - Maxs.X));
|
||||
float dy = MathF.Max(0, MathF.Max(Mins.Y - p.Y, p.Y - Maxs.Y));
|
||||
float dz = MathF.Max(0, MathF.Max(Mins.Z - p.Z, p.Z - Maxs.Z));
|
||||
return MathF.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
public Point3 ToWorld(Point3 l) => new(l.X * _cos - l.Y * _sin, l.X * _sin + l.Y * _cos, l.Z);
|
||||
|
||||
// Two kinds of zone, told apart by the plane through the 4 points:
|
||||
// - Floor (plane closer to horizontal): an axis-aligned box over the points' footprint. Z runs
|
||||
// from BottomMargin below the lowest point up to whichever is higher: MinHeight above the
|
||||
// lowest point, or TopMargin above the highest.
|
||||
// - Wall (plane closer to vertical, e.g. marked on a tunnel mouth): a WallThickness-deep slab
|
||||
// turned to lie in that plane, spanning the points along the face and from BottomMargin below
|
||||
// the lowest point up to the highest.
|
||||
// An explicit topZ (css_zone_height) replaces the top in both cases.
|
||||
public static ZoneShape Compute(IReadOnlyList<Point3> points, NoGoZonesConfig config, float? topZ = null)
|
||||
{
|
||||
float lowZ = points.Min(p => p.Z), highZ = points.Max(p => p.Z);
|
||||
|
||||
if (WallNormal(points) is { } n)
|
||||
{
|
||||
// Local X runs along the face, local Y straight through it.
|
||||
var yaw = MathF.Atan2(n.X, -n.Y) * 180f / MathF.PI;
|
||||
var frame = new ZoneShape(default, default, yaw, true);
|
||||
var local = points.Select(frame.ToLocal).ToList();
|
||||
float midY = local.Average(p => p.Y), half = config.WallThickness / 2f;
|
||||
return frame with
|
||||
{
|
||||
Mins = new Point3(local.Min(p => p.X), midY - half, lowZ - config.BottomMargin),
|
||||
Maxs = new Point3(local.Max(p => p.X), midY + half, topZ ?? highZ),
|
||||
};
|
||||
}
|
||||
|
||||
return new ZoneShape(
|
||||
new Point3(points.Min(p => p.X), points.Min(p => p.Y), lowZ - config.BottomMargin),
|
||||
new Point3(points.Max(p => p.X), points.Max(p => p.Y),
|
||||
topZ ?? Math.Max(lowZ + config.MinHeight, highZ + config.TopMargin)),
|
||||
0f, false);
|
||||
}
|
||||
|
||||
// Horizontal unit normal of the points' plane if it's a wall, otherwise null. Uses the largest
|
||||
// cross product among the three pairs from the first point, so marking order doesn't matter.
|
||||
private static Point3? WallNormal(IReadOnlyList<Point3> points)
|
||||
{
|
||||
var p0 = points[0];
|
||||
Point3 Sub(Point3 a) => new(a.X - p0.X, a.Y - p0.Y, a.Z - p0.Z);
|
||||
static Point3 Cross(Point3 a, Point3 b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X);
|
||||
static float Len(Point3 v) => MathF.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
|
||||
|
||||
var best = default(Point3);
|
||||
for (var i = 1; i < points.Count; i++)
|
||||
for (var j = i + 1; j < points.Count; j++)
|
||||
{
|
||||
var c = Cross(Sub(points[i]), Sub(points[j]));
|
||||
if (Len(c) > Len(best))
|
||||
best = c;
|
||||
}
|
||||
|
||||
var len = Len(best);
|
||||
if (len < 1f || MathF.Abs(best.Z / len) >= 0.5f)
|
||||
return null; // all in a line, or nearer horizontal than vertical: a floor zone
|
||||
var h = MathF.Sqrt(best.X * best.X + best.Y * best.Y);
|
||||
return new Point3(best.X / h, best.Y / h, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
// One JSON file per map: configs/plugins/NoGoZones/zones/<map>.json, holding a list of zones.
|
||||
public static class ZoneStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
IncludeFields = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
|
||||
public static string ZonesDirectory => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp",
|
||||
"configs", "plugins", "NoGoZones", "zones");
|
||||
|
||||
public static string PathFor(string map)
|
||||
{
|
||||
// Workshop maps can carry path separators in their name.
|
||||
var safe = string.Concat(map.Select(c => Path.GetInvalidFileNameChars().Contains(c) || c == '/' ? '_' : c));
|
||||
return Path.Combine(ZonesDirectory, $"{safe}.json");
|
||||
}
|
||||
|
||||
public static List<Zone> Load(string map)
|
||||
{
|
||||
var path = PathFor(map);
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
return JsonSerializer.Deserialize<List<Zone>>(File.ReadAllText(path), JsonOptions) ?? [];
|
||||
}
|
||||
|
||||
public static void Save(string map, List<Zone> zones)
|
||||
{
|
||||
Directory.CreateDirectory(ZonesDirectory);
|
||||
var path = PathFor(map);
|
||||
// Write-then-rename so a crash mid-write never leaves a truncated zone file behind.
|
||||
File.WriteAllText(path + ".tmp", JsonSerializer.Serialize(zones, JsonOptions));
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
public class NoGoZonesConfig : BasePluginConfig
|
||||
{
|
||||
[JsonPropertyName("MinHeight")] public float MinHeight { get; set; } = 128f;
|
||||
[JsonPropertyName("TopMargin")] public float TopMargin { get; set; } = 16f;
|
||||
// Depth of a wall zone as marked (outline, per-tick block), measured through the face.
|
||||
[JsonPropertyName("WallThickness")] public float WallThickness { get; set; } = 16f;
|
||||
[JsonPropertyName("BottomMargin")] public float BottomMargin { get; set; } = 8f;
|
||||
[JsonPropertyName("DefaultColor")] public Rgb DefaultColor { get; set; } = new(255, 0, 0);
|
||||
[JsonPropertyName("BeamWidth")] public float BeamWidth { get; set; } = 1.5f;
|
||||
// Wall zones: horizontal fill lines every FillSpacing units (0 = off), FillWidth wide.
|
||||
[JsonPropertyName("FillSpacing")] public float FillSpacing { get; set; } = 12f;
|
||||
[JsonPropertyName("FillWidth")] public float FillWidth { get; set; } = 4f;
|
||||
// Fill lines' brightness relative to the zone colour (0-1). Beams draw additively, so dim lines
|
||||
// all but vanish against bright walls, especially from a distance.
|
||||
[JsonPropertyName("FillBrightness")] public float FillBrightness { get; set; } = 0.8f;
|
||||
// Fill lines scroll up at this many units/s (negative = down, 0 = still), and/or carry a
|
||||
// brightness wave upwards, one cycle every PulsePeriod seconds.
|
||||
[JsonPropertyName("FillScrollSpeed")] public float FillScrollSpeed { get; set; } = 12f;
|
||||
[JsonPropertyName("FillPulse")] public bool FillPulse { get; set; } = true;
|
||||
[JsonPropertyName("PulsePeriod")] public float PulsePeriod { get; set; } = 1.5f;
|
||||
// Dimmest point of the pulse, as a fraction of FillBrightness (0-1).
|
||||
[JsonPropertyName("PulseMinBrightness")] public float PulseMinBrightness { get; set; } = 0.6f;
|
||||
// Wall zones: a no-entry sign in the middle, up to SignSize units across.
|
||||
[JsonPropertyName("ShowSign")] public bool ShowSign { get; set; } = true;
|
||||
[JsonPropertyName("SignSize")] public float SignSize { get; set; } = 48f;
|
||||
// Draw confirmed zones (outline; wall zones also get the fill lines and sign).
|
||||
[JsonPropertyName("ShowZones")] public bool ShowZones { get; set; } = true;
|
||||
// Let noclipping players (admins flying around) pass through zones.
|
||||
[JsonPropertyName("IgnoreNoclip")] public bool IgnoreNoclip { get; set; } = true;
|
||||
}
|
||||
115
release.sh
Executable file
115
release.sh
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build NoGoZones, package it as one tarball and publish it as a Forgejo release.
|
||||
#
|
||||
# ./release.sh <tag> e.g. ./release.sh v0.14.0
|
||||
#
|
||||
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
|
||||
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
|
||||
# the same tag replaces that release's attachment.
|
||||
#
|
||||
# Asset: NoGoZones-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
|
||||
# tar -xzf NoGoZones-<tag>.tar.gz -C /srv/cs2/game/csgo
|
||||
#
|
||||
# addons/counterstrikesharp/plugins/NoGoZones/ NoGoZones.dll, .pdb, .deps.json
|
||||
#
|
||||
# No configs/: the plugin writes NoGoZones.json itself when it is missing, and zones/<map>.json is
|
||||
# written by admins in-game, so an upgrade never touches the server's settings or zones.
|
||||
set -euo pipefail
|
||||
|
||||
FORGEJO_URL="https://git.zio.sh"
|
||||
OWNER="cs2"
|
||||
REPO="NoGoZones"
|
||||
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
|
||||
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
|
||||
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
|
||||
|
||||
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
|
||||
# (ExcludeAssets=runtime); the server already provides it.
|
||||
PLUGIN_FILES=(
|
||||
NoGoZones.dll
|
||||
NoGoZones.pdb
|
||||
NoGoZones.deps.json
|
||||
)
|
||||
|
||||
TAG="${1:-}"
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "usage: $0 <tag> (e.g. v0.14.0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
|
||||
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
|
||||
exit 1
|
||||
fi
|
||||
for tool in podman jq curl git; do
|
||||
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
|
||||
done
|
||||
|
||||
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
|
||||
|
||||
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
|
||||
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The tag should name the version the plugin reports in `css_plugins list`.
|
||||
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' NoGoZones.cs)"
|
||||
if [[ "${TAG#v}" != "$version" ]]; then
|
||||
echo "Tag $TAG doesn't match ModuleVersion $version in NoGoZones.cs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Build -----------------------------------------------------------------------
|
||||
OUT="release-out"
|
||||
PUBLISH="$OUT/publish"
|
||||
rm -rf bin obj "$OUT"
|
||||
podman run --rm -v "$(pwd)":/src:Z -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
|
||||
|
||||
# --- Stage (layout of game/csgo/) and package ----------------------------------------
|
||||
STAGE="$OUT/stage"
|
||||
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/NoGoZones"
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
for file in "${PLUGIN_FILES[@]}"; do
|
||||
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
|
||||
done
|
||||
|
||||
TARBALL="NoGoZones-${TAG}.tar.gz"
|
||||
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
|
||||
rm -rf "$STAGE" "$PUBLISH"
|
||||
|
||||
echo "Packaged:"
|
||||
tar -tzf "$OUT/$TARBALL"
|
||||
|
||||
# --- Tag ---------------------------------------------------------------------------
|
||||
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
git tag -a "$TAG" -m "$TAG"
|
||||
fi
|
||||
git push origin "refs/tags/$TAG"
|
||||
|
||||
# --- Release -----------------------------------------------------------------------
|
||||
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
|
||||
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
|
||||
|
||||
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
|
||||
if [[ -z "$release_json" ]]; then
|
||||
body="$(git log -1 --format=%B "$TAG")"
|
||||
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
|
||||
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
|
||||
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
|
||||
echo "Created release $TAG"
|
||||
else
|
||||
echo "Release $TAG already exists, replacing its attachment"
|
||||
fi
|
||||
release_id="$(jq -r '.id' <<<"$release_json")"
|
||||
|
||||
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
|
||||
if [[ -n "$existing_id" ]]; then
|
||||
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
|
||||
fi
|
||||
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
|
||||
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
|
||||
echo "Uploaded $TARBALL"
|
||||
|
||||
echo
|
||||
echo "Download URL:"
|
||||
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"
|
||||
Loading…
Add table
Add a link
Reference in a new issue