NoGoZones/Beams.cs
Astra 006949d01c 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.
2026-09-25 14:18:26 +01:00

239 lines
10 KiB
C#

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