← Home SEEBIE90 DOCS
The GTG Safe Zone mod is published under the Arma Public License (APL)Example code is free to useSuggestions: Discord to the author

GTG Safe Zone

Points to polygon

The zone has no model and no collider. Its shape is a list of positions that somebody placed, and everything else – protection, alarm, ring – is a question asked of that list.

In plain wordsThe shape of the zone is a list of corner points. A ray test tells you whether a position is inside. The order of the points matters, and the server decides it.

Corner points are plain entities

A corner point is an entity that does nothing but exist. It adds itself to a static list when it initialises and removes itself when it is deleted. The zone later asks that list for the corners that belong to it. Because the points are ordinary world entities, they can be placed in the World Editor, or by a Game Master while the session runs, and moved afterwards.

Example code – free to useMyAD_ZonePoint.c
[EntityEditorProps(category: "GameScripted/MyAD", description: "Corner point of a zone")]
class MyAD_ZonePointClass : GenericEntityClass
{
}

class MyAD_ZonePoint : GenericEntity
{
	[Attribute("Zone1", desc: "Name of the zone this corner belongs to")]
	protected string m_sZoneId;

	protected static ref array<MyAD_ZonePoint> s_aPoints = {};

	static array<MyAD_ZonePoint> GetAll() { return s_aPoints; }
	string GetZoneId() { return m_sZoneId; }

	void MyAD_ZonePoint(IEntitySource src, IEntity parent)
	{
		SetEventMask(EntityEvent.INIT);
	}

	override protected void EOnInit(IEntity owner)
	{
		if (!s_aPoints.Contains(this))
			s_aPoints.Insert(this);
	}

	void ~MyAD_ZonePoint()
	{
		if (s_aPoints)
			s_aPoints.RemoveItem(this);
	}
}
PitfallDo not nest the points under the master prefab and do not group them in the editor. A group is only an organisational aid: at runtime it creates no parent and child, and the stored coordinates are relative to the group. The zone then reads coordinates near the world origin. The points belong in the layer as independent objects.

Which points belong to which zone

Master and points share a zone name. All points with the master's name form its outline. Compare the names without regard to case, and mind what ToLower() does: it changes the string you call it on, in place. Copy first.

Example code – free to use
// Collect the corners of one zone as world positions.
static array<vector> CollectOutline(string zoneId)
{
	string wanted = zoneId;
	wanted.ToLower();				// ToLower() changes the string itself - so only ever on a copy

	array<vector> outline = {};
	foreach (MyAD_ZonePoint point : MyAD_ZonePoint.GetAll())
	{
		string id = point.GetZoneId();	// also a copy
		id.ToLower();
		if (id != wanted)
			continue;

		outline.Insert(MyAD_Zone.WorldPos(point));
	}

	return outline;
}

The position comes from the world transform, not from GetOrigin(). For an entity without a parent they are the same. For anything that sits inside a vehicle, GetOrigin() is relative to that vehicle – a mistake you only notice when a player in a turret is treated as standing at the world origin.

Example code – free to use
static vector WorldPos(notnull IEntity entity)
{
	vector mat[4];
	entity.GetWorldTransform(mat);
	return mat[3];		// the translation row
}

Build one step later

The points register themselves in their own EOnInit, and the engine does not promise an order between entities. A master that builds its outline in its own EOnInit sees none of its points, or some of them, differently from one start to the next. Hand the work to the call queue and it runs when everything exists.

Example code – free to use
override protected void EOnInit(IEntity owner)
{
	if (!s_aZones.Contains(this))
		s_aZones.Insert(this);

	GetGame().GetCallqueue().CallLater(DeferredStart, 0, false);	// one step later: the points exist by then
}

protected void DeferredStart()
{
	if (!Replication.IsServer())
		return;						// only the server builds the outline - see "Alarm and replication"

	m_aOutline = MyAD_ZonePoint.CollectOutline(m_sZoneId);
	MyAD_Geometry.OrderByAngleXZ(m_aOutline);
}

Inside or outside: a ray

The usual shortcut – a position is inside when it lies on the same side of every edge – is correct only for convex shapes. A notch around a hillside, a wall to follow, a corridor left open on purpose: all of those are concave, and the shortcut gets them wrong. Ray casting does not care. Send a ray from the position in one direction and count how many edges it crosses. An odd number means inside.

Example code – free to useMyAD_Geometry.c
class MyAD_Geometry
{
	// Top view: X and Z. Y is height and is a separate question.
	static bool IsPointInPolygonXZ(vector p, notnull array<vector> poly)
	{
		int count = poly.Count();
		if (count < 3)
			return false;

		bool inside = false;
		int j = count - 1;				// the previous corner; starting with (last, first) closes the polygon

		for (int i = 0; i < count; i++)
		{
			float zi = poly[i][2];
			float zj = poly[j][2];

			// Does the edge j->i cross the horizontal line z = p.z?
			// For a horizontal edge both sides of the comparison are equal, so it is skipped -
			// and that is exactly what makes the division below safe.
			if ((zi > p[2]) != (zj > p[2]))
			{
				float t = (p[2] - zi) / (zj - zi);
				float xCross = poly[i][0] + t * (poly[j][0] - poly[i][0]);

				if (p[0] < xCross)		// the ray runs towards -X
					inside = !inside;
			}

			j = i;
		}

		return inside;
	}
}

Height is its own condition

The polygon says nothing about height, and it should not. Floor and ceiling are two offsets from the average height of the placed points. Without a ceiling, an aircraft high above the zone would be protected as well, and could fire from it.

Example code – free to use
protected bool IsHeightInRange(vector pos)
{
	return pos[1] >= m_fBaseHeight + m_fFloorOffset		// e.g. -50
		&& pos[1] <= m_fBaseHeight + m_fCeilingOffset;	// e.g. +150
}

bool IsInside(vector pos)
{
	if (!m_bValid || !m_bEnabled)
		return false;

	return IsHeightInRange(pos) && MyAD_Geometry.IsPointInPolygonXZ(pos, m_aOutline);
}

A bounding sphere for the questions the world answers

The world can be asked for entities within a sphere. The zone needs one that holds the whole outline: the centre of the bounding box and the distance to the farthest corner. It does not have to be the smallest sphere – it is only a pre-selection, and every hit is afterwards tested exactly against the polygon. The same function gives the reference height for floor and ceiling: the average height of the corners.

Example code – free to useMyAD_Geometry.c
static void ComputeBounds(notnull array<vector> poly, out vector centre, out float radius, out float baseHeight)
{
	float minX = poly[0][0];
	float maxX = minX;
	float minZ = poly[0][2];
	float maxZ = minZ;
	float sumY = 0;

	foreach (vector p : poly)
	{
		minX = Math.Min(minX, p[0]);
		maxX = Math.Max(maxX, p[0]);
		minZ = Math.Min(minZ, p[2]);
		maxZ = Math.Max(maxZ, p[2]);
		sumY += p[1];
	}

	baseHeight = sumY / poly.Count();
	centre = Vector((minX + maxX) * 0.5, baseHeight, (minZ + maxZ) * 0.5);

	radius = 0;
	foreach (vector q : poly)
	{
		float dx = q[0] - centre[0];
		float dz = q[2] - centre[2];
		radius = Math.Max(radius, Math.Sqrt(dx * dx + dz * dz));
	}
}

Putting the points in order

An outline is not a set of points but a path: the order decides which corner connects to which. The first version used the order in which the points had been created. That is not a property of the shape. It is the order in which their EOnInit happened to fire – the order of placing on the server, the order of delivery on a client, and anything at all after a restart with persistence.

A path that follows from the positions alone has none of those problems: sort the points by their angle around the centre of all of them, once around like a clock hand. Each edge then stays within its own wedge, and two wedges never overlap, so the outline cannot cross itself, however the points were placed.

Example code – free to use
static void OrderByAngleXZ(notnull array<vector> points)
{
	int count = points.Count();
	if (count < 3)
		return;

	float centreX = 0;
	float centreZ = 0;
	for (int k = 0; k < count; k++)
	{
		centreX += points[k][0];
		centreZ += points[k][2];
	}
	centreX /= count;
	centreZ /= count;

	// Work out each angle once - Atan2 costs more than the comparison that follows.
	array<float> angles = {};
	for (int m = 0; m < count; m++)
		angles.Insert(Math.Atan2(points[m][2] - centreZ, points[m][0] - centreX));

	// Insertion sort: for a handful of corners it beats anything fancier,
	// and both arrays move in step, so they cannot drift apart.
	for (int i = 1; i < count; i++)
	{
		vector point = points[i];
		float angle = angles[i];

		int j = i - 1;
		while (j >= 0 && angles[j] > angle)
		{
			points[j + 1] = points[j];
			angles[j + 1] = angles[j];
			j--;
		}

		points[j + 1] = point;
		angles[j + 1] = angle;
	}
}
PitfallThe angle sort has a limit. Where a ray from the centre meets the boundary more than once – a U, or a star with deep notches – two corners fall into the same wedge and are swapped. For those shapes the points carry an explicit order number, and as soon as any point of the zone has one, it wins over the sort.
Example code on this page is free to use in your own projects (credit appreciated). The GTG Safe Zone mod is published under the Arma Public License (APL). Suggestions: Discord. Arma Reforger / Enfusion are trademarks of Bohemia Interactive.
Impressum · Privacy Policy