diff --git a/.gitattributes b/.gitattributes index 0cc7c22b..6ee91dd4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ # Auto detect text files and perform LF normalization * text=auto + ## Unity ## *.cs diff=csharp text *.cginc text @@ -13,8 +14,9 @@ *.asset merge=unityyamlmerge eol=lf *.meta merge=unityyamlmerge eol=lf *.controller merge=unityyamlmerge eol=lf + ## git-lfs ## -#Image +# Image *.jpg filter=lfs diff=lfs merge=lfs -text *.jpeg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text @@ -22,31 +24,35 @@ *.psd filter=lfs diff=lfs merge=lfs -text *.ai filter=lfs diff=lfs merge=lfs -text *.tif filter=lfs diff=lfs merge=lfs -text -#Audio +*.tga filter=lfs diff=lfs merge=lfs -text +*.exr filter=lfs diff=lfs merge=lfs -text + +# Audio *.mp3 filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text *.ogg filter=lfs diff=lfs merge=lfs -text -#Video +*.aif filter=lfs diff=lfs merge=lfs -text + +# Video *.mp4 filter=lfs diff=lfs merge=lfs -text *.mov filter=lfs diff=lfs merge=lfs -text -#3D Object -*.FBX filter=lfs diff=lfs merge=lfs -text + +# 3D Object *.fbx filter=lfs diff=lfs merge=lfs -text *.blend filter=lfs diff=lfs merge=lfs -text *.obj filter=lfs diff=lfs merge=lfs -text -#ETC -*.a filter=lfs diff=lfs merge=lfs -text -*.exr filter=lfs diff=lfs merge=lfs -text -*.tga filter=lfs diff=lfs merge=lfs -text +*.lxo filter=lfs diff=lfs merge=lfs -text + +# Fonts / Docs / Archives / Binaries +*.ttf filter=lfs diff=lfs merge=lfs -text *.pdf filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.dll filter=lfs diff=lfs merge=lfs -text +*.a filter=lfs diff=lfs merge=lfs -text +*.exe filter=lfs diff=lfs merge=lfs -text *.unitypackage filter=lfs diff=lfs merge=lfs -text -*.aif filter=lfs diff=lfs merge=lfs -text -*.ttf filter=lfs diff=lfs merge=lfs -text +*.srcaar filter=lfs diff=lfs merge=lfs -text + +# Other *.rns filter=lfs diff=lfs merge=lfs -text -*.reason filter=lfs diff=lfs merge=lfs -text -*.lxo filter=lfs diff=lfs merge=lfs -text -Assets/Firebase/Editor/generate_xml_from_google_services_json.exe filter=lfs diff=lfs merge=lfs -text -Assets/Firebase/Editor/network_request.exe filter=lfs diff=lfs merge=lfs -text -Assets/Firebase/m2repository/com/google/firebase/firebase-app-unity/12.5.0/firebase-app-unity-12.5.0.srcaar filter=lfs diff=lfs merge=lfs -text +*.reason filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.meta b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.meta new file mode 100644 index 00000000..6e78a859 --- /dev/null +++ b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2657f564ee47c514db4e1cada2770fbc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 1.asset b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 1.asset similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 1.asset rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 1.asset diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 1.asset.meta b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 1.asset.meta similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 1.asset.meta rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 1.asset.meta diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 2.asset b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 2.asset similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 2.asset rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 2.asset diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 2.asset.meta b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 2.asset.meta similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile 2.asset.meta rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile 2.asset.meta diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.asset b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile.asset similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.asset rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile.asset diff --git a/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.asset.meta b/Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile.asset.meta similarity index 100% rename from Assets/Features/VoxelWorld/Data/VoxelBiomeProfile.asset.meta rename to Assets/Features/VoxelWorld/Data/VoxelBiomeProfile/VoxelBiomeProfile.asset.meta diff --git a/Assets/Features/VoxelWorld/Data/VoxelWorldConfig.asset b/Assets/Features/VoxelWorld/Data/VoxelWorldConfig.asset index 1d1de8b2..08d31a21 100644 --- a/Assets/Features/VoxelWorld/Data/VoxelWorldConfig.asset +++ b/Assets/Features/VoxelWorld/Data/VoxelWorldConfig.asset @@ -36,6 +36,8 @@ MonoBehaviour: - {fileID: 11400000, guid: 6d0dbe510ed048440a3925ef40aeeb5b, type: 2} biomeNoiseScale: 0.02 biomeSize: 6 + placementCollections: + - {fileID: 11400000, guid: b91d23f483c774f4dbb1a77660881d87, type: 2} maxAsyncChunkJobs: 2 maxChunkBuildsPerFrame: 1 maxChunkMeshBuildsPerFrame: 1 diff --git a/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset new file mode 100644 index 00000000..cc0ff875 --- /dev/null +++ b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset @@ -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 diff --git a/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset.meta b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset.meta new file mode 100644 index 00000000..b5216c04 --- /dev/null +++ b/Assets/Features/VoxelWorld/Data/WorldPrefabCollection.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b91d23f483c774f4dbb1a77660881d87 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorld/Prefabs/TestPlayer.prefab b/Assets/Features/VoxelWorld/Prefabs/TestPlayer.prefab index 0c843d96..24db32a0 100644 --- a/Assets/Features/VoxelWorld/Prefabs/TestPlayer.prefab +++ b/Assets/Features/VoxelWorld/Prefabs/TestPlayer.prefab @@ -444,7 +444,12 @@ MonoBehaviour: _addedNetworkObject: {fileID: 6486868354670318784} _networkObjectCache: {fileID: 6486868354670318784} _moveSpeed: 30 + _sprintMultiplier: 2 + _slowMultiplier: 0.5 _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 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs index a9c4b866..5af18ffa 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldConfig.cs @@ -37,6 +37,9 @@ namespace InfiniteWorld.VoxelWorld public float biomeNoiseScale = 0.02f; [Min(1f)] public float biomeSize = 48f; + [Header("Placements")] + public List placementCollections = new List(); + [Header("Runtime")] [Min(1)] public int maxAsyncChunkJobs = 2; [Min(1)] public int maxChunkBuildsPerFrame = 1; @@ -50,6 +53,7 @@ namespace InfiniteWorld.VoxelWorld internal readonly struct VoxelWorldResolvedSettings { private static readonly IReadOnlyList EmptyBiomes = System.Array.Empty(); + private static readonly IReadOnlyList EmptyPlacementCollections = System.Array.Empty(); public static readonly VoxelWorldResolvedSettings Default = Resolve(null); @@ -75,6 +79,7 @@ namespace InfiniteWorld.VoxelWorld IReadOnlyList biomeProfiles, float biomeNoiseScale, float biomeSize, + IReadOnlyList placementCollections, int maxAsyncChunkJobs, int maxChunkBuildsPerFrame, int maxChunkMeshBuildsPerFrame, @@ -104,6 +109,7 @@ namespace InfiniteWorld.VoxelWorld BiomeProfiles = biomeProfiles; BiomeNoiseScale = biomeNoiseScale; BiomeSize = biomeSize; + PlacementCollections = placementCollections; MaxAsyncChunkJobs = maxAsyncChunkJobs; MaxChunkBuildsPerFrame = maxChunkBuildsPerFrame; MaxChunkMeshBuildsPerFrame = maxChunkMeshBuildsPerFrame; @@ -134,6 +140,7 @@ namespace InfiniteWorld.VoxelWorld public IReadOnlyList BiomeProfiles { get; } public float BiomeNoiseScale { get; } public float BiomeSize { get; } + public IReadOnlyList PlacementCollections { get; } public int MaxAsyncChunkJobs { get; } public int MaxChunkBuildsPerFrame { get; } public int MaxChunkMeshBuildsPerFrame { get; } @@ -147,6 +154,7 @@ namespace InfiniteWorld.VoxelWorld IReadOnlyList biomes = config != null && config.biomeProfiles != null ? config.biomeProfiles : EmptyBiomes; + IReadOnlyList placements = ResolvePlacementCollections(config); return new VoxelWorldResolvedSettings( Mathf.Max(8, config != null ? config.chunkSize : 16), @@ -170,6 +178,7 @@ namespace InfiniteWorld.VoxelWorld biomes, config != null ? config.biomeNoiseScale : 0.02f, Mathf.Max(1f, config != null ? config.biomeSize : 48f), + placements, Mathf.Max(1, config != null ? config.maxAsyncChunkJobs : 2), Mathf.Max(1, config != null ? config.maxChunkBuildsPerFrame : 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.maxRegionBuildsPerFrame : 1)); } + + private static IReadOnlyList ResolvePlacementCollections(VoxelWorldConfig config) + { + if (config == null || config.placementCollections == null || config.placementCollections.Count == 0) + { + return EmptyPlacementCollections; + } + + List result = new List(config.placementCollections.Count); + for (int collectionIndex = 0; collectionIndex < config.placementCollections.Count; collectionIndex++) + { + WorldPrefabCollection collection = config.placementCollections[collectionIndex]; + 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 (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; + } } } diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Generation.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Generation.cs index 0e99dd99..e5f7d48f 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Generation.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Generation.cs @@ -44,6 +44,8 @@ namespace InfiniteWorld.VoxelWorld } } + ApplyChunkPlacementHeights(coord, heights); + return new ChunkBuildResult(coord, heights, biomeIndices, version, session, runtimeId); } diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Mesh.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Mesh.cs index 7d458478..35cd6586 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Mesh.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Mesh.cs @@ -435,14 +435,14 @@ namespace InfiniteWorld.VoxelWorld 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 localZ = worldCell.y - coord.y * 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]; diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs new file mode 100644 index 00000000..c16132d9 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InfiniteWorld.VoxelWorld +{ + public sealed partial class VoxelWorldGenerator + { + 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) + { + 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 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.Placements.cs.meta b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs.meta new file mode 100644 index 00000000..7e4dceeb --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Placements.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa5a2f44cc77fe844a2f0b14725a6dd8 \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs new file mode 100644 index 00000000..d480bdf4 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InfiniteWorld.VoxelWorld +{ + public sealed partial class VoxelWorldGenerator + { + private readonly Dictionary> spawnedPlacementsByChunk = new Dictionary>(); + private readonly Dictionary spawnedPlacementsById = new Dictionary(); + private readonly HashSet disabledSpawnIds = new HashSet(); + + private Transform placementRoot; + + public IReadOnlyCollection 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 spawnPoints = plan.SpawnPoints; + if (spawnPoints == null || spawnPoints.Count == 0) + { + return; + } + + List chunkPlacements = new List(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 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 chunkPlacements)) + { + chunkPlacements.RemoveAll(item => item.SpawnId == runtime.SpawnId); + if (chunkPlacements.Count == 0) + { + spawnedPlacementsByChunk.Remove(runtime.ChunkCoord); + } + } + + DestroyPlacementInstance(runtime.Instance); + } + + private void CleanupSpawnedPlacements() + { + foreach (KeyValuePair> pair in spawnedPlacementsByChunk) + { + List 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; } + } + } +} diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs.meta b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs.meta new file mode 100644 index 00000000..e5663157 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.Spawns.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f7d0d16ba43223c41bd21fbe9333d819 \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs index 25a9216d..6a5924a1 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs @@ -84,6 +84,8 @@ namespace InfiniteWorld.VoxelWorld EnsureRuntimeData(); EnsureChunkRoot(); EnsureRegionRoot(); + EnsurePlacementRoot(); + TryResolveStreamTarget(); } private void Update() @@ -91,6 +93,7 @@ namespace InfiniteWorld.VoxelWorld EnsureRuntimeData(); EnsureChunkRoot(); EnsureRegionRoot(); + EnsurePlacementRoot(); if (!TryResolveStreamTarget()) { return; @@ -128,6 +131,8 @@ namespace InfiniteWorld.VoxelWorld CleanupChunks(); CleanupRegions(); + CleanupPlacementPlans(); + CleanupSpawnedPlacements(); atlas?.Dispose(); atlas = null; } @@ -135,6 +140,7 @@ namespace InfiniteWorld.VoxelWorld private void EnsureRuntimeData() { settings = VoxelWorldResolvedSettings.Resolve(config); + ValidatePlacementCollectionsIfNeeded(); int configuredBiomeCount = CountConfiguredBiomes(); if (atlas != null && atlasBiomeCount == configuredBiomeCount) @@ -373,6 +379,11 @@ namespace InfiniteWorld.VoxelWorld MarkRegionDirty(coord); PublishChunkNavGeometryRemoved(coord, runtime.Version); chunks.Remove(coord); + lock (placementPlanLock) + { + chunkPlacementPlans.Remove(coord); + } + DespawnChunkPlacements(coord); runtime.Dispose(); TryDisposeRegionIfEmpty(regionCoord); QueueNeighborRefresh(coord); @@ -898,6 +909,8 @@ namespace InfiniteWorld.VoxelWorld runtime.State = ChunkState.ReadyToRender; } + TrySpawnChunkPlacements(result.Coord); + return true; } @@ -984,6 +997,7 @@ namespace InfiniteWorld.VoxelWorld } chunks.Clear(); + CleanupSpawnedPlacements(); dirtyChunkMeshes.Clear(); queuedChunkMeshes.Clear(); pendingNeighborRefreshes.Clear(); diff --git a/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs new file mode 100644 index 00000000..d501cee7 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs @@ -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 entries = new List(); + + public IReadOnlyList 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 entries) + { + SourceIndex = sourceIndex; + MaxPlacementsPerChunk = maxPlacementsPerChunk; + AttemptsPerPlacement = attemptsPerPlacement; + ChunkEdgePadding = chunkEdgePadding; + Entries = entries ?? Array.Empty(); + } + + public int SourceIndex { get; } + public int MaxPlacementsPerChunk { get; } + public int AttemptsPerPlacement { get; } + public int ChunkEdgePadding { get; } + public IReadOnlyList 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 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; + + public WorldTerrainPatch(IReadOnlyList cells) + { + flattenedCells = cells != null ? new List(cells) : new List(); + } + + public IReadOnlyList 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 EmptySpawnPoints = Array.Empty(); + private static readonly IReadOnlyList EmptyTerrainPatches = Array.Empty(); + + private readonly IReadOnlyList spawnPoints; + private readonly IReadOnlyList terrainPatches; + private readonly HashSet flattenedCells; + + public static WorldChunkPlacementPlan Empty { get; } = new WorldChunkPlacementPlan(EmptySpawnPoints, EmptyTerrainPatches, null); + + public WorldChunkPlacementPlan(IReadOnlyList spawnPoints, IReadOnlyList terrainPatches, HashSet flattenedCells) + { + this.spawnPoints = spawnPoints ?? EmptySpawnPoints; + this.terrainPatches = terrainPatches ?? EmptyTerrainPatches; + this.flattenedCells = flattenedCells; + } + + public IReadOnlyList SpawnPoints => spawnPoints; + public IReadOnlyList 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; + } + } +} diff --git a/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs.meta b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs.meta new file mode 100644 index 00000000..ebc37c25 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldPrefabCollection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f36445faa7413742b67196646eb2631 \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs b/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs new file mode 100644 index 00000000..18269622 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs @@ -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; + } + } +} diff --git a/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs.meta b/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs.meta new file mode 100644 index 00000000..1d968364 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldSeedUtility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 24b3661eb20e8b44a96a4f8cdf49e466 \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs b/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs new file mode 100644 index 00000000..36622a8b --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs @@ -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 collections, + System.Func getBaseHeight) + { + if (collections == null || collections.Count == 0) + { + return WorldChunkPlacementPlan.Empty; + } + + List spawnPoints = new List(); + List terrainPatches = new List(); + HashSet flattenedCells = new HashSet(); + HashSet occupiedCells = new HashSet(); + 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 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 occupiedCells, + HashSet flattenedCells, + System.Func getBaseHeight, + out WorldSpawnPoint spawnPoint, + out WorldTerrainPatch patch, + out List 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 occupiedCells, + HashSet flattenedCells, + System.Func getBaseHeight, + out WorldTerrainPatch patch, + out List reservedCells) + { + int flattenPadding = Mathf.Max(entry.Clearance, entry.FlattenPadding); + RectInt flattenRect = ExpandRect(footprintRect, flattenPadding, chunkSize); + List flattened = CollectCells(chunkOrigin, flattenRect); + if (IntersectsOccupied(flattened, occupiedCells)) + { + patch = null; + reservedCells = null; + return false; + } + + List 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 uniqueFlattened = new HashSet(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 TryBuildAccessCorridor( + Vector2Int chunkOrigin, + int chunkSize, + RectInt flattenRect, + int searchRadius, + HashSet occupiedCells, + HashSet flattenedCells, + System.Func getBaseHeight) + { + if (HasAccessibleGroundNeighbor(chunkOrigin, flattenRect, flattenedCells, getBaseHeight)) + { + return new List(); + } + + 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 corridor = new List(); + 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 flattenedCells, System.Func 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 occupiedCells, + HashSet flattenedCells, + System.Func 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 flattenedCells, System.Func getBaseHeight) + { + return flattenedCells.Contains(worldCell) ? 0 : getBaseHeight(worldCell); + } + + private static bool IsGroundArea(Vector2Int chunkOrigin, RectInt rect, HashSet flattenedCells, System.Func 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 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 cells, HashSet occupiedCells) + { + for (int i = 0; i < cells.Count; i++) + { + if (occupiedCells.Contains(cells[i])) + { + return true; + } + } + + return false; + } + + private static List CollectCells(Vector2Int chunkOrigin, RectInt rect) + { + List result = new List(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); + } + } +} diff --git a/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs.meta b/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs.meta new file mode 100644 index 00000000..f67da9a3 --- /dev/null +++ b/Assets/Features/VoxelWorld/Runtime/WorldSpawnPlanner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c0ff8828be489ea4b8cef4f1f62b3a14 \ No newline at end of file diff --git a/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity b/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity index 0f400d8f..f8bd540e 100644 --- a/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity +++ b/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity @@ -408,6 +408,14 @@ PrefabInstance: propertyPath: streamTarget value: 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_RemovedGameObjects: [] m_AddedGameObjects: [] diff --git a/Assets/Scripts/Players/PlayerMoving.cs b/Assets/Scripts/Players/PlayerMoving.cs index 5e59a7d2..df6149aa 100644 --- a/Assets/Scripts/Players/PlayerMoving.cs +++ b/Assets/Scripts/Players/PlayerMoving.cs @@ -1,12 +1,18 @@ using FishNet.Object; using UnityEngine; +using UnityEngine.InputSystem; namespace Players { public sealed class PlayerMoving : NetworkBehaviour { [SerializeField] private float _moveSpeed = 5f; + [SerializeField] private float _sprintMultiplier = 2f; + [SerializeField] private float _slowMultiplier = 0.5f; [SerializeField] private CharacterController _characterController; + [SerializeField] private InputActionReference _moveAction; + [SerializeField] private InputActionReference _sprintAction; + [SerializeField] private InputActionReference _slowAction; private Transform _cameraTransform; @@ -16,22 +22,72 @@ namespace Players if (playerCamera != null) _cameraTransform = playerCamera.transform; } + + private void OnEnable() + { + EnableAction(_moveAction); + EnableAction(_sprintAction); + EnableAction(_slowAction); + } + + private void OnDisable() + { + DisableAction(_moveAction); + DisableAction(_sprintAction); + DisableAction(_slowAction); + } private void Update() { if (!IsOwner) return; - - float horizontal = Input.GetAxisRaw("Horizontal"); - float vertical = Input.GetAxisRaw("Vertical"); + + Vector2 moveInput = ReadMoveInput(); + float speedMultiplier = ReadSpeedMultiplier(); Transform directionSource = _cameraTransform != null ? _cameraTransform : transform; Vector3 forward = Vector3.ProjectOnPlane(directionSource.forward, Vector3.up).normalized; Vector3 right = Vector3.ProjectOnPlane(directionSource.right, Vector3.up).normalized; - Vector3 moveDirection = (right * horizontal + forward * vertical).normalized; - Vector3 offset = moveDirection * (_moveSpeed * Time.deltaTime); + Vector3 moveDirection = (right * moveInput.x + forward * moveInput.y).normalized; + Vector3 offset = moveDirection * (_moveSpeed * speedMultiplier * Time.deltaTime); _characterController.Move(offset); } + + private Vector2 ReadMoveInput() + { + InputAction action = _moveAction != null ? _moveAction.action : null; + return action != null ? action.ReadValue() : 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(); + } } } diff --git a/docs/tasks/Index.md b/docs/tasks/Index.md index 74212446..8f5c7b3d 100644 --- a/docs/tasks/Index.md +++ b/docs/tasks/Index.md @@ -58,11 +58,13 @@ | 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-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-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-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-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 | Реализовать миникарту и механизм сохранения открытой карты у хоста так, чтобы состояние миникарты было общим для всех игроков мира. | | TASK-0027 | ToDo | Highest | gameplay-core | unassigned | 3d | docs/tasks/items/TASK-0027.md | Перевести player movement на host-authoritative NavMesh pipeline с server-side path planning и shared debug path preview для всех клиентов. | | TASK-0028 | ToDo | Highest | ai | unassigned | 2d | docs/tasks/items/TASK-0028.md | Перевести основной runtime pathing mode на interest-cluster-based coverage windows, чтобы убрать seam-разрывы region-based NavMesh и учитывать multiplayer interest set. | diff --git a/docs/tasks/items/TASK-0019.md b/docs/tasks/items/TASK-0019.md index 0ff06b8f..e7bff733 100644 --- a/docs/tasks/items/TASK-0019.md +++ b/docs/tasks/items/TASK-0019.md @@ -4,7 +4,7 @@ title: Добавить генерацию данжей-предфабов по summary: Реализовать BackLog-задачу на детерминированное размещение dungeon prefab в voxel-мире: одинаковая позиция по seed, встраивание через stamp/carve и сохранение результата. priority: High area: worldgen -owner: unassigned +owner: pretty_kotik created: 2026-03-30 updated: 2026-03-30 execution_time: 1d6h diff --git a/docs/tasks/items/TASK-0025.md b/docs/tasks/items/TASK-0025.md new file mode 100644 index 00000000..14d812d8 --- /dev/null +++ b/docs/tasks/items/TASK-0025.md @@ -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. diff --git a/docs/tasks/items/TASK-0026.md b/docs/tasks/items/TASK-0026.md new file mode 100644 index 00000000..916fb10c --- /dev/null +++ b/docs/tasks/items/TASK-0026.md @@ -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.