[Add] Asset Validator

This commit is contained in:
2026-04-08 13:18:26 +07:00
parent 211c975889
commit efce7c458d
5 changed files with 211 additions and 13 deletions
@@ -19,20 +19,10 @@ MonoBehaviour:
- id: EntranceCrypt
prefab: {fileID: 155468, guid: ea0c7071e67bf1c43940a8ab3ea121f8, type: 3}
weight: 10
spawnChancePercent: 100
spawnChancePercent: 15
placementMode: 1
footprint: {x: 2, y: 2}
clearance: 4
flattenPadding: 4
flattenSearchRadius: 4
allowRotations: 1
- id: GlowingOrb
prefab: {fileID: 190932, guid: 20e0bf298681fcd4693097c822277593, type: 3}
weight: 0
spawnChancePercent: 100
placementMode: 0
footprint: {x: 2, y: 2}
clearance: 4
flattenPadding: 4
flattenSearchRadius: 4
allowRotations: 1
@@ -199,18 +199,19 @@ namespace InfiniteWorld.VoxelWorld
for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++)
{
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
if (collection == null)
if (WorldPlacementValidation.TryGetCollectionBlockerReason(collection, out _))
{
continue;
}
List<WorldPrefabEntryRuntime> entries = new List<WorldPrefabEntryRuntime>();
HashSet<string> usedIds = new HashSet<string>();
if (collection.entries != null)
{
for (int entryIndex = 0; entryIndex < collection.entries.Count; entryIndex++)
{
WorldPrefabEntry entry = collection.entries[entryIndex];
if (entry == null)
if (WorldPlacementValidation.TryGetEntryBlockerReason(entry, usedIds, out _))
{
continue;
}
@@ -7,6 +7,7 @@ namespace InfiniteWorld.VoxelWorld
{
private readonly Dictionary<Vector2Int, WorldChunkPlacementPlan> chunkPlacementPlans = new Dictionary<Vector2Int, WorldChunkPlacementPlan>();
private readonly object placementPlanLock = new object();
private int placementValidationSignature = int.MinValue;
private IReadOnlyList<WorldPrefabCollectionRuntime> placementCollections => settings.PlacementCollections;
public WorldChunkPlacementPlan GetChunkPlacementPlan(Vector2Int chunkCoord)
@@ -88,5 +89,105 @@ namespace InfiniteWorld.VoxelWorld
chunkPlacementPlans.Clear();
}
}
private void ValidatePlacementCollectionsIfNeeded()
{
int signature = ComputePlacementValidationSignature();
if (signature == placementValidationSignature)
{
return;
}
placementValidationSignature = signature;
ValidatePlacementCollections();
}
private int ComputePlacementValidationSignature()
{
unchecked
{
int hash = 17;
hash = hash * 31 + (config != null ? config.GetInstanceID() : 0);
if (config == null || config.placementCollections == null)
{
return hash;
}
hash = hash * 31 + config.placementCollections.Count;
for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++)
{
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
hash = hash * 31 + (collection != null ? collection.GetInstanceID() : 0);
if (collection == null)
{
continue;
}
hash = hash * 31 + collection.maxPlacementsPerChunk;
hash = hash * 31 + collection.attemptsPerPlacement;
hash = hash * 31 + collection.chunkEdgePadding;
hash = hash * 31 + (collection.entries != null ? collection.entries.Count : 0);
if (collection.entries == null)
{
continue;
}
for (int entryIndex = 0; entryIndex < collection.entries.Count; entryIndex++)
{
WorldPrefabEntry entry = collection.entries[entryIndex];
hash = hash * 31 + (entry != null ? entry.id?.GetHashCode() ?? 0 : 0);
if (entry == null)
{
continue;
}
hash = hash * 31 + (entry.prefab != null ? entry.prefab.GetInstanceID() : 0);
hash = hash * 31 + entry.weight.GetHashCode();
hash = hash * 31 + entry.spawnChancePercent.GetHashCode();
hash = hash * 31 + (int)entry.placementMode;
hash = hash * 31 + entry.footprint.x;
hash = hash * 31 + entry.footprint.y;
hash = hash * 31 + entry.clearance;
hash = hash * 31 + entry.flattenPadding;
hash = hash * 31 + entry.flattenSearchRadius;
hash = hash * 31 + (entry.allowRotations ? 1 : 0);
}
}
return hash;
}
}
private void ValidatePlacementCollections()
{
if (config == null || config.placementCollections == null || config.placementCollections.Count == 0)
{
return;
}
for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++)
{
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
string collectionName = collection != null ? collection.name : $"Collection[{collectionIndex}]";
if (WorldPlacementValidation.TryGetCollectionBlockerReason(collection, out string collectionReason))
{
Debug.LogError($"[VoxelWorld] Collection '{collectionName}' will not spawn: {collectionReason}", collection != null ? collection : this);
continue;
}
HashSet<string> usedIds = new HashSet<string>();
for (int entryIndex = 0; entryIndex < collection.entries.Count; entryIndex++)
{
WorldPrefabEntry entry = collection.entries[entryIndex];
string entryId = entry != null && !string.IsNullOrWhiteSpace(entry.id) ? entry.id : $"entry #{entryIndex}";
if (!WorldPlacementValidation.TryGetEntryBlockerReason(entry, usedIds, out string entryReason))
{
continue;
}
Debug.LogError($"[VoxelWorld] Collection '{collectionName}' entry #{entryIndex} ('{entryId}') will not spawn: {entryReason}", collection);
}
}
}
}
}
@@ -131,6 +131,7 @@ namespace InfiniteWorld.VoxelWorld
private void EnsureRuntimeData()
{
settings = VoxelWorldResolvedSettings.Resolve(config);
ValidatePlacementCollectionsIfNeeded();
int configuredBiomeCount = CountConfiguredBiomes();
if (atlas != null && atlasBiomeCount == configuredBiomeCount)
@@ -106,6 +106,111 @@ namespace InfiniteWorld.VoxelWorld
public bool HasPrefab { get; }
}
internal static class WorldPlacementValidation
{
public static bool TryGetCollectionBlockerReason(WorldPrefabCollection collection, out string reason)
{
if (collection == null)
{
reason = "collection reference is missing.";
return true;
}
if (collection.maxPlacementsPerChunk <= 0)
{
reason = "max placements per chunk must be greater than 0.";
return true;
}
if (collection.attemptsPerPlacement < 1)
{
reason = "attempts per placement must be at least 1.";
return true;
}
if (collection.chunkEdgePadding < 0)
{
reason = "chunk edge padding cannot be negative.";
return true;
}
if (collection.entries == null || collection.entries.Count == 0)
{
reason = "collection has no entries configured.";
return true;
}
reason = null;
return false;
}
public static bool TryGetEntryBlockerReason(WorldPrefabEntry entry, HashSet<string> usedIds, out string reason)
{
if (entry == null)
{
reason = "entry reference is missing.";
return true;
}
if (string.IsNullOrWhiteSpace(entry.id))
{
reason = "id is empty. A stable id is required for deterministic spawns and save data.";
return true;
}
if (usedIds != null && !usedIds.Add(entry.id))
{
reason = $"duplicate id '{entry.id}' in the same collection.";
return true;
}
if (entry.prefab == null)
{
reason = "prefab is missing.";
return true;
}
if (entry.weight <= 0f)
{
reason = "weight must be greater than 0.";
return true;
}
if (entry.spawnChancePercent <= 0f)
{
reason = "spawn chance must be greater than 0%.";
return true;
}
if (entry.footprint.x <= 0 || entry.footprint.y <= 0)
{
reason = "footprint must be at least 1x1.";
return true;
}
if (entry.clearance < 0)
{
reason = "clearance cannot be negative.";
return true;
}
if (entry.flattenPadding < 0)
{
reason = "flatten padding cannot be negative.";
return true;
}
if (entry.placementMode == WorldPlacementMode.FlattenTerrain && entry.flattenSearchRadius < 1)
{
reason = "flatten search radius must be at least 1 for FlattenTerrain mode.";
return true;
}
reason = null;
return false;
}
}
public sealed class WorldTerrainPatch
{
private readonly List<Vector2Int> flattenedCells;