Class TargetOrdering
Deterministic ordering, ranking and shape tests shared by every target selector.
public static class TargetOrdering
- Inheritance
-
TargetOrdering
- Inherited Members
Remarks
Why ordering is a correctness concern. OverlapSphere and Raycast fill
their buffers in broadphase order, which is a function of the physics scene's internal state
and is not reproducible between two runs, let alone between two peers. Any selector that then
caps at MaxHits, picks "the first match", or rolls a random index is choosing out of an
unordered set — so the same cast can hit different characters on different runs. Imposing a
total order on the candidates before any of those steps is what makes the outcome a function
of the world rather than of the broadphase.
Total order, not merely a stable sort. Every comparator here ends in the candidate's own index, so no two entries ever compare equal. That means the result does not depend on whether the sort algorithm happens to be stable — Sort() is not.
Fields
MaximumQueryBufferSize
Largest a query buffer is allowed to grow to before results are truncated.
public const int MaximumQueryBufferSize = 256
Field Value
Remarks
Matches AbilityObjectSweep's ceiling, so every query in the project truncates at the
same point rather than at whichever bound its own author picked.
UnnetworkedObjectId
Sort key for a candidate that carries no FishNet.Object.NetworkObject.
public const int UnnetworkedObjectId = 2147483647
Field Value
Methods
ApplyMaxHits(List<TargetRank>, int)
Truncates an ordered rank list to maxHits entries.
public static void ApplyMaxHits(List<TargetRank> ranks, int maxHits)
Parameters
ranksList<TargetRank>maxHitsint
CappedCount(int, int)
Number of entries that survive a maxHits cap.
public static int CappedCount(int count, int maxHits)
Parameters
Returns
Remarks
A cap is only meaningful once the set it is applied to is ordered; this is separated from the sort so a test can pin that relationship rather than infer it.
CompareByDistance(TargetRank, TargetRank)
Orders by ascending distance, breaking ties with CompareStable(TargetRank, TargetRank).
public static int CompareByDistance(TargetRank a, TargetRank b)
Parameters
aTargetRankbTargetRank
Returns
CompareStable(TargetRank, TargetRank)
Orders by network identity: ObjectId, then name, then position key, then original index.
public static int CompareStable(TargetRank a, TargetRank b)
Parameters
aTargetRankbTargetRank
Returns
- int
Negative when
asorts first.
ContainsBody(List<GameObject>, GameObject)
True when key is already present in keptKeys.
public static bool ContainsBody(List<GameObject> keptKeys, GameObject key)
Parameters
keptKeysList<GameObject>keyGameObject
Returns
Remarks
The streaming form of DedupeByBody(List<TargetRank>, IReadOnlyList<GameObject>), for a caller that emits as it walks an already-ordered set rather than truncating a rank list. Same linear scan, and the same ReferenceEquals(object, object) for the same reason.
DedupeByBody(List<TargetRank>, IReadOnlyList<GameObject>)
Drops every candidate whose body is already represented, keeping the first of each.
public static void DedupeByBody(List<TargetRank> ranks, IReadOnlyList<GameObject> keys)
Parameters
ranksList<TargetRank>The ordered rank list, truncated in place.
keysIReadOnlyList<GameObject>The dedupe key of each candidate, indexed by Index. A null key is treated as its own body and never collapses with another.
Remarks
Run it on a SORTED list, between the sort and the cap. The entry it keeps for a body is the first one it meets, so on a distance-ordered list that is the body's nearest collider — the reading every consumer wants — and on an unsorted one it is whichever the broadphase listed first, which is the arbitrary choice the whole module exists to remove.
Why it is needed at all. A prefab may hang several colliders off one body, and a
MaxHits cap applied to the raw hits then counts colliders rather than victims: the
same ability affects a different NUMBER of characters depending on how its targets are
rigged, and a random selection weights a body by its collider count. Static scenery is
untouched by this — a wall with twenty colliders and no rigidbody keys each collider to
itself, so twenty separate candidates is exactly what comes back.
A linear scan over what has been kept rather than a set: the list is bounded by the query
buffer, so this allocates nothing and hashes nothing. ReferenceEquals(object, object)
rather than ==, because Unity overloads equality on Object to ask the engine
whether the native object is still alive — a native crossing per comparison, for a question
that is not open: every operand came out of a query in this same frame.
FurthestIndex(IReadOnlyList<TargetRank>)
Index into ranks of the furthest candidate, ties broken by identity.
public static int FurthestIndex(IReadOnlyList<TargetRank> ranks)
Parameters
ranksIReadOnlyList<TargetRank>
Returns
- int
-1 when the list is empty.
Remarks
Deliberately not "the last entry of a distance sort": that would break ties by picking the highest identity while NearestIndex(IReadOnlyList<TargetRank>) picks the lowest, so two selectors pointed at the same equidistant pair would disagree about which one they are talking about.
IsWithinCone(Vector3, Vector3, Vector3, float)
True when targetPosition lies inside a cone of
coneAngleDegrees total spread, opening along forward.
public static bool IsWithinCone(Vector3 origin, Vector3 forward, Vector3 targetPosition, float coneAngleDegrees)
Parameters
originVector3forwardVector3targetPositionVector3coneAngleDegreesfloat
Returns
Remarks
A target standing on the origin is outside every cone. That case is the caster
itself, and the previous formulation selected it whenever the cone was 180° or wider:
the caster-to-caster vector is zero, Vector3.normalized returns zero rather than
throwing, and Acos(Dot(forward, zero)) == Acos(0) == 90° — which passes any
half-angle of 90° or more. A cone is a direction test and a point with no direction
cannot satisfy one.
Compared as a dot product against the cosine of the half-angle rather than through
Acos: same answer, no transcendental, and no accumulation of the rounding that makes
an exactly-on-the-edge target land differently on two peers.
NearestIndex(IReadOnlyList<TargetRank>)
Index into ranks of the closest candidate, ties broken by identity.
public static int NearestIndex(IReadOnlyList<TargetRank> ranks)
Parameters
ranksIReadOnlyList<TargetRank>
Returns
- int
-1 when the list is empty.
QueryBufferSize(int)
Query buffer size for a caller whose authored cap is maxHits.
public static int QueryBufferSize(int maxHits)
Parameters
maxHitsint
Returns
Remarks
Deliberately larger than the cap. Sizing the buffer at exactly MaxHits makes the
physics broadphase perform the truncation, in its own order, before the caller ever sees
the candidates — so a cap of 5 in a crowd of 20 picked five arbitrary characters and the
deterministic sort that follows had nothing to work with. Querying wide and capping after
the sort is what makes the cap mean "the first five in a defined order".
This is the STARTING size, not a limit: it only moves the truncation point from
maxHits up to maxHits * 4. A caller must still grow the buffer through
TryGrowQueryBuffer<T>(ref T[], int) when a query comes back full, or the same failure
returns in a denser crowd.
Lives here rather than on TargetSelector because the ability actions resolve hits
without a selector and need the identical rule; it was duplicated inline in
AbilityApplyAreaAction for exactly as long as it lived somewhere only selectors
could reach.
Rank(int, GameObject, float)
Builds the sort keys for one candidate GameObject.
public static TargetRank Rank(int index, GameObject candidate, float distance)
Parameters
indexintIndex of the candidate in the caller's list.
candidateGameObjectThe candidate GameObject. May be null.
distancefloatDistance from the query origin.
Returns
ResolveHitKey(Collider, out ICharacter)
The key a hit should be deduplicated and capped by: the character where there is one, otherwise the resolved body.
public static GameObject ResolveHitKey(Collider collider, out ICharacter character)
Parameters
colliderColliderThe collider a query returned.
characterICharacterThe character that owns it, or null.
Returns
- GameObject
The dedupe key, or null when
collideris null.
Remarks
Keyed on the character so two hitboxes on one body cost one hit and occupy one slot of a
MaxHits cap. A cap that counts colliders rather than victims means the same ability
hits a different NUMBER of characters depending on how its targets are rigged.
ResolveHitRoot(Collider, out ICharacter)
Resolves the body a raw collider hit belongs to, and the character on it if there is one.
public static GameObject ResolveHitRoot(Collider collider, out ICharacter character)
Parameters
colliderColliderThe collider a query returned. May be null.
characterICharacterThe character that owns it, or null.
Returns
- GameObject
The resolved body, or null when
collideris null.
Remarks
A prefab is free to hang its hitbox off a child transform, so the collider a query returns
is frequently not the object anything downstream cares about. The rigidbody's GameObject
where there is one — which is what Collision.gameObject reported back when hits came
from collision callbacks — then a parent walk for a character rigged without one.
This is the same resolution Rank(int, GameObject, float) performs for its ObjectId key, and the
reason both exist is that a bare GetComponent on the collider gets two things wrong
at once: it silently drops a character whose hitbox is a child, and it counts a character
with two colliders twice. AbilityApplyAreaAction had both faults while the sweep next
to it did not, so the two hit-resolving paths disagreed about who was even a candidate.
One implementation is what keeps them honest.
ResolveObjectKey(GameObject)
The same dedupe key as ResolveHitKey(Collider, out ICharacter), for an object that did not arrive through a physics query.
public static GameObject ResolveObjectKey(GameObject candidate)
Parameters
candidateGameObjectThe object to key. May be null.
Returns
- GameObject
The dedupe key, or null when
candidateis null.
Remarks
Exists so a selector can compare a hit against its own spatial context — "is this candidate
the caster?" — on the same terms it compares two hits against each other. The context is an
EventData.Target, which is a GameObject rather than a collider, and testing it with a
bare reference comparison against hit.gameObject asks a different question: a caster
whose hitbox is a child does not equal its own hitbox, so the self-exclusion in the nearest,
furthest and random selectors let the caster select itself.
UnityEngine.Collider.attachedRigidbody has no GameObject equivalent, so the rigidbody is found by walking the parents instead. The two agree for anything a query can return: a collider's attached rigidbody is the nearest one at or above it in the hierarchy.
SortByDistance(List<TargetRank>)
Applies the distance order in place.
public static void SortByDistance(List<TargetRank> ranks)
Parameters
ranksList<TargetRank>
SortRaycastHits(RaycastHit[], int)
Orders a filled Raycast buffer by distance along the ray, ties by identity.
public static void SortRaycastHits(RaycastHit[] hits, int count)
Parameters
hitsRaycastHit[]countint
Remarks
Distance first because a ray is a line and "what it passed through, in order" is the only reading a pierce or beam effect can act on. Unity guarantees no order at all for the non-allocating overloads, so without this a two-hit pierce chose its victims arbitrarily.
SortStable(List<TargetRank>)
Applies the identity order in place.
public static void SortStable(List<TargetRank> ranks)
Parameters
ranksList<TargetRank>
StableNameKey(string)
A hash of a GameObject's name that both peers compute identically.
public static int StableNameKey(string name)
Parameters
namestring
Returns
Remarks
Not GetHashCode(): that is permitted to be randomised per process, so using it as a cross-peer sort key would order the same two scene objects differently on the client and on the server. FNV-1a over the UTF-16 code units has no such freedom.
StablePositionKey(Vector3)
Stable hash of a world position at millimetre resolution. Used only to separate un-networked candidates that share a name.
public static int StablePositionKey(Vector3 position)
Parameters
positionVector3
Returns
Remarks
It hashes the LIVE position, not an authored one — the caller reads
candidate.transform.position. For the case this key exists to serve that is the same
thing: un-networked candidates are scene objects, every peer loads them from the same scene,
and they do not move. It is only a total-order tiebreak of last resort, reached when two
candidates share both an ObjectId and a name, so anything networked is separated long before
it gets here. Two un-networked candidates that share a name AND move independently would
order differently on two peers; nothing in the project does that today, and a candidate that
needs a reproducible identity should carry a NetworkObject rather than rely on this.
TryGrowQueryBuffer<T>(ref T[], int)
Doubles a query buffer that came back full, so the caller can re-run the query.
public static bool TryGrowQueryBuffer<T>(ref T[] buffer, int count)
Parameters
bufferT[]The buffer, replaced with a larger one when this returns true.
countintResult count the query just returned.
Returns
- bool
True when the buffer grew and the query must be re-run.
Type Parameters
TBuffer element type —
ColliderorRaycastHit.
Remarks
A non-allocating physics query returns at most buffer.Length results and says
nothing about how many it discarded. A full buffer is therefore indistinguishable from an
exactly-full one, and the discarded entries were chosen by the broadphase — so a cap or a
sort applied afterwards is ordering an arbitrary subset. Re-querying into a bigger buffer
is the only way to learn whether anything was lost.
Use it as the condition of the query loop:
int count;
while (true)
{
count = physicsScene.OverlapSphere(centre, radius, hits, mask, QueryTriggerInteraction.UseGlobal);
if (!TargetOrdering.TryGrowQueryBuffer(ref hits, count)) break;
}