[Add] Asset Validator
This commit is contained in:
@@ -19,20 +19,10 @@ MonoBehaviour:
|
|||||||
- id: EntranceCrypt
|
- id: EntranceCrypt
|
||||||
prefab: {fileID: 155468, guid: ea0c7071e67bf1c43940a8ab3ea121f8, type: 3}
|
prefab: {fileID: 155468, guid: ea0c7071e67bf1c43940a8ab3ea121f8, type: 3}
|
||||||
weight: 10
|
weight: 10
|
||||||
spawnChancePercent: 100
|
spawnChancePercent: 15
|
||||||
placementMode: 1
|
placementMode: 1
|
||||||
footprint: {x: 2, y: 2}
|
footprint: {x: 2, y: 2}
|
||||||
clearance: 4
|
clearance: 4
|
||||||
flattenPadding: 4
|
flattenPadding: 4
|
||||||
flattenSearchRadius: 4
|
flattenSearchRadius: 4
|
||||||
allowRotations: 1
|
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++)
|
for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++)
|
||||||
{
|
{
|
||||||
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
|
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
|
||||||
if (collection == null)
|
if (WorldPlacementValidation.TryGetCollectionBlockerReason(collection, out _))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<WorldPrefabEntryRuntime> entries = new List<WorldPrefabEntryRuntime>();
|
List<WorldPrefabEntryRuntime> entries = new List<WorldPrefabEntryRuntime>();
|
||||||
|
HashSet<string> usedIds = new HashSet<string>();
|
||||||
if (collection.entries != null)
|
if (collection.entries != null)
|
||||||
{
|
{
|
||||||
for (int entryIndex = 0; entryIndex < collection.entries.Count; entryIndex++)
|
for (int entryIndex = 0; entryIndex < collection.entries.Count; entryIndex++)
|
||||||
{
|
{
|
||||||
WorldPrefabEntry entry = collection.entries[entryIndex];
|
WorldPrefabEntry entry = collection.entries[entryIndex];
|
||||||
if (entry == null)
|
if (WorldPlacementValidation.TryGetEntryBlockerReason(entry, usedIds, out _))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
{
|
{
|
||||||
private readonly Dictionary<Vector2Int, WorldChunkPlacementPlan> chunkPlacementPlans = new Dictionary<Vector2Int, WorldChunkPlacementPlan>();
|
private readonly Dictionary<Vector2Int, WorldChunkPlacementPlan> chunkPlacementPlans = new Dictionary<Vector2Int, WorldChunkPlacementPlan>();
|
||||||
private readonly object placementPlanLock = new object();
|
private readonly object placementPlanLock = new object();
|
||||||
|
private int placementValidationSignature = int.MinValue;
|
||||||
private IReadOnlyList<WorldPrefabCollectionRuntime> placementCollections => settings.PlacementCollections;
|
private IReadOnlyList<WorldPrefabCollectionRuntime> placementCollections => settings.PlacementCollections;
|
||||||
|
|
||||||
public WorldChunkPlacementPlan GetChunkPlacementPlan(Vector2Int chunkCoord)
|
public WorldChunkPlacementPlan GetChunkPlacementPlan(Vector2Int chunkCoord)
|
||||||
@@ -88,5 +89,105 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
chunkPlacementPlans.Clear();
|
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()
|
private void EnsureRuntimeData()
|
||||||
{
|
{
|
||||||
settings = VoxelWorldResolvedSettings.Resolve(config);
|
settings = VoxelWorldResolvedSettings.Resolve(config);
|
||||||
|
ValidatePlacementCollectionsIfNeeded();
|
||||||
int configuredBiomeCount = CountConfiguredBiomes();
|
int configuredBiomeCount = CountConfiguredBiomes();
|
||||||
|
|
||||||
if (atlas != null && atlasBiomeCount == configuredBiomeCount)
|
if (atlas != null && atlasBiomeCount == configuredBiomeCount)
|
||||||
|
|||||||
@@ -106,6 +106,111 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
public bool HasPrefab { get; }
|
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
|
public sealed class WorldTerrainPatch
|
||||||
{
|
{
|
||||||
private readonly List<Vector2Int> flattenedCells;
|
private readonly List<Vector2Int> flattenedCells;
|
||||||
|
|||||||
Reference in New Issue
Block a user