Table of Contents

Class TargetOrdering

Namespace
FishMMO.Shared
Assembly
FishMMO.Shared.dll

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

int

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

int

Methods

ApplyMaxHits(List<TargetRank>, int)

Truncates an ordered rank list to maxHits entries.

public static void ApplyMaxHits(List<TargetRank> ranks, int maxHits)

Parameters

ranks List<TargetRank>
maxHits int

CappedCount(int, int)

Number of entries that survive a maxHits cap.

public static int CappedCount(int count, int maxHits)

Parameters

count int
maxHits int

Returns

int

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

a TargetRank
b TargetRank

Returns

int

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

a TargetRank
b TargetRank

Returns

int

Negative when a sorts first.

ContainsBody(List<GameObject>, GameObject)

True when key is already present in keptKeys.

public static bool ContainsBody(List<GameObject> keptKeys, GameObject key)

Parameters

keptKeys List<GameObject>
key GameObject

Returns

bool

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

ranks List<TargetRank>

The ordered rank list, truncated in place.

keys IReadOnlyList<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

ranks IReadOnlyList<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

origin Vector3
forward Vector3
targetPosition Vector3
coneAngleDegrees float

Returns

bool

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

ranks IReadOnlyList<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

maxHits int

Returns

int

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

index int

Index of the candidate in the caller's list.

candidate GameObject

The candidate GameObject. May be null.

distance float

Distance from the query origin.

Returns

TargetRank

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

collider Collider

The collider a query returned.

character ICharacter

The character that owns it, or null.

Returns

GameObject

The dedupe key, or null when collider is 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

collider Collider

The collider a query returned. May be null.

character ICharacter

The character that owns it, or null.

Returns

GameObject

The resolved body, or null when collider is 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

candidate GameObject

The object to key. May be null.

Returns

GameObject

The dedupe key, or null when candidate is 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

ranks List<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

hits RaycastHit[]
count int

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

ranks List<TargetRank>

StableNameKey(string)

A hash of a GameObject's name that both peers compute identically.

public static int StableNameKey(string name)

Parameters

name string

Returns

int

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

position Vector3

Returns

int

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

buffer T[]

The buffer, replaced with a larger one when this returns true.

count int

Result count the query just returned.

Returns

bool

True when the buffer grew and the query must be re-run.

Type Parameters

T

Buffer element type — Collider or RaycastHit.

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