ArenaController.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Networked identity for the complete arena configuration.
///
/// These are stable ArenaLayout.LayoutId values, not scene-child indices.
/// The host chooses one state; each peer locally instantiates the matching
/// prefab assets under its three arena section anchors.
/// </summary>
public struct ArenaLayoutState : IEquatable<ArenaLayoutState>
{
public const int InvalidId = -1;
public int SpawnAId { get; set; }
public int CenterId { get; set; }
public int SpawnBId { get; set; }
public static ArenaLayoutState Invalid =>
new()
{
SpawnAId = InvalidId,
CenterId = InvalidId,
SpawnBId = InvalidId
};
public bool IsValid =>
SpawnAId >= 0 &&
CenterId >= 0 &&
SpawnBId >= 0;
public bool Equals( ArenaLayoutState other )
{
return
SpawnAId == other.SpawnAId &&
CenterId == other.CenterId &&
SpawnBId == other.SpawnBId;
}
public override bool Equals( object obj )
{
return
obj is ArenaLayoutState other &&
Equals( other );
}
public override int GetHashCode()
{
return HashCode.Combine(
SpawnAId,
CenterId,
SpawnBId
);
}
public static bool operator ==(
ArenaLayoutState left,
ArenaLayoutState right
)
{
return left.Equals( right );
}
public static bool operator !=(
ArenaLayoutState left,
ArenaLayoutState right
)
{
return !left.Equals( right );
}
public override string ToString()
{
return $"A:{SpawnAId} C:{CenterId} B:{SpawnBId}";
}
}
/// <summary>
/// Arena System v2.1 — runtime prefab layouts.
///
/// main.scene contains only the three section anchors plus this controller.
/// Layout assets live as prefabs in the controller catalog. When the networked
/// ArenaLayoutState changes, each peer locally replaces the three active
/// runtime instances.
///
/// Competitive rounds use the SAME Spawn prefab ID for A and B; Spawn B is
/// mirrored by the 180-degree Yaw on SpawnBSection. Sandbox may still choose
/// Spawn A and Spawn B independently.
///
/// Runtime clones are intentionally local/non-networked. Only the compact
/// ArenaLayoutState is synchronized.
/// </summary>
[Title( "Arena Controller" )]
[Category( "One More Round" )]
[Icon( "grid_view" )]
public sealed class ArenaController : Component
{
// =========================================================
// SECTION ANCHORS
// =========================================================
[Property, Group( "Sections" )]
public GameObject SpawnASection { get; set; }
[Property, Group( "Sections" )]
public GameObject CenterSection { get; set; }
[Property, Group( "Sections" )]
public GameObject SpawnBSection { get; set; }
// =========================================================
// PREFAB CATALOG
// =========================================================
/// <summary>
/// Spawn layout prefab assets. The SAME catalog is used for Spawn A and
/// Spawn B. Drag prefab assets here directly from the Asset Browser.
/// </summary>
[Property, Group( "Catalog" )]
public List<GameObject> SpawnLayoutPrefabs { get; set; } = new();
/// <summary>
/// Center layout prefab assets.
/// </summary>
[Property, Group( "Catalog" )]
public List<GameObject> CenterLayoutPrefabs { get; set; } = new();
// =========================================================
// NETWORKED STATE
// =========================================================
[Sync( SyncFlags.FromHost )]
[Change( nameof( OnCurrentLayoutStateChanged ) )]
public ArenaLayoutState CurrentLayoutState { get; set; } =
ArenaLayoutState.Invalid;
// =========================================================
// DIAGNOSTICS
// =========================================================
[Property, Group( "Diagnostics" )]
public bool LogLayoutChanges { get; set; } = true;
[Property, Group( "Diagnostics" )]
public bool WarnAboutNonNormalizedLayoutRoots { get; set; } = true;
// =========================================================
// PUBLIC INFO FOR SANDBOX SETUP UI
// =========================================================
public int SpawnALayoutCount
{
get
{
EnsureCatalogs();
return _spawnLayouts.Count;
}
}
public int CenterLayoutCount
{
get
{
EnsureCatalogs();
return _centerLayouts.Count;
}
}
public int SpawnBLayoutCount
{
get
{
EnsureCatalogs();
return _spawnLayouts.Count;
}
}
public string GetSpawnALayoutName( int index )
{
EnsureCatalogs();
return GetLayoutNameByIndex(
_spawnLayouts,
index
);
}
public string GetCenterLayoutName( int index )
{
EnsureCatalogs();
return GetLayoutNameByIndex(
_centerLayouts,
index
);
}
public string GetSpawnBLayoutName( int index )
{
EnsureCatalogs();
return GetLayoutNameByIndex(
_spawnLayouts,
index
);
}
// =========================================================
// CATALOG DATA
// =========================================================
private sealed class LayoutEntry
{
public int Id { get; init; }
public string Name { get; init; }
public GameObject Prefab { get; init; }
public ArenaLayout Metadata { get; init; }
}
private struct ArenaCombination
{
public int SpawnId { get; set; }
public int CenterId { get; set; }
public ArenaLayoutState ToSymmetricState()
{
return new ArenaLayoutState
{
SpawnAId = SpawnId,
CenterId = CenterId,
SpawnBId = SpawnId
};
}
}
private readonly List<LayoutEntry> _spawnLayouts =
new();
private readonly List<LayoutEntry> _centerLayouts =
new();
private readonly Dictionary<int, LayoutEntry> _spawnById =
new();
private readonly Dictionary<int, LayoutEntry> _centerById =
new();
private bool _catalogsBuilt;
// =========================================================
// LIVE RUNTIME INSTANCES
// =========================================================
private GameObject _spawnAInstance;
private GameObject _centerInstance;
private GameObject _spawnBInstance;
private int _spawnAInstanceId =
ArenaLayoutState.InvalidId;
private int _centerInstanceId =
ArenaLayoutState.InvalidId;
private int _spawnBInstanceId =
ArenaLayoutState.InvalidId;
// =========================================================
// MATCH SELECTION STATE
// =========================================================
private readonly Dictionary<RoundModifier, List<ArenaCombination>> _bags =
new();
private readonly Dictionary<RoundModifier, ArenaLayoutState> _lastStateByModifier =
new();
private int _preparedRoundNumber =
-1;
private RoundModifier _preparedModifier =
RoundModifier.Standard;
private ArenaLayoutUsage _preparedUsage =
ArenaLayoutUsage.Standard;
private ArenaLayoutState _preparedRoundState =
ArenaLayoutState.Invalid;
// =========================================================
// LIFECYCLE
// =========================================================
protected override void OnStart()
{
RebuildCatalogs();
// If a synchronized state already exists (late join / scene startup),
// materialize it immediately. Otherwise the section anchors remain empty
// until lobby/round preparation chooses the first state.
if ( CurrentLayoutState.IsValid )
{
ApplyLayoutState(
CurrentLayoutState
);
}
}
private void OnCurrentLayoutStateChanged(
ArenaLayoutState oldState,
ArenaLayoutState newState
)
{
EnsureCatalogs();
ApplyLayoutState(
newState
);
}
// =========================================================
// ROUND / LOBBY PREPARATION
// =========================================================
/// <summary>
/// Clears only host-side shuffle/history state for a new match.
/// It does not change the visible arena by itself.
/// </summary>
public void ResetMatchSequence()
{
if ( !Networking.IsHost )
return;
_bags.Clear();
_lastStateByModifier.Clear();
_preparedRoundNumber =
-1;
_preparedModifier =
RoundModifier.Standard;
_preparedUsage =
ArenaLayoutUsage.Standard;
_preparedRoundState =
ArenaLayoutState.Invalid;
}
/// <summary>
/// Uses a deterministic, non-consuming Lobby configuration.
/// </summary>
public void PrepareLobby()
{
if ( !Networking.IsHost )
return;
EnsureCatalogs();
ArenaLayoutState state =
FindFirstSymmetricState(
ArenaLayoutUsage.Lobby
);
if ( !state.IsValid )
{
Log.Warning(
"ARENA | No Lobby layout combination found. Falling back to Standard."
);
state =
FindFirstSymmetricState(
ArenaLayoutUsage.Standard
);
}
if ( !state.IsValid )
{
Log.Error(
"ARENA | Lobby could not be prepared because no valid layout combination exists."
);
return;
}
SetCurrentState(
state
);
if ( LogLayoutChanges )
{
Log.Info(
$"ARENA | LOBBY | {DescribeState( state )}"
);
}
}
/// <summary>
/// Prepares the arena for a competitive round.
///
/// Repeated calls for the same round/modifier are idempotent and reuse the
/// already-selected state instead of consuming another shuffle-bag entry.
/// </summary>
public void PrepareForRound(
int roundNumber,
RoundModifier modifier
)
{
PrepareForRound(
roundNumber,
modifier,
GetUsageForModifier( modifier )
);
}
/// <summary>
/// Data-driven overload used by OMRRoundRuleProfile. The modifier remains the
/// shuffle/history key while the profile chooses which authored layout usage
/// bucket this round should consume.
/// </summary>
public void PrepareForRound(
int roundNumber,
RoundModifier modifier,
ArenaLayoutUsage usage
)
{
if ( !Networking.IsHost )
return;
EnsureCatalogs();
if ( !ValidateRuntimeRequirements() )
return;
if (
_preparedRoundNumber == roundNumber &&
_preparedModifier == modifier &&
_preparedUsage == usage &&
_preparedRoundState.IsValid
)
{
SetCurrentState(
_preparedRoundState
);
return;
}
ArenaLayoutState state =
TakeNextStateFromBag(
modifier,
usage
);
if ( !state.IsValid )
{
Log.Warning(
$"ARENA | No valid {modifier} combination found. Falling back to Standard layouts."
);
state =
TakeNextStateFromBag(
RoundModifier.Standard,
ArenaLayoutUsage.Standard
);
}
if ( !state.IsValid )
{
Log.Error(
$"ARENA | ROUND {roundNumber} could not prepare a valid arena."
);
return;
}
_preparedRoundNumber =
roundNumber;
_preparedModifier =
modifier;
_preparedUsage =
usage;
_preparedRoundState =
state;
SetCurrentState(
state
);
if ( LogLayoutChanges )
{
Log.Info(
$"ARENA | ROUND {roundNumber} | MODIFIER {modifier} | {DescribeState( state )}"
);
}
}
/// <summary>
/// Sandbox keeps independent Spawn A / Center / Spawn B selection by the
/// existing UI indices. Both Spawn sides use the same prefab catalog.
/// </summary>
public void PrepareSandbox(
int spawnAIndex,
int centerIndex,
int spawnBIndex
)
{
if ( !Networking.IsHost )
return;
EnsureCatalogs();
LayoutEntry spawnA =
GetLayoutByIndex(
_spawnLayouts,
spawnAIndex
);
LayoutEntry center =
GetLayoutByIndex(
_centerLayouts,
centerIndex
);
LayoutEntry spawnB =
GetLayoutByIndex(
_spawnLayouts,
spawnBIndex
);
if (
spawnA is null ||
center is null ||
spawnB is null
)
{
Log.Warning(
"ARENA | SANDBOX | One or more requested layout catalogs are empty."
);
return;
}
ArenaLayoutState state =
new()
{
SpawnAId = spawnA.Id,
CenterId = center.Id,
SpawnBId = spawnB.Id
};
SetCurrentState(
state
);
if ( LogLayoutChanges )
{
Log.Info(
$"ARENA | SANDBOX | {DescribeState( state )}"
);
}
}
// =========================================================
// APPLY NETWORKED STATE
// =========================================================
private void SetCurrentState(
ArenaLayoutState state
)
{
CurrentLayoutState =
state;
// Explicit apply is useful when reapplying an identical state because a
// [Change] callback only needs to react to an actual property change.
ApplyLayoutState(
state
);
}
private void ApplyLayoutState(
ArenaLayoutState state
)
{
if ( !state.IsValid )
{
ClearRuntimeLayouts();
return;
}
ReplaceSectionInstance(
SpawnASection,
_spawnById,
state.SpawnAId,
ref _spawnAInstance,
ref _spawnAInstanceId,
"Spawn A"
);
ReplaceSectionInstance(
CenterSection,
_centerById,
state.CenterId,
ref _centerInstance,
ref _centerInstanceId,
"Center"
);
ReplaceSectionInstance(
SpawnBSection,
_spawnById,
state.SpawnBId,
ref _spawnBInstance,
ref _spawnBInstanceId,
"Spawn B"
);
}
private void ReplaceSectionInstance(
GameObject section,
Dictionary<int, LayoutEntry> catalog,
int requestedId,
ref GameObject liveInstance,
ref int liveId,
string sectionName
)
{
if (
liveId == requestedId &&
liveInstance is not null &&
liveInstance.IsValid
)
{
return;
}
RemoveRuntimeInstance(
ref liveInstance,
ref liveId
);
if (
section is null ||
!section.IsValid
)
{
Log.Error(
$"ARENA | {sectionName} section anchor is missing."
);
return;
}
if (
!catalog.TryGetValue(
requestedId,
out LayoutEntry entry
) ||
entry.Prefab is null ||
!entry.Prefab.IsValid
)
{
Log.Warning(
$"ARENA | {sectionName} has no prefab with LayoutId {requestedId}."
);
return;
}
GameObject instance =
entry.Prefab.Clone();
if (
instance is null ||
!instance.IsValid
)
{
Log.Error(
$"ARENA | Failed to clone {sectionName} layout {requestedId}:{entry.Name}."
);
return;
}
// Runtime arena geometry is deterministic local content. We synchronize
// only ArenaLayoutState; we do not network-spawn three extra hierarchies.
instance.Flags |=
GameObjectFlags.NotNetworked;
instance.Parent =
section;
// Normalize the cloned prefab root after parenting it under the
// section anchor. Use GameObject local transform properties directly
// here; inside a Component class, `Transform` also names the inherited
// GameTransform property, which makes `Transform.Zero` resolve to the
// wrong symbol in current S&box.
instance.LocalPosition =
Vector3.Zero;
instance.LocalRotation =
Rotation.Identity;
instance.LocalScale =
Vector3.One;
instance.Enabled =
true;
liveInstance =
instance;
liveId =
requestedId;
if ( LogLayoutChanges )
{
Log.Info(
$"ARENA | SPAWNED {sectionName} | {requestedId}:{entry.Name}"
);
}
}
private void RemoveRuntimeInstance(
ref GameObject instance,
ref int id
)
{
if (
instance is not null &&
instance.IsValid
)
{
// Disable immediately so old colliders/renderers cannot overlap the
// incoming layout during the frame in which Destroy is processed.
instance.Enabled =
false;
instance.Destroy();
}
instance =
null;
id =
ArenaLayoutState.InvalidId;
}
private void ClearRuntimeLayouts()
{
RemoveRuntimeInstance(
ref _spawnAInstance,
ref _spawnAInstanceId
);
RemoveRuntimeInstance(
ref _centerInstance,
ref _centerInstanceId
);
RemoveRuntimeInstance(
ref _spawnBInstance,
ref _spawnBInstanceId
);
}
// =========================================================
// SHUFFLE BAGS
// =========================================================
private ArenaLayoutState TakeNextStateFromBag(
RoundModifier modifier,
ArenaLayoutUsage usage
)
{
if (
!_bags.TryGetValue(
modifier,
out List<ArenaCombination> bag
) ||
bag.Count == 0
)
{
bag =
BuildSymmetricCombinations(
usage
);
if ( bag.Count == 0 )
return ArenaLayoutState.Invalid;
Shuffle(
bag
);
AvoidImmediateRepeatAcrossBagBoundary(
modifier,
bag
);
_bags[modifier] =
bag;
}
int lastIndex =
bag.Count - 1;
ArenaCombination combination =
bag[lastIndex];
bag.RemoveAt(
lastIndex
);
ArenaLayoutState state =
combination.ToSymmetricState();
_lastStateByModifier[modifier] =
state;
return state;
}
private List<ArenaCombination> BuildSymmetricCombinations(
ArenaLayoutUsage usage
)
{
List<int> spawnIds =
_spawnLayouts
.Where(
x =>
x.Metadata.Supports(
usage
)
)
.Select(
x => x.Id
)
.Distinct()
.OrderBy(
x => x
)
.ToList();
List<int> centerIds =
_centerLayouts
.Where(
x =>
x.Metadata.Supports(
usage
)
)
.Select(
x => x.Id
)
.Distinct()
.OrderBy(
x => x
)
.ToList();
List<ArenaCombination> combinations =
new();
foreach (
int spawnId in spawnIds
)
{
foreach (
int centerId in centerIds
)
{
combinations.Add(
new ArenaCombination
{
SpawnId = spawnId,
CenterId = centerId
}
);
}
}
return combinations;
}
private void Shuffle(
List<ArenaCombination> bag
)
{
for (
int i = bag.Count - 1;
i > 0;
i--
)
{
int swapIndex =
Game.Random.Next(
0,
i + 1
);
(
bag[i],
bag[swapIndex]
) = (
bag[swapIndex],
bag[i]
);
}
}
private void AvoidImmediateRepeatAcrossBagBoundary(
RoundModifier modifier,
List<ArenaCombination> bag
)
{
if (
bag.Count <= 1 ||
!_lastStateByModifier.TryGetValue(
modifier,
out ArenaLayoutState previous
)
)
return;
int nextIndex =
bag.Count - 1;
ArenaLayoutState next =
bag[nextIndex]
.ToSymmetricState();
if ( next != previous )
return;
(
bag[nextIndex],
bag[0]
) = (
bag[0],
bag[nextIndex]
);
}
// =========================================================
// PREFAB CATALOG
// =========================================================
private void EnsureCatalogs()
{
if ( _catalogsBuilt )
return;
RebuildCatalogs();
}
private void RebuildCatalogs()
{
BuildPrefabCatalog(
SpawnLayoutPrefabs,
ArenaLayoutSection.Spawn,
_spawnLayouts,
_spawnById
);
BuildPrefabCatalog(
CenterLayoutPrefabs,
ArenaLayoutSection.Center,
_centerLayouts,
_centerById
);
_catalogsBuilt =
true;
_bags.Clear();
}
private void BuildPrefabCatalog(
IEnumerable<GameObject> prefabReferences,
ArenaLayoutSection expectedSection,
List<LayoutEntry> entries,
Dictionary<int, LayoutEntry> byId
)
{
entries.Clear();
byId.Clear();
if ( prefabReferences is null )
return;
foreach (
GameObject prefab in prefabReferences
)
{
if (
prefab is null ||
!prefab.IsValid
)
continue;
ArenaLayout metadata =
prefab.GetComponent<ArenaLayout>(
true
);
if ( metadata is null )
continue;
LayoutEntry entry =
new()
{
Id = metadata.LayoutId,
Name = metadata.GetDisplayName(),
Prefab = prefab,
Metadata = metadata
};
entries.Add(
entry
);
if ( !byId.ContainsKey( entry.Id ) )
{
byId.Add(
entry.Id,
entry
);
}
}
entries.Sort(
(a, b) =>
a.Id.CompareTo(
b.Id
)
);
}
// =========================================================
// LOOKUP / UI HELPERS
// =========================================================
private LayoutEntry GetLayoutByIndex(
List<LayoutEntry> entries,
int index
)
{
if (
entries is null ||
entries.Count == 0
)
return null;
index =
Math.Clamp(
index,
0,
entries.Count - 1
);
return entries[index];
}
private string GetLayoutNameByIndex(
List<LayoutEntry> entries,
int index
)
{
LayoutEntry entry =
GetLayoutByIndex(
entries,
index
);
if ( entry is null )
return "NONE";
return entry.Name
.ToUpperInvariant();
}
private ArenaLayoutState FindFirstSymmetricState(
ArenaLayoutUsage usage
)
{
List<ArenaCombination> combinations =
BuildSymmetricCombinations(
usage
);
if ( combinations.Count == 0 )
return ArenaLayoutState.Invalid;
ArenaCombination first =
combinations
.OrderBy(
x => x.SpawnId
)
.ThenBy(
x => x.CenterId
)
.First();
return first.ToSymmetricState();
}
private ArenaLayoutUsage GetUsageForModifier(
RoundModifier modifier
)
{
return modifier switch
{
RoundModifier.HighNoon =>
ArenaLayoutUsage.HighNoon,
_ =>
ArenaLayoutUsage.Standard
};
}
private string DescribeState(
ArenaLayoutState state
)
{
string spawnAName =
GetNameById(
_spawnById,
state.SpawnAId
);
string centerName =
GetNameById(
_centerById,
state.CenterId
);
string spawnBName =
GetNameById(
_spawnById,
state.SpawnBId
);
return
$"SPAWN A {state.SpawnAId}:{spawnAName} | " +
$"CENTER {state.CenterId}:{centerName} | " +
$"SPAWN B {state.SpawnBId}:{spawnBName}";
}
private string GetNameById(
Dictionary<int, LayoutEntry> byId,
int id
)
{
if (
byId.TryGetValue(
id,
out LayoutEntry entry
)
)
{
return entry.Name;
}
return "MISSING";
}
// =========================================================
// VALIDATION
// =========================================================
[Button, Title( "Validate Arena" ), Description( "Rebuild the prefab catalog and report arena setup problems." )]
public void ValidateArenaSetup()
{
RebuildCatalogs();
int errors =
0;
int warnings =
0;
ValidateSectionAnchor(
"Spawn A",
SpawnASection,
ref errors,
ref warnings
);
ValidateSectionAnchor(
"Center",
CenterSection,
ref errors,
ref warnings
);
ValidateSectionAnchor(
"Spawn B",
SpawnBSection,
ref errors,
ref warnings
);
ValidatePrefabCatalog(
"Spawn",
SpawnLayoutPrefabs,
ArenaLayoutSection.Spawn,
_spawnLayouts,
ref errors,
ref warnings
);
ValidatePrefabCatalog(
"Center",
CenterLayoutPrefabs,
ArenaLayoutSection.Center,
_centerLayouts,
ref errors,
ref warnings
);
int standardCount =
BuildSymmetricCombinations(
ArenaLayoutUsage.Standard
).Count;
int lobbyCount =
BuildSymmetricCombinations(
ArenaLayoutUsage.Lobby
).Count;
int highNoonCount =
BuildSymmetricCombinations(
ArenaLayoutUsage.HighNoon
).Count;
if ( standardCount == 0 )
{
errors++;
Log.Error(
"ARENA VALIDATION | No Standard combinations exist."
);
}
if ( highNoonCount == 0 )
{
errors++;
Log.Error(
"ARENA VALIDATION | No High Noon combination exists."
);
}
if ( lobbyCount == 0 )
{
warnings++;
Log.Warning(
"ARENA VALIDATION | No Lobby combination is marked. PrepareLobby will fall back to Standard."
);
}
if ( errors == 0 )
{
Log.Info(
$"ARENA VALIDATION | PASS | Spawn:{_spawnLayouts.Count} | Center:{_centerLayouts.Count} | Standard:{standardCount} | Lobby:{lobbyCount} | HighNoon:{highNoonCount} | Warnings:{warnings}"
);
}
else
{
Log.Error(
$"ARENA VALIDATION | FAIL | Errors:{errors} | Warnings:{warnings}"
);
}
}
private bool ValidateRuntimeRequirements()
{
if (
SpawnASection is null ||
CenterSection is null ||
SpawnBSection is null
)
{
Log.Error(
"ARENA | Section anchor references are missing."
);
return false;
}
if (
_spawnLayouts.Count == 0 ||
_centerLayouts.Count == 0
)
{
Log.Error(
"ARENA | Spawn or Center prefab catalog is empty."
);
return false;
}
return true;
}
private void ValidateSectionAnchor(
string sectionName,
GameObject section,
ref int errors,
ref int warnings
)
{
if (
section is null ||
!section.IsValid
)
{
errors++;
Log.Error(
$"ARENA VALIDATION | {sectionName} section anchor is missing."
);
return;
}
// v2.1 expects authoring layouts to live only as prefab assets.
// Any ArenaLayout child already placed in main.scene is leftover v2.0
// content and would duplicate the runtime-instantiated copy.
foreach (
GameObject child in section.Children
)
{
if (
child is null ||
!child.IsValid
)
continue;
ArenaLayout metadata =
child.GetComponent<ArenaLayout>(
true
);
if ( metadata is null )
continue;
warnings++;
Log.Warning(
$"ARENA VALIDATION | {sectionName}/{child.Name} is a pre-placed ArenaLayout. Arena v2.1 expects section anchors to be empty in main.scene; remove this instance after adding its prefab to the Catalog."
);
}
}
private void ValidatePrefabCatalog(
string label,
IEnumerable<GameObject> prefabReferences,
ArenaLayoutSection expectedSection,
List<LayoutEntry> entries,
ref int errors,
ref int warnings
)
{
List<GameObject> refs =
prefabReferences?
.Where(
x =>
x is not null &&
x.IsValid
)
.ToList()
?? new List<GameObject>();
if ( refs.Count == 0 )
{
errors++;
Log.Error(
$"ARENA VALIDATION | {label} prefab catalog is empty."
);
return;
}
if ( entries.Count == 0 )
{
errors++;
Log.Error(
$"ARENA VALIDATION | {label} prefab catalog contains no valid ArenaLayout metadata."
);
return;
}
foreach (
GameObject prefab in refs
)
{
ArenaLayout metadata =
prefab.GetComponent<ArenaLayout>(
true
);
if ( metadata is null )
{
errors++;
Log.Error(
$"ARENA VALIDATION | {label} prefab reference '{prefab.Name}' has no ArenaLayout component on its root."
);
continue;
}
if (
metadata.Section != expectedSection
)
{
errors++;
Log.Error(
$"ARENA VALIDATION | {label}/{prefab.Name} is marked Section={metadata.Section}, expected {expectedSection}."
);
}
if ( metadata.LayoutId < 0 )
{
errors++;
Log.Error(
$"ARENA VALIDATION | {label}/{prefab.Name} uses negative LayoutId {metadata.LayoutId}."
);
}
if (
WarnAboutNonNormalizedLayoutRoots &&
!IsNormalizedLayoutRoot(
prefab
)
)
{
warnings++;
Log.Warning(
$"ARENA VALIDATION | {label}/{prefab.Name} prefab root is not normalized. Expected Local Position 0,0,0; Local Rotation identity; Local Scale 1,1,1."
);
}
}
foreach (
IGrouping<int, LayoutEntry> duplicateGroup in
entries.GroupBy(
x => x.Id
)
)
{
if (
duplicateGroup.Count() <= 1
)
continue;
errors++;
Log.Error(
$"ARENA VALIDATION | {label} catalog has duplicate LayoutId {duplicateGroup.Key}."
);
}
}
private bool IsNormalizedLayoutRoot(
GameObject root
)
{
if (
root is null ||
!root.IsValid
)
return false;
bool positionOkay =
root.LocalPosition.Distance(
Vector3.Zero
) <= 0.01f;
bool rotationOkay =
root.LocalRotation.Distance(
Rotation.Identity
) <= 0.01f;
bool scaleOkay =
root.LocalScale.Distance(
Vector3.One
) <= 0.01f;
return
positionOkay &&
rotationOkay &&
scaleOkay;
}
}