From efce7c458de62d7b81989b4245a2e931eb1b9cfe Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Wed, 8 Apr 2026 13:18:26 +0700 Subject: [PATCH] [Add] Asset Validator --- .../Data/WorldPrefabCollection.asset | 12 +- .../VoxelWorld/Runtime/VoxelWorldConfig.cs | 5 +- .../Runtime/VoxelWorldGenerator.Placements.cs | 101 +++++++++++++++++ .../VoxelWorld/Runtime/VoxelWorldGenerator.cs | 1 + .../Runtime/WorldPrefabCollection.cs | 105 ++++++++++++++++++ 5 files changed, 211 insertions(+), 13 deletions(-) diff --git a/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset index 1c46d886..b1c7a448 100644 --- a/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset +++ b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset @@ -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 diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs index 99bb439b..5af18ffa 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs @@ -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 entries = new List(); + HashSet usedIds = new HashSet(); 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; } diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs index 89b02720..c16132d9 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs @@ -7,6 +7,7 @@ namespace InfiniteWorld.VoxelWorld { private readonly Dictionary chunkPlacementPlans = new Dictionary(); private readonly object placementPlanLock = new object(); + private int placementValidationSignature = int.MinValue; private IReadOnlyList 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 usedIds = new HashSet(); + 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); + } + } + } } } diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs index ac1591cc..b66fd783 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs @@ -131,6 +131,7 @@ namespace InfiniteWorld.VoxelWorld private void EnsureRuntimeData() { settings = VoxelWorldResolvedSettings.Resolve(config); + ValidatePlacementCollectionsIfNeeded(); int configuredBiomeCount = CountConfiguredBiomes(); if (atlas != null && atlasBiomeCount == configuredBiomeCount) diff --git a/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs index c7ba553c..d501cee7 100644 --- a/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs +++ b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs @@ -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 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 flattenedCells;