Добавить детерминированное размещение dungeon prefab в voxel-мире через stamp/carve в данных чанков. #6
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2657f564ee47c514db4e1cada2770fbc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -36,6 +36,8 @@ MonoBehaviour:
|
|||||||
- {fileID: 11400000, guid: 6d0dbe510ed048440a3925ef40aeeb5b, type: 2}
|
- {fileID: 11400000, guid: 6d0dbe510ed048440a3925ef40aeeb5b, type: 2}
|
||||||
biomeNoiseScale: 0.02
|
biomeNoiseScale: 0.02
|
||||||
biomeSize: 6
|
biomeSize: 6
|
||||||
|
placementCollections:
|
||||||
|
- {fileID: 11400000, guid: b91d23f483c774f4dbb1a77660881d87, type: 2}
|
||||||
maxAsyncChunkJobs: 2
|
maxAsyncChunkJobs: 2
|
||||||
maxChunkBuildsPerFrame: 1
|
maxChunkBuildsPerFrame: 1
|
||||||
maxChunkMeshBuildsPerFrame: 1
|
maxChunkMeshBuildsPerFrame: 1
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 0f36445faa7413742b67196646eb2631, type: 3}
|
||||||
|
m_Name: WorldPrefabCollection
|
||||||
|
m_EditorClassIdentifier: VoxelWorld.Runtime::InfiniteWorld.VoxelWorld.WorldPrefabCollection
|
||||||
|
maxPlacementsPerChunk: 2
|
||||||
|
attemptsPerPlacement: 8
|
||||||
|
chunkEdgePadding: 1
|
||||||
|
entries:
|
||||||
|
- id: EntranceCrypt
|
||||||
|
prefab: {fileID: 155468, guid: ea0c7071e67bf1c43940a8ab3ea121f8, type: 3}
|
||||||
|
weight: 10
|
||||||
|
spawnChancePercent: 5
|
||||||
|
placementMode: 1
|
||||||
|
footprint: {x: 2, y: 2}
|
||||||
|
clearance: 4
|
||||||
|
flattenPadding: 4
|
||||||
|
flattenSearchRadius: 4
|
||||||
|
allowRotations: 1
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b91d23f483c774f4dbb1a77660881d87
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -444,7 +444,12 @@ MonoBehaviour:
|
|||||||
_addedNetworkObject: {fileID: 6486868354670318784}
|
_addedNetworkObject: {fileID: 6486868354670318784}
|
||||||
_networkObjectCache: {fileID: 6486868354670318784}
|
_networkObjectCache: {fileID: 6486868354670318784}
|
||||||
_moveSpeed: 30
|
_moveSpeed: 30
|
||||||
|
_sprintMultiplier: 2
|
||||||
|
_slowMultiplier: 0.5
|
||||||
_characterController: {fileID: 9116505237391369033}
|
_characterController: {fileID: 9116505237391369033}
|
||||||
|
_moveAction: {fileID: -1680190386980627800, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}
|
||||||
|
_sprintAction: {fileID: -7471211009204490832, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}
|
||||||
|
_slowAction: {fileID: 7040659881675835329, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}
|
||||||
--- !u!114 &5771682633975047943
|
--- !u!114 &5771682633975047943
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
public float biomeNoiseScale = 0.02f;
|
public float biomeNoiseScale = 0.02f;
|
||||||
[Min(1f)] public float biomeSize = 48f;
|
[Min(1f)] public float biomeSize = 48f;
|
||||||
|
|
||||||
|
[Header("Placements")]
|
||||||
|
public List<WorldPrefabCollection> placementCollections = new List<WorldPrefabCollection>();
|
||||||
|
|
||||||
[Header("Runtime")]
|
[Header("Runtime")]
|
||||||
[Min(1)] public int maxAsyncChunkJobs = 2;
|
[Min(1)] public int maxAsyncChunkJobs = 2;
|
||||||
[Min(1)] public int maxChunkBuildsPerFrame = 1;
|
[Min(1)] public int maxChunkBuildsPerFrame = 1;
|
||||||
@@ -50,6 +53,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
internal readonly struct VoxelWorldResolvedSettings
|
internal readonly struct VoxelWorldResolvedSettings
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyList<VoxelBiomeProfile> EmptyBiomes = System.Array.Empty<VoxelBiomeProfile>();
|
private static readonly IReadOnlyList<VoxelBiomeProfile> EmptyBiomes = System.Array.Empty<VoxelBiomeProfile>();
|
||||||
|
private static readonly IReadOnlyList<WorldPrefabCollectionRuntime> EmptyPlacementCollections = System.Array.Empty<WorldPrefabCollectionRuntime>();
|
||||||
|
|
||||||
public static readonly VoxelWorldResolvedSettings Default = Resolve(null);
|
public static readonly VoxelWorldResolvedSettings Default = Resolve(null);
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
IReadOnlyList<VoxelBiomeProfile> biomeProfiles,
|
IReadOnlyList<VoxelBiomeProfile> biomeProfiles,
|
||||||
float biomeNoiseScale,
|
float biomeNoiseScale,
|
||||||
float biomeSize,
|
float biomeSize,
|
||||||
|
IReadOnlyList<WorldPrefabCollectionRuntime> placementCollections,
|
||||||
int maxAsyncChunkJobs,
|
int maxAsyncChunkJobs,
|
||||||
int maxChunkBuildsPerFrame,
|
int maxChunkBuildsPerFrame,
|
||||||
int maxChunkMeshBuildsPerFrame,
|
int maxChunkMeshBuildsPerFrame,
|
||||||
@@ -104,6 +109,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
BiomeProfiles = biomeProfiles;
|
BiomeProfiles = biomeProfiles;
|
||||||
BiomeNoiseScale = biomeNoiseScale;
|
BiomeNoiseScale = biomeNoiseScale;
|
||||||
BiomeSize = biomeSize;
|
BiomeSize = biomeSize;
|
||||||
|
PlacementCollections = placementCollections;
|
||||||
MaxAsyncChunkJobs = maxAsyncChunkJobs;
|
MaxAsyncChunkJobs = maxAsyncChunkJobs;
|
||||||
MaxChunkBuildsPerFrame = maxChunkBuildsPerFrame;
|
MaxChunkBuildsPerFrame = maxChunkBuildsPerFrame;
|
||||||
MaxChunkMeshBuildsPerFrame = maxChunkMeshBuildsPerFrame;
|
MaxChunkMeshBuildsPerFrame = maxChunkMeshBuildsPerFrame;
|
||||||
@@ -134,6 +140,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
public IReadOnlyList<VoxelBiomeProfile> BiomeProfiles { get; }
|
public IReadOnlyList<VoxelBiomeProfile> BiomeProfiles { get; }
|
||||||
public float BiomeNoiseScale { get; }
|
public float BiomeNoiseScale { get; }
|
||||||
public float BiomeSize { get; }
|
public float BiomeSize { get; }
|
||||||
|
public IReadOnlyList<WorldPrefabCollectionRuntime> PlacementCollections { get; }
|
||||||
public int MaxAsyncChunkJobs { get; }
|
public int MaxAsyncChunkJobs { get; }
|
||||||
public int MaxChunkBuildsPerFrame { get; }
|
public int MaxChunkBuildsPerFrame { get; }
|
||||||
public int MaxChunkMeshBuildsPerFrame { get; }
|
public int MaxChunkMeshBuildsPerFrame { get; }
|
||||||
@@ -147,6 +154,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
IReadOnlyList<VoxelBiomeProfile> biomes = config != null && config.biomeProfiles != null
|
IReadOnlyList<VoxelBiomeProfile> biomes = config != null && config.biomeProfiles != null
|
||||||
? config.biomeProfiles
|
? config.biomeProfiles
|
||||||
: EmptyBiomes;
|
: EmptyBiomes;
|
||||||
|
IReadOnlyList<WorldPrefabCollectionRuntime> placements = ResolvePlacementCollections(config);
|
||||||
|
|
||||||
return new VoxelWorldResolvedSettings(
|
return new VoxelWorldResolvedSettings(
|
||||||
Mathf.Max(8, config != null ? config.chunkSize : 16),
|
Mathf.Max(8, config != null ? config.chunkSize : 16),
|
||||||
@@ -170,6 +178,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
biomes,
|
biomes,
|
||||||
config != null ? config.biomeNoiseScale : 0.02f,
|
config != null ? config.biomeNoiseScale : 0.02f,
|
||||||
Mathf.Max(1f, config != null ? config.biomeSize : 48f),
|
Mathf.Max(1f, config != null ? config.biomeSize : 48f),
|
||||||
|
placements,
|
||||||
Mathf.Max(1, config != null ? config.maxAsyncChunkJobs : 2),
|
Mathf.Max(1, config != null ? config.maxAsyncChunkJobs : 2),
|
||||||
Mathf.Max(1, config != null ? config.maxChunkBuildsPerFrame : 1),
|
Mathf.Max(1, config != null ? config.maxChunkBuildsPerFrame : 1),
|
||||||
Mathf.Max(1, config != null ? config.maxChunkMeshBuildsPerFrame : 1),
|
Mathf.Max(1, config != null ? config.maxChunkMeshBuildsPerFrame : 1),
|
||||||
@@ -178,5 +187,59 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
Mathf.Max(1, config != null ? config.renderRegionSizeInChunks : 4),
|
Mathf.Max(1, config != null ? config.renderRegionSizeInChunks : 4),
|
||||||
Mathf.Max(1, config != null ? config.maxRegionBuildsPerFrame : 1));
|
Mathf.Max(1, config != null ? config.maxRegionBuildsPerFrame : 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<WorldPrefabCollectionRuntime> ResolvePlacementCollections(VoxelWorldConfig config)
|
||||||
|
{
|
||||||
|
if (config == null || config.placementCollections == null || config.placementCollections.Count == 0)
|
||||||
|
{
|
||||||
|
return EmptyPlacementCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WorldPrefabCollectionRuntime> result = new List<WorldPrefabCollectionRuntime>(config.placementCollections.Count);
|
||||||
|
for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++)
|
||||||
|
{
|
||||||
|
WorldPrefabCollection collection = config.placementCollections[collectionIndex];
|
||||||
|
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 (WorldPlacementValidation.TryGetEntryBlockerReason(entry, usedIds, out _))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new WorldPrefabEntryRuntime(
|
||||||
|
entryIndex,
|
||||||
|
entry.id,
|
||||||
|
entry.weight,
|
||||||
|
entry.spawnChancePercent,
|
||||||
|
entry.placementMode,
|
||||||
|
entry.footprint,
|
||||||
|
entry.clearance,
|
||||||
|
entry.flattenPadding,
|
||||||
|
entry.flattenSearchRadius,
|
||||||
|
entry.allowRotations,
|
||||||
|
entry.prefab != null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new WorldPrefabCollectionRuntime(
|
||||||
|
collectionIndex,
|
||||||
|
Mathf.Max(0, collection.maxPlacementsPerChunk),
|
||||||
|
Mathf.Max(1, collection.attemptsPerPlacement),
|
||||||
|
Mathf.Max(0, collection.chunkEdgePadding),
|
||||||
|
entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Count > 0 ? result : EmptyPlacementCollections;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyChunkPlacementHeights(coord, heights);
|
||||||
|
|
||||||
return new ChunkBuildResult(coord, heights, biomeIndices, version, session, runtimeId);
|
return new ChunkBuildResult(coord, heights, biomeIndices, version, session, runtimeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -435,14 +435,14 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
|
|
||||||
if (!chunks.TryGetValue(coord, out ChunkRuntime runtime) || !runtime.HasData)
|
if (!chunks.TryGetValue(coord, out ChunkRuntime runtime) || !runtime.HasData)
|
||||||
{
|
{
|
||||||
return SampleRock(worldCell) ? SampleHeight(worldCell) : 0;
|
return GetSampledFinalHeightAtWorldCell(worldCell);
|
||||||
}
|
}
|
||||||
|
|
||||||
int localX = worldCell.x - coord.x * chunkSize;
|
int localX = worldCell.x - coord.x * chunkSize;
|
||||||
int localZ = worldCell.y - coord.y * chunkSize;
|
int localZ = worldCell.y - coord.y * chunkSize;
|
||||||
if (localX < 0 || localZ < 0 || localX >= chunkSize || localZ >= chunkSize)
|
if (localX < 0 || localZ < 0 || localX >= chunkSize || localZ >= chunkSize)
|
||||||
{
|
{
|
||||||
return SampleRock(worldCell) ? SampleHeight(worldCell) : 0;
|
return GetSampledFinalHeightAtWorldCell(worldCell);
|
||||||
}
|
}
|
||||||
|
|
||||||
return runtime.Heights[localZ * chunkSize + localX];
|
return runtime.Heights[localZ * chunkSize + localX];
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace InfiniteWorld.VoxelWorld
|
||||||
|
{
|
||||||
|
public sealed partial class VoxelWorldGenerator
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return GetOrCreateChunkPlacementPlan(chunkCoord);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetBaseHeightAtWorldCell(Vector2Int worldCell)
|
||||||
|
{
|
||||||
|
return SampleRock(worldCell) ? SampleHeight(worldCell) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetFinalHeightAtWorldCell(Vector2Int worldCell)
|
||||||
|
{
|
||||||
|
return GetSampledFinalHeightAtWorldCell(worldCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte GetBiomeIndexAtWorldCell(Vector2Int worldCell)
|
||||||
|
{
|
||||||
|
return SampleBiomeIndex(worldCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorldChunkPlacementPlan GetOrCreateChunkPlacementPlan(Vector2Int chunkCoord)
|
||||||
|
{
|
||||||
|
lock (placementPlanLock)
|
||||||
|
{
|
||||||
|
if (chunkPlacementPlans.TryGetValue(chunkCoord, out WorldChunkPlacementPlan cachedPlan))
|
||||||
|
{
|
||||||
|
return cachedPlan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldChunkPlacementPlan plan = WorldSpawnPlanner.PlanChunk(seed, chunkCoord, chunkSize, placementCollections, GetBaseHeightAtWorldCell);
|
||||||
|
|
||||||
|
lock (placementPlanLock)
|
||||||
|
{
|
||||||
|
if (chunkPlacementPlans.TryGetValue(chunkCoord, out WorldChunkPlacementPlan cachedPlan))
|
||||||
|
{
|
||||||
|
return cachedPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkPlacementPlans[chunkCoord] = plan;
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetSampledFinalHeightAtWorldCell(Vector2Int worldCell)
|
||||||
|
{
|
||||||
|
int baseHeight = GetBaseHeightAtWorldCell(worldCell);
|
||||||
|
Vector2Int chunkCoord = new Vector2Int(
|
||||||
|
Mathf.FloorToInt(worldCell.x / (float)chunkSize),
|
||||||
|
Mathf.FloorToInt(worldCell.y / (float)chunkSize));
|
||||||
|
return GetOrCreateChunkPlacementPlan(chunkCoord).GetFinalHeight(worldCell, baseHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyChunkPlacementHeights(Vector2Int chunkCoord, int[] heights)
|
||||||
|
{
|
||||||
|
WorldChunkPlacementPlan plan = GetOrCreateChunkPlacementPlan(chunkCoord);
|
||||||
|
if (!plan.HasFlattening)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2Int chunkOrigin = new Vector2Int(chunkCoord.x * chunkSize, chunkCoord.y * chunkSize);
|
||||||
|
for (int z = 0; z < chunkSize; z++)
|
||||||
|
{
|
||||||
|
for (int x = 0; x < chunkSize; x++)
|
||||||
|
{
|
||||||
|
Vector2Int worldCell = new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + z);
|
||||||
|
heights[z * chunkSize + x] = plan.GetFinalHeight(worldCell, heights[z * chunkSize + x]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupPlacementPlans()
|
||||||
|
{
|
||||||
|
lock (placementPlanLock)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aa5a2f44cc77fe844a2f0b14725a6dd8
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace InfiniteWorld.VoxelWorld
|
||||||
|
{
|
||||||
|
public sealed partial class VoxelWorldGenerator
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Vector2Int, List<SpawnedPlacementRuntime>> spawnedPlacementsByChunk = new Dictionary<Vector2Int, List<SpawnedPlacementRuntime>>();
|
||||||
|
private readonly Dictionary<long, SpawnedPlacementRuntime> spawnedPlacementsById = new Dictionary<long, SpawnedPlacementRuntime>();
|
||||||
|
private readonly HashSet<long> disabledSpawnIds = new HashSet<long>();
|
||||||
|
|
||||||
|
private Transform placementRoot;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<long> DisabledSpawnIds => disabledSpawnIds;
|
||||||
|
|
||||||
|
public bool IsSpawnDisabled(long spawnId)
|
||||||
|
{
|
||||||
|
return disabledSpawnIds.Contains(spawnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DisableSpawn(long spawnId)
|
||||||
|
{
|
||||||
|
if (!disabledSpawnIds.Add(spawnId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnedPlacementsById.TryGetValue(spawnId, out SpawnedPlacementRuntime runtime))
|
||||||
|
{
|
||||||
|
RemoveSpawnedPlacement(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EnableSpawn(long spawnId)
|
||||||
|
{
|
||||||
|
return disabledSpawnIds.Remove(spawnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearDisabledSpawns()
|
||||||
|
{
|
||||||
|
disabledSpawnIds.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsurePlacementRoot()
|
||||||
|
{
|
||||||
|
if (placementRoot != null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Transform existing = transform.Find("WorldPlacements");
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
placementRoot = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject root = new GameObject("WorldPlacements");
|
||||||
|
root.transform.SetParent(transform, false);
|
||||||
|
placementRoot = root.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrySpawnChunkPlacements(Vector2Int chunkCoord)
|
||||||
|
{
|
||||||
|
EnsurePlacementRoot();
|
||||||
|
if (placementRoot == null || spawnedPlacementsByChunk.ContainsKey(chunkCoord))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldChunkPlacementPlan plan = GetChunkPlacementPlan(chunkCoord);
|
||||||
|
IReadOnlyList<WorldSpawnPoint> spawnPoints = plan.SpawnPoints;
|
||||||
|
if (spawnPoints == null || spawnPoints.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SpawnedPlacementRuntime> chunkPlacements = new List<SpawnedPlacementRuntime>(spawnPoints.Count);
|
||||||
|
for (int i = 0; i < spawnPoints.Count; i++)
|
||||||
|
{
|
||||||
|
WorldSpawnPoint spawnPoint = spawnPoints[i];
|
||||||
|
if (disabledSpawnIds.Contains(spawnPoint.SpawnId) || spawnedPlacementsById.ContainsKey(spawnPoint.SpawnId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryResolveSpawnPrefab(spawnPoint, out GameObject prefab) || prefab == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameObject instance = Object.Instantiate(prefab, spawnPoint.Position, spawnPoint.Rotation, placementRoot);
|
||||||
|
instance.name = $"{prefab.name}_{spawnPoint.ChunkCoord.x}_{spawnPoint.ChunkCoord.y}_{spawnPoint.SpawnOrdinalInChunk}";
|
||||||
|
SpawnedPlacementRuntime runtime = new SpawnedPlacementRuntime(spawnPoint.SpawnId, chunkCoord, spawnPoint.CollectionIndex, spawnPoint.EntryIndex, instance);
|
||||||
|
chunkPlacements.Add(runtime);
|
||||||
|
spawnedPlacementsById.Add(runtime.SpawnId, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkPlacements.Count > 0)
|
||||||
|
{
|
||||||
|
spawnedPlacementsByChunk[chunkCoord] = chunkPlacements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryResolveSpawnPrefab(WorldSpawnPoint spawnPoint, out GameObject prefab)
|
||||||
|
{
|
||||||
|
prefab = null;
|
||||||
|
if (config == null || config.placementCollections == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnPoint.CollectionIndex < 0 || spawnPoint.CollectionIndex >= config.placementCollections.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldPrefabCollection collection = config.placementCollections[spawnPoint.CollectionIndex];
|
||||||
|
if (collection == null || collection.entries == null || spawnPoint.EntryIndex < 0 || spawnPoint.EntryIndex >= collection.entries.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldPrefabEntry entry = collection.entries[spawnPoint.EntryIndex];
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
prefab = entry.prefab;
|
||||||
|
return prefab != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DespawnChunkPlacements(Vector2Int chunkCoord)
|
||||||
|
{
|
||||||
|
if (!spawnedPlacementsByChunk.TryGetValue(chunkCoord, out List<SpawnedPlacementRuntime> chunkPlacements))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < chunkPlacements.Count; i++)
|
||||||
|
{
|
||||||
|
SpawnedPlacementRuntime runtime = chunkPlacements[i];
|
||||||
|
spawnedPlacementsById.Remove(runtime.SpawnId);
|
||||||
|
DestroyPlacementInstance(runtime.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnedPlacementsByChunk.Remove(chunkCoord);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSpawnedPlacement(SpawnedPlacementRuntime runtime)
|
||||||
|
{
|
||||||
|
spawnedPlacementsById.Remove(runtime.SpawnId);
|
||||||
|
if (spawnedPlacementsByChunk.TryGetValue(runtime.ChunkCoord, out List<SpawnedPlacementRuntime> chunkPlacements))
|
||||||
|
{
|
||||||
|
chunkPlacements.RemoveAll(item => item.SpawnId == runtime.SpawnId);
|
||||||
|
if (chunkPlacements.Count == 0)
|
||||||
|
{
|
||||||
|
spawnedPlacementsByChunk.Remove(runtime.ChunkCoord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DestroyPlacementInstance(runtime.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupSpawnedPlacements()
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<Vector2Int, List<SpawnedPlacementRuntime>> pair in spawnedPlacementsByChunk)
|
||||||
|
{
|
||||||
|
List<SpawnedPlacementRuntime> chunkPlacements = pair.Value;
|
||||||
|
for (int i = 0; i < chunkPlacements.Count; i++)
|
||||||
|
{
|
||||||
|
DestroyPlacementInstance(chunkPlacements[i].Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnedPlacementsByChunk.Clear();
|
||||||
|
spawnedPlacementsById.Clear();
|
||||||
|
|
||||||
|
if (placementRoot != null)
|
||||||
|
{
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
Object.Destroy(placementRoot.gameObject);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(placementRoot.gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
placementRoot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DestroyPlacementInstance(GameObject instance)
|
||||||
|
{
|
||||||
|
if (instance == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
Object.Destroy(instance);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SpawnedPlacementRuntime
|
||||||
|
{
|
||||||
|
public SpawnedPlacementRuntime(long spawnId, Vector2Int chunkCoord, int collectionIndex, int entryIndex, GameObject instance)
|
||||||
|
{
|
||||||
|
SpawnId = spawnId;
|
||||||
|
ChunkCoord = chunkCoord;
|
||||||
|
CollectionIndex = collectionIndex;
|
||||||
|
EntryIndex = entryIndex;
|
||||||
|
Instance = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long SpawnId { get; }
|
||||||
|
public Vector2Int ChunkCoord { get; }
|
||||||
|
public int CollectionIndex { get; }
|
||||||
|
public int EntryIndex { get; }
|
||||||
|
public GameObject Instance { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f7d0d16ba43223c41bd21fbe9333d819
|
||||||
@@ -75,6 +75,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
EnsureRuntimeData();
|
EnsureRuntimeData();
|
||||||
EnsureChunkRoot();
|
EnsureChunkRoot();
|
||||||
EnsureRegionRoot();
|
EnsureRegionRoot();
|
||||||
|
EnsurePlacementRoot();
|
||||||
TryResolveStreamTarget();
|
TryResolveStreamTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
EnsureRuntimeData();
|
EnsureRuntimeData();
|
||||||
EnsureChunkRoot();
|
EnsureChunkRoot();
|
||||||
EnsureRegionRoot();
|
EnsureRegionRoot();
|
||||||
|
EnsurePlacementRoot();
|
||||||
if (!TryResolveStreamTarget())
|
if (!TryResolveStreamTarget())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -120,6 +122,8 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
|
|
||||||
CleanupChunks();
|
CleanupChunks();
|
||||||
CleanupRegions();
|
CleanupRegions();
|
||||||
|
CleanupPlacementPlans();
|
||||||
|
CleanupSpawnedPlacements();
|
||||||
atlas?.Dispose();
|
atlas?.Dispose();
|
||||||
atlas = null;
|
atlas = null;
|
||||||
}
|
}
|
||||||
@@ -127,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)
|
||||||
@@ -285,6 +290,11 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
Vector2Int regionCoord = ChunkToRegion(coord);
|
Vector2Int regionCoord = ChunkToRegion(coord);
|
||||||
MarkRegionDirty(coord);
|
MarkRegionDirty(coord);
|
||||||
chunks.Remove(coord);
|
chunks.Remove(coord);
|
||||||
|
lock (placementPlanLock)
|
||||||
|
{
|
||||||
|
chunkPlacementPlans.Remove(coord);
|
||||||
|
}
|
||||||
|
DespawnChunkPlacements(coord);
|
||||||
runtime.Dispose();
|
runtime.Dispose();
|
||||||
TryDisposeRegionIfEmpty(regionCoord);
|
TryDisposeRegionIfEmpty(regionCoord);
|
||||||
QueueNeighborRefresh(coord);
|
QueueNeighborRefresh(coord);
|
||||||
@@ -799,6 +809,8 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
runtime.State = ChunkState.ReadyToRender;
|
runtime.State = ChunkState.ReadyToRender;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TrySpawnChunkPlacements(result.Coord);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +897,7 @@ namespace InfiniteWorld.VoxelWorld
|
|||||||
}
|
}
|
||||||
|
|
||||||
chunks.Clear();
|
chunks.Clear();
|
||||||
|
CleanupSpawnedPlacements();
|
||||||
dirtyChunkMeshes.Clear();
|
dirtyChunkMeshes.Clear();
|
||||||
queuedChunkMeshes.Clear();
|
queuedChunkMeshes.Clear();
|
||||||
pendingNeighborRefreshes.Clear();
|
pendingNeighborRefreshes.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0f36445faa7413742b67196646eb2631
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace InfiniteWorld.VoxelWorld
|
||||||
|
{
|
||||||
|
internal static class WorldSeedUtility
|
||||||
|
{
|
||||||
|
public static uint Hash(params int[] values)
|
||||||
|
{
|
||||||
|
uint hash = 2166136261u;
|
||||||
|
for (int i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
hash ^= unchecked((uint)values[i]);
|
||||||
|
hash *= 16777619u;
|
||||||
|
hash ^= hash >> 13;
|
||||||
|
hash *= 1274126177u;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Value01(uint hash)
|
||||||
|
{
|
||||||
|
return (hash & 0x00FFFFFFu) / 16777215f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Range(uint hash, int minInclusive, int maxExclusive)
|
||||||
|
{
|
||||||
|
if (maxExclusive <= minInclusive)
|
||||||
|
{
|
||||||
|
return minInclusive;
|
||||||
|
}
|
||||||
|
|
||||||
|
return minInclusive + (int)(hash % (uint)(maxExclusive - minInclusive));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long ToStableId(uint hashA, uint hashB)
|
||||||
|
{
|
||||||
|
return ((long)hashA << 32) | hashB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 24b3661eb20e8b44a96a4f8cdf49e466
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace InfiniteWorld.VoxelWorld
|
||||||
|
{
|
||||||
|
internal static class WorldSpawnPlanner
|
||||||
|
{
|
||||||
|
public static WorldChunkPlacementPlan PlanChunk(
|
||||||
|
int worldSeed,
|
||||||
|
Vector2Int chunkCoord,
|
||||||
|
int chunkSize,
|
||||||
|
IReadOnlyList<WorldPrefabCollectionRuntime> collections,
|
||||||
|
System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
if (collections == null || collections.Count == 0)
|
||||||
|
{
|
||||||
|
return WorldChunkPlacementPlan.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WorldSpawnPoint> spawnPoints = new List<WorldSpawnPoint>();
|
||||||
|
List<WorldTerrainPatch> terrainPatches = new List<WorldTerrainPatch>();
|
||||||
|
HashSet<Vector2Int> flattenedCells = new HashSet<Vector2Int>();
|
||||||
|
HashSet<Vector2Int> occupiedCells = new HashSet<Vector2Int>();
|
||||||
|
int spawnOrdinal = 0;
|
||||||
|
|
||||||
|
for (int collectionIndex = 0; collectionIndex < collections.Count; collectionIndex++)
|
||||||
|
{
|
||||||
|
WorldPrefabCollectionRuntime collection = collections[collectionIndex];
|
||||||
|
if (collection == null || collection.Entries == null || collection.Entries.Count == 0 || collection.MaxPlacementsPerChunk <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int placementIndex = 0; placementIndex < collection.MaxPlacementsPerChunk; placementIndex++)
|
||||||
|
{
|
||||||
|
if (!TryPickEntry(collection, worldSeed, chunkCoord, collectionIndex, placementIndex, out int entryIndex, out WorldPrefabEntryRuntime entry))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PassesSpawnChance(entry, worldSeed, chunkCoord, collectionIndex, placementIndex))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryPlanPlacement(worldSeed, chunkCoord, chunkSize, collection, collectionIndex, placementIndex, entryIndex, entry, spawnOrdinal, occupiedCells, flattenedCells, getBaseHeight, out WorldSpawnPoint spawnPoint, out WorldTerrainPatch patch, out List<Vector2Int> reservedCells))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnPoints.Add(spawnPoint);
|
||||||
|
if (patch != null)
|
||||||
|
{
|
||||||
|
terrainPatches.Add(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < reservedCells.Count; i++)
|
||||||
|
{
|
||||||
|
occupiedCells.Add(reservedCells[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnOrdinal++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnPoints.Count == 0 && terrainPatches.Count == 0)
|
||||||
|
{
|
||||||
|
return WorldChunkPlacementPlan.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorldChunkPlacementPlan(spawnPoints, terrainPatches, flattenedCells.Count > 0 ? flattenedCells : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPickEntry(WorldPrefabCollectionRuntime collection, int worldSeed, Vector2Int chunkCoord, int collectionIndex, int placementIndex, out int entryIndex, out WorldPrefabEntryRuntime entry)
|
||||||
|
{
|
||||||
|
float totalWeight = 0f;
|
||||||
|
for (int i = 0; i < collection.Entries.Count; i++)
|
||||||
|
{
|
||||||
|
WorldPrefabEntryRuntime candidate = collection.Entries[i];
|
||||||
|
if (candidate == null || !candidate.HasPrefab || candidate.Weight <= 0f)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalWeight += candidate.Weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalWeight <= 0f)
|
||||||
|
{
|
||||||
|
entryIndex = -1;
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
float pick = WorldSeedUtility.Value01(WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex, placementIndex, 1)) * totalWeight;
|
||||||
|
float accumulated = 0f;
|
||||||
|
for (int i = 0; i < collection.Entries.Count; i++)
|
||||||
|
{
|
||||||
|
WorldPrefabEntryRuntime candidate = collection.Entries[i];
|
||||||
|
if (candidate == null || !candidate.HasPrefab || candidate.Weight <= 0f)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulated += candidate.Weight;
|
||||||
|
if (pick <= accumulated)
|
||||||
|
{
|
||||||
|
entryIndex = i;
|
||||||
|
entry = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = collection.Entries.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
WorldPrefabEntryRuntime candidate = collection.Entries[i];
|
||||||
|
if (candidate == null || !candidate.HasPrefab || candidate.Weight <= 0f)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIndex = i;
|
||||||
|
entry = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entryIndex = -1;
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PassesSpawnChance(WorldPrefabEntryRuntime entry, int worldSeed, Vector2Int chunkCoord, int collectionIndex, int placementIndex)
|
||||||
|
{
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.SpawnChancePercent <= 0f)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.SpawnChancePercent >= 100f)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
float roll = WorldSeedUtility.Value01(WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex, placementIndex, 11)) * 100f;
|
||||||
|
return roll <= entry.SpawnChancePercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPlanPlacement(
|
||||||
|
int worldSeed,
|
||||||
|
Vector2Int chunkCoord,
|
||||||
|
int chunkSize,
|
||||||
|
WorldPrefabCollectionRuntime collection,
|
||||||
|
int collectionIndex,
|
||||||
|
int placementIndex,
|
||||||
|
int entryIndex,
|
||||||
|
WorldPrefabEntryRuntime entry,
|
||||||
|
int spawnOrdinal,
|
||||||
|
HashSet<Vector2Int> occupiedCells,
|
||||||
|
HashSet<Vector2Int> flattenedCells,
|
||||||
|
System.Func<Vector2Int, int> getBaseHeight,
|
||||||
|
out WorldSpawnPoint spawnPoint,
|
||||||
|
out WorldTerrainPatch patch,
|
||||||
|
out List<Vector2Int> reservedCells)
|
||||||
|
{
|
||||||
|
reservedCells = null;
|
||||||
|
spawnPoint = default;
|
||||||
|
patch = null;
|
||||||
|
|
||||||
|
Vector2Int chunkOrigin = new Vector2Int(chunkCoord.x * chunkSize, chunkCoord.y * chunkSize);
|
||||||
|
int attempts = Mathf.Max(1, collection.AttemptsPerPlacement);
|
||||||
|
for (int attempt = 0; attempt < attempts; attempt++)
|
||||||
|
{
|
||||||
|
int rotationSteps = entry.AllowRotations ? WorldSeedUtility.Range(WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex, placementIndex, attempt, 2), 0, 4) : 0;
|
||||||
|
Vector2Int footprint = GetFootprint(entry.Footprint, rotationSteps);
|
||||||
|
if (footprint.x <= 0 || footprint.y <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int localMinX = collection.ChunkEdgePadding;
|
||||||
|
int localMinZ = collection.ChunkEdgePadding;
|
||||||
|
int localMaxX = chunkSize - collection.ChunkEdgePadding - footprint.x;
|
||||||
|
int localMaxZ = chunkSize - collection.ChunkEdgePadding - footprint.y;
|
||||||
|
if (localMaxX < localMinX || localMaxZ < localMinZ)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int localX = WorldSeedUtility.Range(WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex, placementIndex, attempt, 3), localMinX, localMaxX + 1);
|
||||||
|
int localZ = WorldSeedUtility.Range(WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex, placementIndex, attempt, 4), localMinZ, localMaxZ + 1);
|
||||||
|
RectInt footprintRect = new RectInt(localX, localZ, footprint.x, footprint.y);
|
||||||
|
RectInt reservedRect = ExpandRect(footprintRect, Mathf.Max(0, entry.Clearance), chunkSize);
|
||||||
|
|
||||||
|
if (IntersectsOccupied(chunkOrigin, reservedRect, occupiedCells))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.PlacementMode == WorldPlacementMode.GroundOnly)
|
||||||
|
{
|
||||||
|
if (!IsGroundArea(chunkOrigin, reservedRect, flattenedCells, getBaseHeight))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reservedCells = CollectCells(chunkOrigin, reservedRect);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!TryCreateFlattenPatch(chunkOrigin, chunkSize, footprintRect, reservedRect, entry, occupiedCells, flattenedCells, getBaseHeight, out patch, out reservedCells))
|
||||||
|
{
|
||||||
|
patch = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float rotationY = rotationSteps * 90f;
|
||||||
|
Vector3 position = new Vector3(
|
||||||
|
chunkOrigin.x + footprintRect.x + footprintRect.width * 0.5f,
|
||||||
|
0f,
|
||||||
|
chunkOrigin.y + footprintRect.y + footprintRect.height * 0.5f);
|
||||||
|
long spawnId = WorldSeedUtility.ToStableId(
|
||||||
|
WorldSeedUtility.Hash(worldSeed, chunkCoord.x, chunkCoord.y, collectionIndex),
|
||||||
|
WorldSeedUtility.Hash(entryIndex, placementIndex, spawnOrdinal, footprintRect.x, footprintRect.y));
|
||||||
|
spawnPoint = new WorldSpawnPoint(spawnId, chunkCoord, spawnOrdinal, collectionIndex, entryIndex, entry.Id, position, Quaternion.Euler(0f, rotationY, 0f));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateFlattenPatch(
|
||||||
|
Vector2Int chunkOrigin,
|
||||||
|
int chunkSize,
|
||||||
|
RectInt footprintRect,
|
||||||
|
RectInt reservedRect,
|
||||||
|
WorldPrefabEntryRuntime entry,
|
||||||
|
HashSet<Vector2Int> occupiedCells,
|
||||||
|
HashSet<Vector2Int> flattenedCells,
|
||||||
|
System.Func<Vector2Int, int> getBaseHeight,
|
||||||
|
out WorldTerrainPatch patch,
|
||||||
|
out List<Vector2Int> reservedCells)
|
||||||
|
{
|
||||||
|
int flattenPadding = Mathf.Max(entry.Clearance, entry.FlattenPadding);
|
||||||
|
RectInt flattenRect = ExpandRect(footprintRect, flattenPadding, chunkSize);
|
||||||
|
List<Vector2Int> flattened = CollectCells(chunkOrigin, flattenRect);
|
||||||
|
if (IntersectsOccupied(flattened, occupiedCells))
|
||||||
|
{
|
||||||
|
patch = null;
|
||||||
|
reservedCells = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Vector2Int> corridor = TryBuildAccessCorridor(chunkOrigin, chunkSize, flattenRect, Mathf.Max(1, entry.FlattenSearchRadius), occupiedCells, flattenedCells, getBaseHeight);
|
||||||
|
if (corridor == null)
|
||||||
|
{
|
||||||
|
patch = null;
|
||||||
|
reservedCells = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < corridor.Count; i++)
|
||||||
|
{
|
||||||
|
flattened.Add(corridor[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<Vector2Int> uniqueFlattened = new HashSet<Vector2Int>(flattened);
|
||||||
|
foreach (Vector2Int worldCell in uniqueFlattened)
|
||||||
|
{
|
||||||
|
flattenedCells.Add(worldCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = new WorldTerrainPatch(flattened);
|
||||||
|
reservedCells = CollectCells(chunkOrigin, reservedRect);
|
||||||
|
reservedCells.AddRange(corridor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector2Int> TryBuildAccessCorridor(
|
||||||
|
Vector2Int chunkOrigin,
|
||||||
|
int chunkSize,
|
||||||
|
RectInt flattenRect,
|
||||||
|
int searchRadius,
|
||||||
|
HashSet<Vector2Int> occupiedCells,
|
||||||
|
HashSet<Vector2Int> flattenedCells,
|
||||||
|
System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
if (HasAccessibleGroundNeighbor(chunkOrigin, flattenRect, flattenedCells, getBaseHeight))
|
||||||
|
{
|
||||||
|
return new List<Vector2Int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2Int center = new Vector2Int(flattenRect.x + flattenRect.width / 2, flattenRect.y + flattenRect.height / 2);
|
||||||
|
Vector2Int? target = FindNearestGroundCell(chunkOrigin, chunkSize, center, flattenRect, searchRadius, occupiedCells, flattenedCells, getBaseHeight);
|
||||||
|
if (!target.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2Int entryCell = new Vector2Int(
|
||||||
|
Mathf.Clamp(target.Value.x, chunkOrigin.x + flattenRect.xMin, chunkOrigin.x + flattenRect.xMax - 1),
|
||||||
|
Mathf.Clamp(target.Value.y, chunkOrigin.y + flattenRect.yMin, chunkOrigin.y + flattenRect.yMax - 1));
|
||||||
|
|
||||||
|
List<Vector2Int> corridor = new List<Vector2Int>();
|
||||||
|
Vector2Int cursor = target.Value;
|
||||||
|
while (cursor.x != entryCell.x)
|
||||||
|
{
|
||||||
|
if (!occupiedCells.Contains(cursor))
|
||||||
|
{
|
||||||
|
corridor.Add(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.x += cursor.x < entryCell.x ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cursor.y != entryCell.y)
|
||||||
|
{
|
||||||
|
if (!occupiedCells.Contains(cursor))
|
||||||
|
{
|
||||||
|
corridor.Add(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.y += cursor.y < entryCell.y ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return corridor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAccessibleGroundNeighbor(Vector2Int chunkOrigin, RectInt flattenRect, HashSet<Vector2Int> flattenedCells, System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
for (int z = flattenRect.yMin - 1; z <= flattenRect.yMax; z++)
|
||||||
|
{
|
||||||
|
Vector2Int left = new Vector2Int(chunkOrigin.x + flattenRect.xMin - 1, chunkOrigin.y + z);
|
||||||
|
Vector2Int right = new Vector2Int(chunkOrigin.x + flattenRect.xMax, chunkOrigin.y + z);
|
||||||
|
if (GetPlannedHeight(left, flattenedCells, getBaseHeight) == 0 || GetPlannedHeight(right, flattenedCells, getBaseHeight) == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int x = flattenRect.xMin; x < flattenRect.xMax; x++)
|
||||||
|
{
|
||||||
|
Vector2Int bottom = new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + flattenRect.yMin - 1);
|
||||||
|
Vector2Int top = new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + flattenRect.yMax);
|
||||||
|
if (GetPlannedHeight(bottom, flattenedCells, getBaseHeight) == 0 || GetPlannedHeight(top, flattenedCells, getBaseHeight) == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2Int? FindNearestGroundCell(
|
||||||
|
Vector2Int chunkOrigin,
|
||||||
|
int chunkSize,
|
||||||
|
Vector2Int localCenter,
|
||||||
|
RectInt excludedRect,
|
||||||
|
int searchRadius,
|
||||||
|
HashSet<Vector2Int> occupiedCells,
|
||||||
|
HashSet<Vector2Int> flattenedCells,
|
||||||
|
System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
for (int radius = 1; radius <= searchRadius; radius++)
|
||||||
|
{
|
||||||
|
for (int dz = -radius; dz <= radius; dz++)
|
||||||
|
{
|
||||||
|
for (int dx = -radius; dx <= radius; dx++)
|
||||||
|
{
|
||||||
|
if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dz)) != radius)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int localX = localCenter.x + dx;
|
||||||
|
int localZ = localCenter.y + dz;
|
||||||
|
if (localX < 0 || localZ < 0 || localX >= chunkSize || localZ >= chunkSize)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludedRect.Contains(new Vector2Int(localX, localZ)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2Int worldCell = new Vector2Int(chunkOrigin.x + localX, chunkOrigin.y + localZ);
|
||||||
|
if (occupiedCells.Contains(worldCell))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GetPlannedHeight(worldCell, flattenedCells, getBaseHeight) == 0)
|
||||||
|
{
|
||||||
|
return worldCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetPlannedHeight(Vector2Int worldCell, HashSet<Vector2Int> flattenedCells, System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
return flattenedCells.Contains(worldCell) ? 0 : getBaseHeight(worldCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGroundArea(Vector2Int chunkOrigin, RectInt rect, HashSet<Vector2Int> flattenedCells, System.Func<Vector2Int, int> getBaseHeight)
|
||||||
|
{
|
||||||
|
for (int z = rect.yMin; z < rect.yMax; z++)
|
||||||
|
{
|
||||||
|
for (int x = rect.xMin; x < rect.xMax; x++)
|
||||||
|
{
|
||||||
|
if (GetPlannedHeight(new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + z), flattenedCells, getBaseHeight) > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsOccupied(Vector2Int chunkOrigin, RectInt rect, HashSet<Vector2Int> occupiedCells)
|
||||||
|
{
|
||||||
|
for (int z = rect.yMin; z < rect.yMax; z++)
|
||||||
|
{
|
||||||
|
for (int x = rect.xMin; x < rect.xMax; x++)
|
||||||
|
{
|
||||||
|
if (occupiedCells.Contains(new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + z)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsOccupied(List<Vector2Int> cells, HashSet<Vector2Int> occupiedCells)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < cells.Count; i++)
|
||||||
|
{
|
||||||
|
if (occupiedCells.Contains(cells[i]))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector2Int> CollectCells(Vector2Int chunkOrigin, RectInt rect)
|
||||||
|
{
|
||||||
|
List<Vector2Int> result = new List<Vector2Int>(rect.width * rect.height);
|
||||||
|
for (int z = rect.yMin; z < rect.yMax; z++)
|
||||||
|
{
|
||||||
|
for (int x = rect.xMin; x < rect.xMax; x++)
|
||||||
|
{
|
||||||
|
result.Add(new Vector2Int(chunkOrigin.x + x, chunkOrigin.y + z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RectInt ExpandRect(RectInt rect, int amount, int chunkSize)
|
||||||
|
{
|
||||||
|
return new RectInt(
|
||||||
|
Mathf.Max(0, rect.xMin - amount),
|
||||||
|
Mathf.Max(0, rect.yMin - amount),
|
||||||
|
Mathf.Min(chunkSize, rect.xMax + amount) - Mathf.Max(0, rect.xMin - amount),
|
||||||
|
Mathf.Min(chunkSize, rect.yMax + amount) - Mathf.Max(0, rect.yMin - amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2Int GetFootprint(Vector2Int footprint, int rotationSteps)
|
||||||
|
{
|
||||||
|
footprint.x = Mathf.Max(1, footprint.x);
|
||||||
|
footprint.y = Mathf.Max(1, footprint.y);
|
||||||
|
return rotationSteps % 2 == 0 ? footprint : new Vector2Int(footprint.y, footprint.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c0ff8828be489ea4b8cef4f1f62b3a14
|
||||||
@@ -394,6 +394,14 @@ PrefabInstance:
|
|||||||
propertyPath: streamTarget
|
propertyPath: streamTarget
|
||||||
value:
|
value:
|
||||||
objectReference: {fileID: 0}
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 2927522923773808063, guid: 91b5caa5457131b4f8c542529f4ad7c3, type: 3}
|
||||||
|
propertyPath: placementCollections.Array.size
|
||||||
|
value: 1
|
||||||
|
objectReference: {fileID: 0}
|
||||||
|
- target: {fileID: 2927522923773808063, guid: 91b5caa5457131b4f8c542529f4ad7c3, type: 3}
|
||||||
|
propertyPath: 'placementCollections.Array.data[0]'
|
||||||
|
value:
|
||||||
|
objectReference: {fileID: 11400000, guid: b91d23f483c774f4dbb1a77660881d87, type: 2}
|
||||||
m_RemovedComponents: []
|
m_RemovedComponents: []
|
||||||
m_RemovedGameObjects: []
|
m_RemovedGameObjects: []
|
||||||
m_AddedGameObjects: []
|
m_AddedGameObjects: []
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
using FishNet.Object;
|
using FishNet.Object;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
namespace Players
|
namespace Players
|
||||||
{
|
{
|
||||||
public sealed class PlayerMoving : NetworkBehaviour
|
public sealed class PlayerMoving : NetworkBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float _moveSpeed = 5f;
|
[SerializeField] private float _moveSpeed = 5f;
|
||||||
|
[SerializeField] private float _sprintMultiplier = 2f;
|
||||||
|
[SerializeField] private float _slowMultiplier = 0.5f;
|
||||||
[SerializeField] private CharacterController _characterController;
|
[SerializeField] private CharacterController _characterController;
|
||||||
|
[SerializeField] private InputActionReference _moveAction;
|
||||||
|
[SerializeField] private InputActionReference _sprintAction;
|
||||||
|
[SerializeField] private InputActionReference _slowAction;
|
||||||
|
|
||||||
private Transform _cameraTransform;
|
private Transform _cameraTransform;
|
||||||
|
|
||||||
@@ -16,22 +22,72 @@ namespace Players
|
|||||||
if (playerCamera != null)
|
if (playerCamera != null)
|
||||||
_cameraTransform = playerCamera.transform;
|
_cameraTransform = playerCamera.transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
EnableAction(_moveAction);
|
||||||
|
EnableAction(_sprintAction);
|
||||||
|
EnableAction(_slowAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
DisableAction(_moveAction);
|
||||||
|
DisableAction(_sprintAction);
|
||||||
|
DisableAction(_slowAction);
|
||||||
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!IsOwner)
|
if (!IsOwner)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
float horizontal = Input.GetAxisRaw("Horizontal");
|
Vector2 moveInput = ReadMoveInput();
|
||||||
float vertical = Input.GetAxisRaw("Vertical");
|
float speedMultiplier = ReadSpeedMultiplier();
|
||||||
|
|
||||||
Transform directionSource = _cameraTransform != null ? _cameraTransform : transform;
|
Transform directionSource = _cameraTransform != null ? _cameraTransform : transform;
|
||||||
Vector3 forward = Vector3.ProjectOnPlane(directionSource.forward, Vector3.up).normalized;
|
Vector3 forward = Vector3.ProjectOnPlane(directionSource.forward, Vector3.up).normalized;
|
||||||
Vector3 right = Vector3.ProjectOnPlane(directionSource.right, Vector3.up).normalized;
|
Vector3 right = Vector3.ProjectOnPlane(directionSource.right, Vector3.up).normalized;
|
||||||
Vector3 moveDirection = (right * horizontal + forward * vertical).normalized;
|
Vector3 moveDirection = (right * moveInput.x + forward * moveInput.y).normalized;
|
||||||
Vector3 offset = moveDirection * (_moveSpeed * Time.deltaTime);
|
Vector3 offset = moveDirection * (_moveSpeed * speedMultiplier * Time.deltaTime);
|
||||||
|
|
||||||
_characterController.Move(offset);
|
_characterController.Move(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vector2 ReadMoveInput()
|
||||||
|
{
|
||||||
|
InputAction action = _moveAction != null ? _moveAction.action : null;
|
||||||
|
return action != null ? action.ReadValue<Vector2>() : Vector2.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float ReadSpeedMultiplier()
|
||||||
|
{
|
||||||
|
bool isSprinting = IsActionPressed(_sprintAction);
|
||||||
|
bool isSlowing = IsActionPressed(_slowAction);
|
||||||
|
if (isSprinting == isSlowing)
|
||||||
|
return 1f;
|
||||||
|
|
||||||
|
return isSprinting ? _sprintMultiplier : _slowMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsActionPressed(InputActionReference actionReference)
|
||||||
|
{
|
||||||
|
InputAction action = actionReference != null ? actionReference.action : null;
|
||||||
|
return action != null && action.IsPressed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnableAction(InputActionReference actionReference)
|
||||||
|
{
|
||||||
|
InputAction action = actionReference != null ? actionReference.action : null;
|
||||||
|
if (action != null && !action.enabled)
|
||||||
|
action.Enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisableAction(InputActionReference actionReference)
|
||||||
|
{
|
||||||
|
InputAction action = actionReference != null ? actionReference.action : null;
|
||||||
|
if (action != null && action.enabled)
|
||||||
|
action.Disable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -58,9 +58,11 @@
|
|||||||
| TASK-0016 | ToDo | High | classes | unassigned | 1d | docs/tasks/items/TASK-0016.md | Реализовать MVP-скилл Лучника: выстрел через общую систему оружия и навыков. |
|
| TASK-0016 | ToDo | High | classes | unassigned | 1d | docs/tasks/items/TASK-0016.md | Реализовать MVP-скилл Лучника: выстрел через общую систему оружия и навыков. |
|
||||||
| TASK-0017 | BackLog | Medium | networking | unassigned | 1d | docs/tasks/items/TASK-0017.md | Добавить reconnect/resume после дисконнекта с восстановлением позиции и session state. |
|
| TASK-0017 | BackLog | Medium | networking | unassigned | 1d | docs/tasks/items/TASK-0017.md | Добавить reconnect/resume после дисконнекта с восстановлением позиции и session state. |
|
||||||
| TASK-0018 | BackLog | Medium | persistence | unassigned | 1d | docs/tasks/items/TASK-0018.md | Добавить миграции формата сохранений между версиями для world save и player save. |
|
| TASK-0018 | BackLog | Medium | persistence | unassigned | 1d | docs/tasks/items/TASK-0018.md | Добавить миграции формата сохранений между версиями для world save и player save. |
|
||||||
| TASK-0019 | BackLog | High | worldgen | unassigned | 1d6h | docs/tasks/items/TASK-0019.md | Добавить детерминированное размещение dungeon prefab в voxel-мире через stamp/carve в данных чанков. |
|
| TASK-0019 | InProgress | High | worldgen | pretty_kotik | 1d6h | `docs/tasks/items/TASK-0019.md` | Добавить детерминированное размещение dungeon prefab в voxel-мире через stamp/carve в данных чанков. |
|
||||||
| TASK-0020 | BackLog | High | security | unassigned | 1d | docs/tasks/items/TASK-0020.md | Добавить серверные ограничения и валидации против читов и некорректных клиентских команд. |
|
| TASK-0020 | BackLog | High | security | unassigned | 1d | docs/tasks/items/TASK-0020.md | Добавить серверные ограничения и валидации против читов и некорректных клиентских команд. |
|
||||||
| TASK-0021 | ToDo | High | architecture | unassigned | 2d | docs/tasks/items/TASK-0021.md | Привести проект в порядок: разнести код по asmdef, навести структуру Editor/Runtime и добавить базовые автотесты. |
|
| TASK-0021 | ToDo | High | architecture | unassigned | 2d | docs/tasks/items/TASK-0021.md | Привести проект в порядок: разнести код по asmdef, навести структуру Editor/Runtime и добавить базовые автотесты. |
|
||||||
| TASK-0022 | ToDo | Highest | worldgen | unassigned | 1d | docs/tasks/items/TASK-0022.md | Интегрировать спавн врагов в VoxelWorldGenerator: спавнить по загрузке чанка и учитывать kill-state. |
|
| TASK-0022 | ToDo | Highest | worldgen | unassigned | 1d | docs/tasks/items/TASK-0022.md | Интегрировать спавн врагов в VoxelWorldGenerator: спавнить по загрузке чанка и учитывать kill-state. |
|
||||||
| TASK-0023 | InProgress | Highest | ai | abysscion | 2d | `docs/tasks/items/TASK-0023.md` | Реализовать runtime NavMesh bake для voxel-чанка и интегрировать обновление навигации при загрузке/изменении чанков. |
|
| TASK-0023 | InProgress | Highest | ai | abysscion | 2d | `docs/tasks/items/TASK-0023.md` | Реализовать runtime NavMesh bake для voxel-чанка и интегрировать обновление навигации при загрузке/изменении чанков. |
|
||||||
| TASK-0024 | ToDo | Highest | art | unassigned | 2d | docs/tasks/items/TASK-0024.md | Заменить Minecraft-placeholder арт на легальные ассеты для продакшена и зафиксировать источник/лицензии. |
|
| TASK-0024 | ToDo | Highest | art | unassigned | 2d | docs/tasks/items/TASK-0024.md | Заменить Minecraft-placeholder арт на легальные ассеты для продакшена и зафиксировать источник/лицензии. |
|
||||||
|
| TASK-0025 | ToDo | Highest | build | unassigned | 1d | docs/tasks/items/TASK-0025.md | Описать и зафиксировать flow локального теста билда: сборка, запуск, host/client сценарий и обязательный smoke checklist. |
|
||||||
|
| TASK-0026 | BackLog | High | ui | unassigned | 2d | docs/tasks/items/TASK-0026.md | Реализовать миникарту и механизм сохранения открытой карты у хоста так, чтобы состояние миникарты было общим для всех игроков мира. |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Добавить генерацию данжей-предфабов по
|
|||||||
summary: Реализовать BackLog-задачу на детерминированное размещение dungeon prefab в voxel-мире: одинаковая позиция по seed, встраивание через stamp/carve и сохранение результата.
|
summary: Реализовать BackLog-задачу на детерминированное размещение dungeon prefab в voxel-мире: одинаковая позиция по seed, встраивание через stamp/carve и сохранение результата.
|
||||||
priority: High
|
priority: High
|
||||||
area: worldgen
|
area: worldgen
|
||||||
owner: unassigned
|
owner: pretty_kotik
|
||||||
created: 2026-03-30
|
created: 2026-03-30
|
||||||
updated: 2026-03-30
|
updated: 2026-03-30
|
||||||
execution_time: 1d6h
|
execution_time: 1d6h
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
id: TASK-0025
|
||||||
|
title: Описать flow локального теста билда
|
||||||
|
summary: Зафиксировать repeatable flow локального теста билда: как собрать проект, как запускать host/client сценарий и что обязательно проверять перед ручным прогоном.
|
||||||
|
priority: Highest
|
||||||
|
area: build
|
||||||
|
owner: unassigned
|
||||||
|
created: 2026-03-31
|
||||||
|
updated: 2026-03-31
|
||||||
|
execution_time: 1d
|
||||||
|
depends_on: []
|
||||||
|
canonical_docs:
|
||||||
|
- docs/tasks/Index.md
|
||||||
|
related_files:
|
||||||
|
- ProjectSettings/
|
||||||
|
- Assets/Scenes/
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-0025 - Описать flow локального теста билда
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Статус задачи ведется в `docs/tasks/Index.md` и является каноническим там.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Без зафиксированного локального flow билд-тестов сложно быстро и одинаково проверять игру перед изменениями, особенно когда проект одновременно развивается в networking, voxel worldgen и gameplay.
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
Есть понятный и повторяемый локальный сценарий проверки билда: как собрать проект, как запустить игру, как проверить host/client сценарий и какие smoke-checks обязательны перед ручным завершением теста.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
Сейчас локальная проверка билда не оформлена как канонический процесс. Из-за этого каждый новый прогон легко делается по-разному и важные регрессии можно пропустить.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
- `ProjectSettings/...`
|
||||||
|
- фактический build pipeline проекта
|
||||||
|
- актуальные игровые сцены и точка входа в игру
|
||||||
|
|
||||||
|
## Read First
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `docs/tasks/Index.md`
|
||||||
|
- `ProjectSettings/...`
|
||||||
|
- `Assets/Scenes/...`
|
||||||
|
|
||||||
|
## Scope In
|
||||||
|
|
||||||
|
- описать локальную сборку проекта для основного target
|
||||||
|
- зафиксировать host/client сценарий локального прогона
|
||||||
|
- описать smoke checklist после запуска билда
|
||||||
|
- указать, какие артефакты или логи нужно смотреть при сбое
|
||||||
|
|
||||||
|
## Scope Out
|
||||||
|
|
||||||
|
- автоматизация CI/CD
|
||||||
|
- полное end-to-end покрытие всех игровых сценариев
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- flow должен быть достаточно коротким для частого повторения
|
||||||
|
- шаги должны быть воспроизводимыми на одной машине разработчика
|
||||||
|
|
||||||
|
## Suggested Approach
|
||||||
|
|
||||||
|
1. Зафиксировать основной build target и точку входа.
|
||||||
|
2. Описать последовательность сборки и запуска host/client локально.
|
||||||
|
3. Сформировать короткий smoke checklist.
|
||||||
|
4. Добавить заметки по типовым ошибкам и логам.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- есть документированный flow локального теста билда
|
||||||
|
- есть checklist для host/client smoke test
|
||||||
|
- новый человек или AI может повторить прогон без уточнений в чате
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- пройти описанный flow вручную
|
||||||
|
- убедиться, что шаги не зависят от скрытых предположений
|
||||||
|
|
||||||
|
## Risks / Open Questions
|
||||||
|
|
||||||
|
- если build flow сильно зависит от окружения, понадобится отдельно фиксировать prerequisites
|
||||||
|
|
||||||
|
## Human Decisions Needed
|
||||||
|
|
||||||
|
- none currently
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- `2026-03-31` - задача добавлена для стандартизации ручной локальной проверки билда.
|
||||||
|
|
||||||
|
## Handoff Notes
|
||||||
|
|
||||||
|
Если в проекте появится отдельный build script или launcher, этот task нужно обновить и сослаться уже на него как на канонический entry point.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
id: TASK-0026
|
||||||
|
title: Реализовать миникарту и сохранение карты у хоста
|
||||||
|
summary: Добавить миникарту и хранить ее открытое состояние у хоста так, чтобы карта была общей для всех игроков мира и сохранялась вместе с world-state.
|
||||||
|
priority: High
|
||||||
|
area: ui
|
||||||
|
owner: unassigned
|
||||||
|
created: 2026-03-31
|
||||||
|
updated: 2026-03-31
|
||||||
|
execution_time: 2d
|
||||||
|
depends_on:
|
||||||
|
- TASK-0003
|
||||||
|
- TASK-0004
|
||||||
|
canonical_docs:
|
||||||
|
- docs/tasks/Index.md
|
||||||
|
related_files:
|
||||||
|
- Assets/Scripts/VoxelWorld/Runtime/VoxelWorldGenerator.cs
|
||||||
|
- Assets/Scenes/
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-0026 - Реализовать миникарту и сохранение карты у хоста
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Статус задачи ведется в `docs/tasks/Index.md` и является каноническим там.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Миникарта нужна как gameplay и navigation feature, но ее состояние не должно жить только локально у клиента. Хост должен быть источником истины для открытой карты мира, чтобы все игроки видели согласованное состояние.
|
||||||
|
|
||||||
|
## Expected Outcome
|
||||||
|
|
||||||
|
В игре есть миникарта, а открытые области карты сохраняются в состоянии мира у хоста и корректно восстанавливаются для всех игроков при повторном входе в мир.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
Воксельный мир уже генерируется чанками, а world persistence выделен в отдельные задачи. Миникарта должна опираться на те же координаты чанков и храниться как часть world-state, а не как чисто локальный UI cache.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
- `docs/tasks/items/TASK-0003.md`
|
||||||
|
- `docs/tasks/items/TASK-0004.md`
|
||||||
|
- runtime реализация minimap и world save
|
||||||
|
|
||||||
|
## Read First
|
||||||
|
|
||||||
|
- `docs/tasks/items/TASK-0003.md`
|
||||||
|
- `docs/tasks/items/TASK-0004.md`
|
||||||
|
- `Assets/Scripts/VoxelWorld/Runtime/VoxelWorldGenerator.cs`
|
||||||
|
|
||||||
|
## Scope In
|
||||||
|
|
||||||
|
- базовое UI отображение миникарты
|
||||||
|
- определение, какие области карты считаются открытыми
|
||||||
|
- host-authoritative хранение открытой карты мира
|
||||||
|
- восстановление состояния миникарты для всех игроков
|
||||||
|
|
||||||
|
## Scope Out
|
||||||
|
|
||||||
|
- полнофункциональная world map с маркерами, пингами и пользовательскими заметками
|
||||||
|
- сложная система fog-of-war по каждому игроку отдельно
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- источник истины по открытой карте у хоста
|
||||||
|
- состояние карты должно быть частью world-state, а не только локального клиента
|
||||||
|
- решение должно быть совместимо с chunked voxel world
|
||||||
|
|
||||||
|
## Suggested Approach
|
||||||
|
|
||||||
|
1. Определить формат minimap data на уровне чанков/областей.
|
||||||
|
2. Выбрать правило открытия карты: посещение, радиус обзора, загрузка чанка или явный reveal.
|
||||||
|
3. Сохранить это состояние в world save у хоста.
|
||||||
|
4. Подключить синхронизацию minimap state для новых и уже подключенных игроков.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- миникарта отображает мир в согласованной форме
|
||||||
|
- открытая карта сохраняется у хоста
|
||||||
|
- новые игроки и повторно подключившиеся игроки получают актуальное состояние карты
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- ручной тест: открыть часть мира, перезапустить мир и проверить восстановление
|
||||||
|
- ручной тест: второй игрок подключается и получает ту же карту
|
||||||
|
|
||||||
|
## Risks / Open Questions
|
||||||
|
|
||||||
|
- нужно решить, хранить ли карту как coarse chunk-state, bitmap или другой компактный формат
|
||||||
|
|
||||||
|
## Human Decisions Needed
|
||||||
|
|
||||||
|
- none currently
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- `2026-03-31` - задача добавлена как backlog feature с host-authoritative persistence minimap state.
|
||||||
|
|
||||||
|
## Handoff Notes
|
||||||
|
|
||||||
|
Если позже понадобится отдельная личная карта игрока, не ломать эту задачу: вынести per-player overlays поверх общего world minimap state.
|
||||||
Reference in New Issue
Block a user