GTG Safe Zone
Alarm and replication
A zone needs a place where the alarm is decided and a way for everybody to learn about it. Both are in the zone itself: it scans on the server, and it replicates its own state.
No manager
The master prefab carries a RplComponent; nothing else has to be set up. A static list of all zones
makes them findable by position; each zone schedules its own scan; each replicates its own flag. Nothing sits on the game
mode, and nothing in a config has to know the zone exists. A zone exists as soon as it is in the world – and several
of them live side by side without knowing each other.
The scan runs on the server
Every few seconds the zone asks the world for characters within its bounding sphere plus the ring width, and checks each against the exact shape. One enemy is enough, so the query stops at the first. A dead enemy does not count, or a corpse in the ring would hold the alarm.
protected bool m_bEnemySeen; // written by the query callback
protected void ScanForEnemies()
{
m_bEnemySeen = false;
GetGame().GetWorld().QueryEntitiesBySphere(
m_vBoundsCenter, m_fBoundsRadius + m_fRingWidth, OnScanEntity, null, EQueryEntitiesFlags.DYNAMIC);
float now = GetGame().GetWorld().GetWorldTime();
if (m_bEnemySeen)
m_fHoldUntil = now + m_fHoldSeconds * 1000; // e.g. 60 s after the LAST enemy has gone
bool active = now < m_fHoldUntil;
if (active == m_bAlarmActive)
return; // nothing changed - send nothing
m_bAlarmActive = active;
Replication.BumpMe(); // the property goes out to everybody
AnnounceToLocalPlayer(); // the machine that decided gets no callback for its own change
}
protected bool OnScanEntity(notnull IEntity entity)
{
ChimeraCharacter character = ChimeraCharacter.Cast(entity);
if (!character)
return true; // not a person, keep going
SCR_DamageManagerComponent damage = SCR_DamageManagerComponent.GetDamageManager(entity);
if (damage && damage.GetState() == EDamageState.DESTROYED)
return true; // dead people do not hold an alarm
if (!IsInsideOrInRing(WorldPos(entity)) || !IsEnemy(entity))
return true;
m_bEnemySeen = true;
return false; // one is enough
}
protected void DeferredStart()
{
if (!Replication.IsServer())
return;
// ... build the outline (see "Points to polygon") ...
GetGame().GetCallqueue().CallLater(ScanForEnemies, 5000, true); // every 5 s
}
The hold time is the point of the m_fHoldUntil line. An enemy who steps in and out of the ring would otherwise
switch the alarm on and off with every scan. Now the alarm lingers for the configured time after the last sighting, and only
then clears.
GetGame().GetCallqueue().Remove(ScanForEnemies)), so a zone that is deleted while players are connected leaves nothing behind in the queue.One flag, replicated
The result of the scan is a single boolean. It is declared [RplProp] with a callback name, and the authority
calls Replication.BumpMe() when it changes. Every client receives the new value and its callback runs. The
message to the player hangs on that callback: the state goes to every client anyway, and a separate Rpc would send the same
information twice. And a property, unlike a one-off message, also reaches a player who joins later.
[RplProp(onRplName: "OnAlarmReplicated")]
protected bool m_bAlarmActive;
protected void OnAlarmReplicated()
{
AnnounceToLocalPlayer();
}
protected void AnnounceToLocalPlayer()
{
PlayerController controller = GetGame().GetPlayerController();
if (!controller)
return;
IEntity controlled = controller.GetControlledEntity();
if (!controlled || !IsInside(WorldPos(controlled)))
return; // only people inside the zone hear about it
MyAD_Feedback.ShowAlarmChanged(m_bAlarmActive);
}
Only players inside the zone are told. Announcing it to the server would be noise, and the intruder in the ring should not learn that he has been spotted.
The outline is replicated, too
This was a design mistake that only a dedicated server showed. The first version let every machine build the outline from the replicated point entities, on the assumption that the same points give the same shape everywhere. They do not: the order of the outline was the order in which the points initialised, and on a client that is the order in which the network happens to deliver them. Three players saw three different shapes, most of them crossing themselves – and the action lock tests against that same shape, so the no-fire rule had holes in different places for every player. The damage protection stayed right, because the server decides that.
The fix is the rule from the overview: one writer. Only the server builds the outline, orders it, and publishes it. A client does not build; it takes over the result. The mod found no vanilla precedent for replicating a list of vectors, but there is one for lists of floats (the lightning component replicates several), so the corners travel flat: three floats per corner.
[RplProp(onRplName: "OnOutlineReplicated")]
protected ref array<float> m_aOutlineData; // x, y, z per corner, flat
protected void PublishOutline()
{
if (!Replication.IsServer())
return;
if (!m_aOutlineData)
m_aOutlineData = {};
m_aOutlineData.Clear();
foreach (vector corner : m_aOutline)
{
m_aOutlineData.Insert(corner[0]);
m_aOutlineData.Insert(corner[1]); // the height goes along: it feeds the floor and the ceiling
m_aOutlineData.Insert(corner[2]);
}
Replication.BumpMe();
}
protected void OnOutlineReplicated() // clients
{
if (!m_aOutlineData || m_aOutlineData.Count() < 9) // fewer than three corners
{
m_bValid = false;
return;
}
m_aOutline = {};
for (int i = 0; i + 2 < m_aOutlineData.Count(); i += 3)
m_aOutline.Insert(Vector(m_aOutlineData[i], m_aOutlineData[i + 1], m_aOutlineData[i + 2]));
MyAD_Geometry.ComputeBounds(m_aOutline, m_vBoundsCenter, m_fBoundsRadius, m_fBaseHeight);
m_bValid = true;
}
A zone that follows its points
Points can be moved or deleted while players stand inside. The zone therefore watches its corners once a second: if the
number of points changed, it rebuilds; if a point moved, it takes the new position. It happens without a restart, and the
outline is republished with Replication.BumpMe() each time.