278 lines
11 KiB
C#
278 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace InfiniteWorld.VoxelWorld
|
|
{
|
|
public enum WorldPlacementMode : byte
|
|
{
|
|
GroundOnly,
|
|
FlattenTerrain
|
|
}
|
|
|
|
[CreateAssetMenu(menuName = "Infinite World/World Prefab Collection", fileName = "WorldPrefabCollection")]
|
|
public sealed class WorldPrefabCollection : ScriptableObject
|
|
{
|
|
[Tooltip("Maximum number of spawn points this collection may contribute to a single chunk.")]
|
|
[Min(0)] public int maxPlacementsPerChunk = 2;
|
|
[Tooltip("How many deterministic candidate positions are tested for each spawn slot before it is skipped.")]
|
|
[Min(1)] public int attemptsPerPlacement = 8;
|
|
[Tooltip("How many cells near the chunk border are reserved so large prefabs do not clip outside the chunk.")]
|
|
[Min(0)] public int chunkEdgePadding = 1;
|
|
public List<WorldPrefabEntry> entries = new List<WorldPrefabEntry>();
|
|
|
|
public IReadOnlyList<WorldPrefabEntry> Entries => entries;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class WorldPrefabEntry
|
|
{
|
|
[Tooltip("Stable logical identifier for this entry. Use it later for save data, analytics, or seeded config generation.")]
|
|
public string id = "entry";
|
|
|
|
[Tooltip("Prefab that will be instantiated when this entry wins placement generation.")]
|
|
public GameObject prefab;
|
|
|
|
[Tooltip("Relative weight inside the collection. Higher values make this entry more likely to be selected compared to others.")]
|
|
[Min(0f)] public float weight = 1f;
|
|
|
|
[Tooltip("Percent chance from 0 to 100 that the selected entry will actually spawn in its slot after being chosen by weight.")]
|
|
[Range(0f, 100f)] public float spawnChancePercent = 100f;
|
|
|
|
[Tooltip("GroundOnly requires an already flat area. FlattenTerrain carves the area to ground level and ensures an approach path.")]
|
|
public WorldPlacementMode placementMode;
|
|
|
|
[Tooltip("Required placement size in world cells. Rotations may swap X and Y if enabled.")]
|
|
public Vector2Int footprint = Vector2Int.one;
|
|
|
|
[Tooltip("Extra reserved cells around the footprint so other generated placements do not overlap too closely.")]
|
|
[Min(0)] public int clearance;
|
|
|
|
[Tooltip("Extra cells around the footprint that will also be flattened when using FlattenTerrain.")]
|
|
[Min(0)] public int flattenPadding = 1;
|
|
|
|
[Tooltip("Maximum search radius in cells for finding existing ground and cutting an access corridor to the flattened area.")]
|
|
[Min(1)] public int flattenSearchRadius = 6;
|
|
|
|
[Tooltip("Allows deterministic 90-degree rotations so the same prefab can fit in more layout variations.")]
|
|
public bool allowRotations = true;
|
|
}
|
|
|
|
internal sealed class WorldPrefabCollectionRuntime
|
|
{
|
|
public WorldPrefabCollectionRuntime(int sourceIndex, int maxPlacementsPerChunk, int attemptsPerPlacement, int chunkEdgePadding, IReadOnlyList<WorldPrefabEntryRuntime> entries)
|
|
{
|
|
SourceIndex = sourceIndex;
|
|
MaxPlacementsPerChunk = maxPlacementsPerChunk;
|
|
AttemptsPerPlacement = attemptsPerPlacement;
|
|
ChunkEdgePadding = chunkEdgePadding;
|
|
Entries = entries ?? Array.Empty<WorldPrefabEntryRuntime>();
|
|
}
|
|
|
|
public int SourceIndex { get; }
|
|
public int MaxPlacementsPerChunk { get; }
|
|
public int AttemptsPerPlacement { get; }
|
|
public int ChunkEdgePadding { get; }
|
|
public IReadOnlyList<WorldPrefabEntryRuntime> Entries { get; }
|
|
}
|
|
|
|
internal sealed class WorldPrefabEntryRuntime
|
|
{
|
|
public WorldPrefabEntryRuntime(int sourceIndex, string id, float weight, float spawnChancePercent, WorldPlacementMode placementMode, Vector2Int footprint, int clearance, int flattenPadding, int flattenSearchRadius, bool allowRotations, bool hasPrefab)
|
|
{
|
|
SourceIndex = sourceIndex;
|
|
Id = string.IsNullOrWhiteSpace(id) ? $"entry_{sourceIndex}" : id;
|
|
Weight = Mathf.Max(0f, weight);
|
|
SpawnChancePercent = Mathf.Clamp(spawnChancePercent, 0f, 100f);
|
|
PlacementMode = placementMode;
|
|
Footprint = new Vector2Int(Mathf.Max(1, footprint.x), Mathf.Max(1, footprint.y));
|
|
Clearance = Mathf.Max(0, clearance);
|
|
FlattenPadding = Mathf.Max(0, flattenPadding);
|
|
FlattenSearchRadius = Mathf.Max(1, flattenSearchRadius);
|
|
AllowRotations = allowRotations;
|
|
HasPrefab = hasPrefab;
|
|
}
|
|
|
|
public int SourceIndex { get; }
|
|
public string Id { get; }
|
|
public float Weight { get; }
|
|
public float SpawnChancePercent { get; }
|
|
public WorldPlacementMode PlacementMode { get; }
|
|
public Vector2Int Footprint { get; }
|
|
public int Clearance { get; }
|
|
public int FlattenPadding { get; }
|
|
public int FlattenSearchRadius { get; }
|
|
public bool AllowRotations { 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
|
|
{
|
|
private readonly List<Vector2Int> flattenedCells;
|
|
|
|
public WorldTerrainPatch(IReadOnlyList<Vector2Int> cells)
|
|
{
|
|
flattenedCells = cells != null ? new List<Vector2Int>(cells) : new List<Vector2Int>();
|
|
}
|
|
|
|
public IReadOnlyList<Vector2Int> FlattenedCells => flattenedCells;
|
|
}
|
|
|
|
public readonly struct WorldSpawnPoint
|
|
{
|
|
public WorldSpawnPoint(long spawnId, Vector2Int chunkCoord, int spawnOrdinalInChunk, int collectionIndex, int entryIndex, string entryId, Vector3 position, Quaternion rotation)
|
|
{
|
|
SpawnId = spawnId;
|
|
ChunkCoord = chunkCoord;
|
|
SpawnOrdinalInChunk = spawnOrdinalInChunk;
|
|
CollectionIndex = collectionIndex;
|
|
EntryIndex = entryIndex;
|
|
EntryId = entryId;
|
|
Position = position;
|
|
Rotation = rotation;
|
|
}
|
|
|
|
public long SpawnId { get; }
|
|
public Vector2Int ChunkCoord { get; }
|
|
public int SpawnOrdinalInChunk { get; }
|
|
public int CollectionIndex { get; }
|
|
public int EntryIndex { get; }
|
|
public string EntryId { get; }
|
|
public Vector3 Position { get; }
|
|
public Quaternion Rotation { get; }
|
|
}
|
|
|
|
public sealed class WorldChunkPlacementPlan
|
|
{
|
|
private static readonly IReadOnlyList<WorldSpawnPoint> EmptySpawnPoints = Array.Empty<WorldSpawnPoint>();
|
|
private static readonly IReadOnlyList<WorldTerrainPatch> EmptyTerrainPatches = Array.Empty<WorldTerrainPatch>();
|
|
|
|
private readonly IReadOnlyList<WorldSpawnPoint> spawnPoints;
|
|
private readonly IReadOnlyList<WorldTerrainPatch> terrainPatches;
|
|
private readonly HashSet<Vector2Int> flattenedCells;
|
|
|
|
public static WorldChunkPlacementPlan Empty { get; } = new WorldChunkPlacementPlan(EmptySpawnPoints, EmptyTerrainPatches, null);
|
|
|
|
public WorldChunkPlacementPlan(IReadOnlyList<WorldSpawnPoint> spawnPoints, IReadOnlyList<WorldTerrainPatch> terrainPatches, HashSet<Vector2Int> flattenedCells)
|
|
{
|
|
this.spawnPoints = spawnPoints ?? EmptySpawnPoints;
|
|
this.terrainPatches = terrainPatches ?? EmptyTerrainPatches;
|
|
this.flattenedCells = flattenedCells;
|
|
}
|
|
|
|
public IReadOnlyList<WorldSpawnPoint> SpawnPoints => spawnPoints;
|
|
public IReadOnlyList<WorldTerrainPatch> TerrainPatches => terrainPatches;
|
|
public bool HasFlattening => flattenedCells != null && flattenedCells.Count > 0;
|
|
|
|
public int GetFinalHeight(Vector2Int worldCell, int baseHeight)
|
|
{
|
|
return flattenedCells != null && flattenedCells.Contains(worldCell) ? 0 : baseHeight;
|
|
}
|
|
}
|
|
}
|