지하 최적화

블록 프리팹 단위 -> 블록 청크 단위 스폰
기타 건설, 조준 관련 사이드이펙트 버그 수정
This commit is contained in:
2026-01-21 21:34:05 +09:00
parent db5db4b106
commit 59246a67bd
24 changed files with 2622 additions and 127 deletions

View File

@@ -2,6 +2,7 @@ using UnityEngine;
using Unity.Netcode;
using UnityEngine.InputSystem;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using System.Collections.Generic;
public class BuildManager : NetworkBehaviour
@@ -36,6 +37,9 @@ public class BuildManager : NetworkBehaviour
private Vector3Int _currentGridPos;
private PlayerInputActions _inputActions;
// Public property to check if currently in build mode
public bool IsBuildMode => _isBuildMode;
private Dictionary<Vector3Int, TunnelNode> _tunnelRegistry = new Dictionary<Vector3Int, TunnelNode>();
private HashSet<Vector3Int> _occupiedNodes = new HashSet<Vector3Int>();
@@ -139,10 +143,59 @@ public class BuildManager : NetworkBehaviour
return _tunnelRegistry.GetValueOrDefault(pos);
}
// Helper method to properly check if pointer is over UI with New Input System
private bool IsPointerOverUI()
{
if (EventSystem.current == null) return false;
// Use the new input system's pointer position
Vector2 pointerPosition = Mouse.current.position.ReadValue();
PointerEventData eventData = new PointerEventData(EventSystem.current)
{
position = pointerPosition
};
List<RaycastResult> results = new List<RaycastResult>();
EventSystem.current.RaycastAll(eventData, results);
// Filter out non-interactive UI elements (crosshair, HUD, etc.)
foreach (var result in results)
{
GameObject uiObject = result.gameObject;
// Ignore non-interactive UI elements by name
if (uiObject.name == "Crosshair" ||
uiObject.name.Contains("HUD") ||
uiObject.name.Contains("Display"))
{
continue;
}
// Check if the UI element is actually interactive (has a Selectable component)
UnityEngine.UI.Selectable selectable = uiObject.GetComponent<UnityEngine.UI.Selectable>();
if (selectable != null && selectable.interactable)
{
return true;
}
// Also check parent objects for Selectable components (in case we hit a child element)
selectable = uiObject.GetComponentInParent<UnityEngine.UI.Selectable>();
if (selectable != null && selectable.interactable)
{
return true;
}
}
// No interactive UI elements found
return false;
}
// 1. 건설 요청 시 실제 계산된 worldPos를 넘겨줍니다.
private void OnBuildRequested()
{
if (!_isBuildMode || EventSystem.current.IsPointerOverGameObject()) return;
if (!_isBuildMode) return;
if (IsPointerOverUI()) return;
// 고스트가 현재 위치한 '그 좌표'를 그대로 보냅니다.
RequestBuildRpc(_selectedTurretIndex, _currentGridPos, _ghostInstance.transform.position);
@@ -153,12 +206,30 @@ public class BuildManager : NetworkBehaviour
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestBuildRpc(int index, Vector3Int gridPos, Vector3 worldPos)
{
if (constructionSitePrefab == null)
{
Debug.LogError("[BuildManager] Construction site prefab is null!");
return;
}
// GridToWorld를 다시 계산하지 않고 전달받은 worldPos를 그대로 사용합니다.
GameObject siteObj = Instantiate(constructionSitePrefab, worldPos, Quaternion.identity);
siteObj.GetComponent<NetworkObject>().Spawn();
NetworkObject netObj = siteObj.GetComponent<NetworkObject>();
if (netObj == null)
{
Debug.LogError("[BuildManager] Construction site has no NetworkObject component!");
Destroy(siteObj);
return;
}
netObj.Spawn();
ConstructionSite site = siteObj.GetComponent<ConstructionSite>();
if (site != null) site.Initialize(index, gridPos);
if (site != null)
{
site.Initialize(index, gridPos);
}
}
public void SelectTurret(int index)

View File

@@ -2,6 +2,7 @@
/// <summary>
/// Mining behavior for pickaxes and similar tools.
/// Supports both legacy MineableBlock and new chunk-based MineableChunk.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Mining Behavior")]
public class MiningBehavior : ItemBehavior
@@ -27,7 +28,25 @@ public class MiningBehavior : ItemBehavior
{
if (target == null) return;
// Use IDamageable interface for all damageable objects
// Try chunk-based mining first (new system)
if (target.TryGetComponent<MineableChunk>(out var chunk))
{
// Get the specific block index from PlayerNetworkController
var playerController = user.GetComponent<PlayerNetworkController>();
if (playerController != null)
{
var chunkTarget = playerController.GetCurrentChunkTarget();
if (chunkTarget.hasHit && chunkTarget.chunk == chunk)
{
// Damage the specific block within the chunk
chunk.DamageBlockServerRpc(chunkTarget.blockIndex, (byte)Mathf.Min(255, damage));
return;
}
}
return;
}
// Fallback to legacy IDamageable interface
if (target.TryGetComponent<IDamageable>(out var damageable))
{
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, user));
@@ -37,8 +56,15 @@ public class MiningBehavior : ItemBehavior
public override string GetBlockedReason(GameObject user, GameObject target)
{
if (target == null) return "No target";
// Check for chunk
if (target.TryGetComponent<MineableChunk>(out _))
return null; // Chunks are always mineable
// Check for legacy damageable
if (!target.TryGetComponent<IDamageable>(out _))
return "Cannot mine this object";
return null;
}
}

View File

@@ -44,8 +44,13 @@ public class PlayerNetworkController : NetworkBehaviour
private PlayerActionHandler _actionHandler;
private RectTransform _crosshairRect;
private MineableBlock _currentTargetBlock; // 현재 강조 중인 블록 저장
private MineableBlock _lastHighlightedBlock;
private MineableBlock _currentTargetBlock; // 현재 강조 중인 블록 저장 (legacy)
private MineableBlock _lastHighlightedBlock; // legacy block targeting
// Chunk-based targeting (new system)
private MineableChunk _lastHighlightedChunk;
private int _lastHighlightedChunkBlockIndex = -1;
private ChunkInteractionHandler.ChunkHitResult _currentChunkTarget;
private CharacterController _controller;
private PlayerInputActions _inputActions;
@@ -209,13 +214,17 @@ public class PlayerNetworkController : NetworkBehaviour
{
if (!IsOwner || _actionHandler.IsBusy) return;
// Don't perform actions when in build mode
if (BuildManager.Instance != null && BuildManager.Instance.IsBuildMode) return;
ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null) return;
// Check if item has behavior (new system)
if (selectedItem.behavior != null)
{
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
// Get target - prioritize chunk system over legacy blocks
GameObject target = GetCurrentMiningTarget();
// Use the new behavior system
if (selectedItem.CanUse(gameObject, target))
@@ -334,51 +343,87 @@ public class PlayerNetworkController : NetworkBehaviour
{
if (!IsOwner || _crosshairRect == null) return;
// 1. 카메라 레이로 조준점 계산 (플레이어 몸통 무시)
// Use direct raycast from camera through crosshair position
// Use longer range (100m) from camera to catch all distances
Ray aimRay = Camera.main.ScreenPointToRay(_crosshairRect.position);
Vector3 worldAimPoint;
if (Physics.Raycast(aimRay, out RaycastHit mouseHit, 100f, ~ignoreDuringAim))
worldAimPoint = mouseHit.point;
else
worldAimPoint = aimRay.GetPoint(50f);
// 2. 캐릭터 가슴에서 조준점을 향하는 방향 계산
Vector3 origin = transform.position + Vector3.up * 1.2f;
Vector3 direction = (worldAimPoint - origin).normalized;
// 자기 자신 충돌 방지용 오프셋
Vector3 rayStart = origin + direction * 0.4f;
// 3. [중요] 실제 공격과 동일한 SphereCast 실행
RaycastHit blockHit;
bool hasTarget = Physics.SphereCast(rayStart, aimRadius, direction, out blockHit, attackRange - 0.4f, mineableLayer);
bool hasTarget = Physics.SphereCast(aimRay, aimRadius, out blockHit, 100f, mineableLayer);
// 4. 하이라이트 대상 업데이트
MineableBlock currentTarget = null;
// Filter by actual attack range from player
if (hasTarget)
{
currentTarget = blockHit.collider.GetComponentInParent<MineableBlock>();
Vector3 playerPos = transform.position + Vector3.up * 1.2f;
float distanceFromPlayer = Vector3.Distance(playerPos, blockHit.point);
if (distanceFromPlayer > attackRange)
{
hasTarget = false; // Too far from player to interact with
}
}
// 대상이 바뀌었을 때만 아웃라인 갱신 (최적화)
if (_lastHighlightedBlock != currentTarget)
// 4. 하이라이트 대상 업데이트 - 청크 시스템과 레거시 블록 모두 지원
MineableBlock currentLegacyTarget = null;
MineableChunk currentChunk = null;
int currentChunkBlockIndex = -1;
if (hasTarget)
{
// Try chunk first (new system)
var chunkHit = ChunkInteractionHandler.GetChunkHit(blockHit);
if (chunkHit.hasHit)
{
currentChunk = chunkHit.chunk;
currentChunkBlockIndex = chunkHit.blockIndex;
_currentChunkTarget = chunkHit;
}
else
{
// Fallback to legacy MineableBlock
currentLegacyTarget = blockHit.collider.GetComponentInParent<MineableBlock>();
_currentChunkTarget = ChunkInteractionHandler.ChunkHitResult.None;
}
}
else
{
_currentChunkTarget = ChunkInteractionHandler.ChunkHitResult.None;
}
// Update chunk highlight
bool chunkTargetChanged = (currentChunk != _lastHighlightedChunk) ||
(currentChunkBlockIndex != _lastHighlightedChunkBlockIndex);
if (chunkTargetChanged)
{
if (_lastHighlightedChunk != null)
_lastHighlightedChunk.SetHighlight(false);
if (currentChunk != null)
currentChunk.SetHighlight(true, currentChunkBlockIndex);
_lastHighlightedChunk = currentChunk;
_lastHighlightedChunkBlockIndex = currentChunkBlockIndex;
}
// Update legacy block highlight
if (_lastHighlightedBlock != currentLegacyTarget)
{
if (_lastHighlightedBlock != null) _lastHighlightedBlock.SetHighlight(false);
if (currentTarget != null) currentTarget.SetHighlight(true);
_lastHighlightedBlock = currentTarget;
if (currentLegacyTarget != null) currentLegacyTarget.SetHighlight(true);
_lastHighlightedBlock = currentLegacyTarget;
}
// 기즈모 디버그 데이터 동기화
_debugOrigin = rayStart;
_debugDir = direction;
_debugHit = hasTarget;
_debugDist = hasTarget ? blockHit.distance : (attackRange - 0.4f);
Ray debugRay = Camera.main.ScreenPointToRay(_crosshairRect.position);
_debugOrigin = debugRay.origin;
_debugDir = debugRay.direction;
_debugHit = hasTarget && (currentChunk != null || currentLegacyTarget != null);
_debugDist = hasTarget ? blockHit.distance : attackRange;
// 크로스헤어 이미지 교체
bool hasValidTarget = currentChunk != null || currentLegacyTarget != null;
if (crosshairUI != null)
{
crosshairUI.sprite = hasTarget ? targetCrosshair : idleCrosshair;
crosshairUI.color = hasTarget ? Color.green : Color.white;
crosshairUI.sprite = hasValidTarget ? targetCrosshair : idleCrosshair;
crosshairUI.color = hasValidTarget ? Color.green : Color.white;
}
}
@@ -420,11 +465,33 @@ public class PlayerNetworkController : NetworkBehaviour
private void RevealSurroundings()
{
// Use FogOfWarManager's revealRadius if available, fallback to visionRadius
float currentRevealRadius = visionRadius;
if (FogOfWarManager.Instance != null)
{
currentRevealRadius = FogOfWarManager.Instance.revealRadius;
}
// 시야 반경 내의 블록 감지
Collider[] hitBlocks = Physics.OverlapSphere(transform.position, visionRadius, mineableLayer);
Collider[] hitBlocks = Physics.OverlapSphere(transform.position, currentRevealRadius, mineableLayer);
foreach (var col in hitBlocks)
{
// Try chunk-based reveal first (new system)
if (col.TryGetComponent<MineableChunk>(out var chunk))
{
// Update local visibility (for fog of war visual states)
chunk.UpdateLocalVisibility(transform.position, currentRevealRadius);
// Request server to mark blocks as discovered (permanent)
if (IsOwner)
{
RequestChunkRevealServerRpc(chunk.GetComponent<NetworkObject>().NetworkObjectId, transform.position, currentRevealRadius);
}
continue;
}
// Fallback to legacy MineableBlock
if (col.TryGetComponent<MineableBlock>(out var block))
{
// 1. [로컬] 내 화면에서 이 블록을 보이게 함 (실시간 시야)
@@ -451,6 +518,18 @@ public class PlayerNetworkController : NetworkBehaviour
}
}
[ServerRpc]
private void RequestChunkRevealServerRpc(ulong chunkNetId, Vector3 playerPos, float radius)
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(chunkNetId, out var netObj))
{
if (netObj.TryGetComponent<MineableChunk>(out var chunk))
{
chunk.RevealBlocksInRadius(playerPos, radius);
}
}
}
private IEnumerator ActionRoutine(float duration, string animTrigger, Action actionLogic)
{
// 1. 상태 잠금
@@ -516,6 +595,9 @@ public class PlayerNetworkController : NetworkBehaviour
{
if (_actionHandler.IsBusy) return;
// Don't perform actions when in build mode
if (BuildManager.Instance != null && BuildManager.Instance.IsBuildMode) return;
ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null || selectedItem.behavior == null) return;
@@ -525,7 +607,8 @@ public class PlayerNetworkController : NetworkBehaviour
// Skip if non-repeatable action already executed once
if (!actionDesc.CanRepeat && _hasExecutedOnce) return;
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
// Get target - prioritize chunk system over legacy blocks
GameObject target = GetCurrentMiningTarget();
if (selectedItem.CanUse(gameObject, target))
{
@@ -537,6 +620,29 @@ public class PlayerNetworkController : NetworkBehaviour
}
}
/// <summary>
/// Get the current mining target (chunk or legacy block)
/// </summary>
private GameObject GetCurrentMiningTarget()
{
// Prioritize chunk target
if (_currentChunkTarget.hasHit && _currentChunkTarget.chunk != null)
{
return _currentChunkTarget.chunk.gameObject;
}
// Fallback to legacy block
return _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
}
/// <summary>
/// Get the current chunk target info (for MiningBehavior)
/// </summary>
public ChunkInteractionHandler.ChunkHitResult GetCurrentChunkTarget()
{
return _currentChunkTarget;
}
private void OnDrawGizmos()
{
if (!Application.isPlaying || !IsOwner) return;

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f0bcafb0d0408ab4f893e9c51c9e60e5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,193 @@
using Unity.Netcode;
/// <summary>
/// Compact block state struct for chunk-based storage.
/// 3 bytes per block: type (1), health (1), flags (1)
/// </summary>
public struct BlockData : INetworkSerializable
{
/// <summary>
/// Block type: 0=empty/air, 1=normal stone, 2=resource ore
/// </summary>
public byte blockType;
/// <summary>
/// Block health: 0-255 (0 = destroyed)
/// </summary>
public byte health;
/// <summary>
/// Block flags: bit 0 = isDiscovered (fog of war)
/// </summary>
public byte flags;
// Block type constants
public const byte TYPE_EMPTY = 0;
public const byte TYPE_NORMAL = 1;
public const byte TYPE_RESOURCE = 2;
// Flag bit masks
public const byte FLAG_DISCOVERED = 1 << 0; // Has been seen at least once (networked/permanent)
/// <summary>
/// Whether this block is empty (air or destroyed)
/// </summary>
public bool IsEmpty => blockType == TYPE_EMPTY || health == 0;
/// <summary>
/// Whether this block has been discovered by players
/// </summary>
public bool IsDiscovered
{
get => (flags & FLAG_DISCOVERED) != 0;
set
{
if (value)
flags |= FLAG_DISCOVERED;
else
flags &= unchecked((byte)~FLAG_DISCOVERED);
}
}
/// <summary>
/// Whether this block is a resource block
/// </summary>
public bool IsResource => blockType == TYPE_RESOURCE;
/// <summary>
/// Create an empty block
/// </summary>
public static BlockData Empty => new BlockData
{
blockType = TYPE_EMPTY,
health = 0,
flags = 0
};
/// <summary>
/// Create a normal stone block with full health
/// </summary>
public static BlockData Normal(byte maxHealth = 100) => new BlockData
{
blockType = TYPE_NORMAL,
health = maxHealth,
flags = 0
};
/// <summary>
/// Create a resource ore block with full health
/// </summary>
public static BlockData Resource(byte maxHealth = 150) => new BlockData
{
blockType = TYPE_RESOURCE,
health = maxHealth,
flags = 0
};
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref blockType);
serializer.SerializeValue(ref health);
serializer.SerializeValue(ref flags);
}
}
/// <summary>
/// Network-serializable container for entire chunk state.
/// Used for initial sync when clients connect.
/// </summary>
public struct ChunkState : INetworkSerializable
{
public BlockData[] blocks;
public const int CHUNK_SIZE = 4;
public const int BLOCKS_PER_CHUNK = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 64
public ChunkState(int size)
{
blocks = new BlockData[size];
}
/// <summary>
/// Ensure blocks array is initialized
/// </summary>
private void EnsureInitialized()
{
if (blocks == null)
{
blocks = new BlockData[BLOCKS_PER_CHUNK];
}
}
/// <summary>
/// Get block at local coordinates within the chunk
/// </summary>
public BlockData GetBlock(int x, int y, int z)
{
if (blocks == null) return BlockData.Empty;
int index = x + y * CHUNK_SIZE + z * CHUNK_SIZE * CHUNK_SIZE;
if (index < 0 || index >= blocks.Length) return BlockData.Empty;
return blocks[index];
}
/// <summary>
/// Set block at local coordinates within the chunk
/// </summary>
public void SetBlock(int x, int y, int z, BlockData block)
{
EnsureInitialized();
int index = x + y * CHUNK_SIZE + z * CHUNK_SIZE * CHUNK_SIZE;
if (index >= 0 && index < blocks.Length)
{
blocks[index] = block;
}
}
/// <summary>
/// Convert local index to local 3D coordinates
/// </summary>
public static (int x, int y, int z) IndexToLocal(int index)
{
int x = index % CHUNK_SIZE;
int y = (index / CHUNK_SIZE) % CHUNK_SIZE;
int z = index / (CHUNK_SIZE * CHUNK_SIZE);
return (x, y, z);
}
/// <summary>
/// Convert local 3D coordinates to index
/// </summary>
public static int LocalToIndex(int x, int y, int z)
{
return x + y * CHUNK_SIZE + z * CHUNK_SIZE * CHUNK_SIZE;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
// Serialize array length first
int length = BLOCKS_PER_CHUNK;
serializer.SerializeValue(ref length);
if (serializer.IsReader)
{
blocks = new BlockData[length];
}
else if (blocks == null)
{
blocks = new BlockData[length];
}
for (int i = 0; i < length; i++)
{
blocks[i].NetworkSerialize(serializer);
}
}
/// <summary>
/// Create a default initialized ChunkState
/// </summary>
public static ChunkState CreateEmpty()
{
return new ChunkState(BLOCKS_PER_CHUNK);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2b004a2f2115024ea956fdccbb62cf1

View File

@@ -0,0 +1,215 @@
using UnityEngine;
/// <summary>
/// Utility struct for chunk coordinate conversions.
/// Handles conversions between world, grid, chunk, and local block coordinates.
/// </summary>
public struct ChunkCoord
{
public const int CHUNK_SIZE = 4;
/// <summary>
/// Chunk position in chunk coordinates (not world or grid)
/// </summary>
public Vector3Int chunkPos;
public ChunkCoord(Vector3Int pos)
{
chunkPos = pos;
}
public ChunkCoord(int x, int y, int z)
{
chunkPos = new Vector3Int(x, y, z);
}
/// <summary>
/// Get the grid position of the chunk's origin (corner with smallest coordinates)
/// </summary>
public Vector3Int GridOrigin => new Vector3Int(
chunkPos.x * CHUNK_SIZE,
chunkPos.y * CHUNK_SIZE,
chunkPos.z * CHUNK_SIZE
);
/// <summary>
/// Get world position of chunk origin using BuildManager's grid system
/// </summary>
public Vector3 WorldOrigin
{
get
{
if (BuildManager.Instance != null)
{
return BuildManager.Instance.GridToWorld(GridOrigin);
}
// Fallback if BuildManager not available
return new Vector3(
GridOrigin.x + 0.5f,
GridOrigin.y + 0.5f,
GridOrigin.z + 0.5f
);
}
}
/// <summary>
/// Convert grid coordinates to chunk coordinates
/// </summary>
public static ChunkCoord FromGridPos(Vector3Int gridPos)
{
return new ChunkCoord(
Mathf.FloorToInt((float)gridPos.x / CHUNK_SIZE),
Mathf.FloorToInt((float)gridPos.y / CHUNK_SIZE),
Mathf.FloorToInt((float)gridPos.z / CHUNK_SIZE)
);
}
/// <summary>
/// Convert world position to chunk coordinates
/// </summary>
public static ChunkCoord FromWorldPos(Vector3 worldPos)
{
Vector3Int gridPos;
if (BuildManager.Instance != null)
{
gridPos = BuildManager.Instance.WorldToGrid3D(worldPos);
}
else
{
// Fallback
gridPos = new Vector3Int(
Mathf.RoundToInt(worldPos.x - 0.5f),
Mathf.RoundToInt(worldPos.y - 0.5f),
Mathf.RoundToInt(worldPos.z - 0.5f)
);
}
return FromGridPos(gridPos);
}
/// <summary>
/// Get local block coordinates within chunk from grid position
/// </summary>
public static Vector3Int GridToLocal(Vector3Int gridPos)
{
// Use modulo to get local position, handling negative coords
int x = ((gridPos.x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
int y = ((gridPos.y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
int z = ((gridPos.z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
return new Vector3Int(x, y, z);
}
/// <summary>
/// Convert local block coordinates to grid position
/// </summary>
public Vector3Int LocalToGrid(Vector3Int localPos)
{
return GridOrigin + localPos;
}
/// <summary>
/// Convert local block coordinates to world position
/// </summary>
public Vector3 LocalToWorld(Vector3Int localPos)
{
Vector3Int gridPos = LocalToGrid(localPos);
if (BuildManager.Instance != null)
{
return BuildManager.Instance.GridToWorld(gridPos);
}
return new Vector3(gridPos.x + 0.5f, gridPos.y + 0.5f, gridPos.z + 0.5f);
}
/// <summary>
/// Convert local index to local 3D coordinates
/// </summary>
public static Vector3Int IndexToLocal(int index)
{
int x = index % CHUNK_SIZE;
int y = (index / CHUNK_SIZE) % CHUNK_SIZE;
int z = index / (CHUNK_SIZE * CHUNK_SIZE);
return new Vector3Int(x, y, z);
}
/// <summary>
/// Convert local 3D coordinates to index
/// </summary>
public static int LocalToIndex(Vector3Int localPos)
{
return localPos.x + localPos.y * CHUNK_SIZE + localPos.z * CHUNK_SIZE * CHUNK_SIZE;
}
/// <summary>
/// Convert local coordinates (int) to index
/// </summary>
public static int LocalToIndex(int x, int y, int z)
{
return x + y * CHUNK_SIZE + z * CHUNK_SIZE * CHUNK_SIZE;
}
/// <summary>
/// Check if local coordinates are within valid range
/// </summary>
public static bool IsValidLocal(int x, int y, int z)
{
return x >= 0 && x < CHUNK_SIZE &&
y >= 0 && y < CHUNK_SIZE &&
z >= 0 && z < CHUNK_SIZE;
}
/// <summary>
/// Check if local position is within valid range
/// </summary>
public static bool IsValidLocal(Vector3Int local)
{
return IsValidLocal(local.x, local.y, local.z);
}
/// <summary>
/// Convert world hit point to local block index within chunk
/// </summary>
public int WorldPointToLocalIndex(Vector3 worldPoint)
{
// Get chunk origin in world space
Vector3 chunkWorldOrigin = WorldOrigin;
// Calculate offset from chunk origin (in grid units, assuming 1 unit per block)
float cellSize = BuildManager.Instance != null ? BuildManager.Instance.cellSize : 1f;
Vector3 offset = (worldPoint - chunkWorldOrigin) / cellSize;
// Convert to local coordinates (add small epsilon to handle edge cases)
int lx = Mathf.Clamp(Mathf.FloorToInt(offset.x + 0.5f), 0, CHUNK_SIZE - 1);
int ly = Mathf.Clamp(Mathf.FloorToInt(offset.y + 0.5f), 0, CHUNK_SIZE - 1);
int lz = Mathf.Clamp(Mathf.FloorToInt(offset.z + 0.5f), 0, CHUNK_SIZE - 1);
return LocalToIndex(lx, ly, lz);
}
public override bool Equals(object obj)
{
if (obj is ChunkCoord other)
{
return chunkPos == other.chunkPos;
}
return false;
}
public override int GetHashCode()
{
return chunkPos.GetHashCode();
}
public static bool operator ==(ChunkCoord a, ChunkCoord b)
{
return a.chunkPos == b.chunkPos;
}
public static bool operator !=(ChunkCoord a, ChunkCoord b)
{
return a.chunkPos != b.chunkPos;
}
public override string ToString()
{
return $"ChunkCoord({chunkPos.x}, {chunkPos.y}, {chunkPos.z})";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08589d2f166b446418cc8b61105e6d01

View File

@@ -0,0 +1,126 @@
using UnityEngine;
/// <summary>
/// Handles interaction between players and chunk-based blocks.
/// Converts raycast hits to block indices and manages damage/reveal requests.
/// </summary>
public static class ChunkInteractionHandler
{
/// <summary>
/// Result of a chunk interaction query
/// </summary>
public struct ChunkHitResult
{
public bool hasHit;
public MineableChunk chunk;
public int blockIndex;
public Vector3 hitPoint;
public Vector3 blockWorldPosition;
public BlockData blockData;
public static ChunkHitResult None => new ChunkHitResult { hasHit = false };
}
/// <summary>
/// Try to get chunk and block info from a raycast hit
/// </summary>
public static ChunkHitResult GetChunkHit(RaycastHit hit)
{
// Try to get MineableChunk from hit collider
MineableChunk chunk = hit.collider.GetComponentInParent<MineableChunk>();
if (chunk == null)
{
return ChunkHitResult.None;
}
// Convert hit point to block index
// Push slightly into the surface using the normal to get the correct block
Vector3 insidePoint = hit.point - hit.normal * 0.01f;
int blockIndex = chunk.WorldPointToBlockIndex(insidePoint);
BlockData blockData = chunk.GetBlock(blockIndex);
// Skip if block is empty
if (blockData.IsEmpty)
{
return ChunkHitResult.None;
}
return new ChunkHitResult
{
hasHit = true,
chunk = chunk,
blockIndex = blockIndex,
hitPoint = hit.point,
blockWorldPosition = chunk.GetBlockWorldPosition(blockIndex),
blockData = blockData
};
}
/// <summary>
/// Try to damage a block at a raycast hit point
/// </summary>
public static bool TryDamageAtPoint(RaycastHit hit, float damage)
{
ChunkHitResult result = GetChunkHit(hit);
if (!result.hasHit) return false;
// Request damage on server
result.chunk.DamageBlockServerRpc(result.blockIndex, (byte)Mathf.Min(255, damage));
return true;
}
/// <summary>
/// Try to damage a block directly on a known chunk
/// </summary>
public static bool TryDamageBlock(MineableChunk chunk, int blockIndex, float damage)
{
if (chunk == null) return false;
BlockData block = chunk.GetBlock(blockIndex);
if (block.IsEmpty) return false;
chunk.DamageBlockServerRpc(blockIndex, (byte)Mathf.Min(255, damage));
return true;
}
/// <summary>
/// Try to reveal a block at a raycast hit point
/// </summary>
public static bool TryRevealAtPoint(RaycastHit hit)
{
MineableChunk chunk = hit.collider.GetComponentInParent<MineableChunk>();
if (chunk == null) return false;
int blockIndex = chunk.WorldPointToBlockIndex(hit.point - hit.normal * 0.1f);
BlockData blockData = chunk.GetBlock(blockIndex);
if (blockData.IsEmpty || blockData.IsDiscovered) return false;
chunk.RevealBlockServerRpc(blockIndex);
return true;
}
/// <summary>
/// Perform a sphere cast to find a targetable block in a chunk
/// </summary>
public static ChunkHitResult SphereCastForBlock(Vector3 origin, Vector3 direction, float radius, float maxDistance, LayerMask chunkLayer)
{
if (Physics.SphereCast(origin, radius, direction, out RaycastHit hit, maxDistance, chunkLayer))
{
return GetChunkHit(hit);
}
return ChunkHitResult.None;
}
/// <summary>
/// Perform a raycast to find a targetable block in a chunk
/// </summary>
public static ChunkHitResult RaycastForBlock(Vector3 origin, Vector3 direction, float maxDistance, LayerMask chunkLayer)
{
if (Physics.Raycast(origin, direction, out RaycastHit hit, maxDistance, chunkLayer))
{
return GetChunkHit(hit);
}
return ChunkHitResult.None;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3fc7fb23f18204f4eafbf4395b12aba5

View File

@@ -0,0 +1,330 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Builds optimized meshes for chunks with face culling.
/// Only renders faces that are exposed (adjacent to empty blocks or chunk boundaries).
/// Uses submeshes for different block types (normal/resource).
/// </summary>
public static class ChunkMeshBuilder
{
private const int CHUNK_SIZE = ChunkCoord.CHUNK_SIZE;
// Face directions
private static readonly Vector3Int[] FaceDirections = new Vector3Int[]
{
Vector3Int.right, // +X
Vector3Int.left, // -X
Vector3Int.up, // +Y
Vector3Int.down, // -Y
new Vector3Int(0, 0, 1), // +Z
new Vector3Int(0, 0, -1) // -Z
};
// Face normals matching directions
private static readonly Vector3[] FaceNormals = new Vector3[]
{
Vector3.right,
Vector3.left,
Vector3.up,
Vector3.down,
Vector3.forward,
Vector3.back
};
// Vertices for each face (relative to block center)
private static readonly Vector3[][] FaceVertices = new Vector3[][]
{
// +X face
new Vector3[] {
new Vector3(0.5f, -0.5f, -0.5f),
new Vector3(0.5f, 0.5f, -0.5f),
new Vector3(0.5f, 0.5f, 0.5f),
new Vector3(0.5f, -0.5f, 0.5f)
},
// -X face
new Vector3[] {
new Vector3(-0.5f, -0.5f, 0.5f),
new Vector3(-0.5f, 0.5f, 0.5f),
new Vector3(-0.5f, 0.5f, -0.5f),
new Vector3(-0.5f, -0.5f, -0.5f)
},
// +Y face
new Vector3[] {
new Vector3(-0.5f, 0.5f, -0.5f),
new Vector3(-0.5f, 0.5f, 0.5f),
new Vector3( 0.5f, 0.5f, 0.5f),
new Vector3( 0.5f, 0.5f, -0.5f)
},
// -Y face
new Vector3[] {
new Vector3(-0.5f, -0.5f, 0.5f),
new Vector3(-0.5f, -0.5f, -0.5f),
new Vector3( 0.5f, -0.5f, -0.5f),
new Vector3( 0.5f, -0.5f, 0.5f)
},
// +Z face
new Vector3[] {
new Vector3(-0.5f, -0.5f, 0.5f),
new Vector3( 0.5f, -0.5f, 0.5f),
new Vector3( 0.5f, 0.5f, 0.5f),
new Vector3(-0.5f, 0.5f, 0.5f)
},
// -Z face
new Vector3[] {
new Vector3( 0.5f, -0.5f, -0.5f),
new Vector3(-0.5f, -0.5f, -0.5f),
new Vector3(-0.5f, 0.5f, -0.5f),
new Vector3( 0.5f, 0.5f, -0.5f)
}
};
// UV coordinates for each face
private static readonly Vector2[] FaceUVs = new Vector2[]
{
new Vector2(0, 0),
new Vector2(1, 0),
new Vector2(1, 1),
new Vector2(0, 1)
};
/// <summary>
/// Interface for querying neighboring chunks for cross-chunk face culling
/// </summary>
public interface INeighborProvider
{
/// <summary>
/// Check if block at world grid position is solid (for face culling)
/// </summary>
bool IsBlockSolid(Vector3Int gridPos);
}
/// <summary>
/// Visibility check delegate for fog of war
/// </summary>
public delegate bool VisibilityChecker(int blockIndex);
/// <summary>
/// Build mesh for a chunk with face culling.
/// Returns a mesh with two submeshes: 0=normal blocks, 1=resource blocks
/// </summary>
public static Mesh BuildMesh(ChunkState state, ChunkCoord coord, INeighborProvider neighborProvider = null, bool onlyDiscovered = true)
{
return BuildMeshWithVisibility(state, coord, neighborProvider, onlyDiscovered, null);
}
/// <summary>
/// Build mesh for a chunk with face culling and fog of war support.
/// Returns a mesh with 4 submeshes: 0=normal-visible, 1=resource-visible, 2=normal-dark, 3=resource-dark
/// </summary>
public static Mesh BuildMeshWithVisibility(ChunkState state, ChunkCoord coord, INeighborProvider neighborProvider, bool onlyDiscovered, VisibilityChecker isVisible)
{
// Lists for 4 submeshes: normal-visible, resource-visible, normal-dark, resource-dark
var meshData = new MeshData[4];
for (int i = 0; i < 4; i++)
{
meshData[i] = new MeshData();
}
// Handle null blocks array
if (state.blocks == null)
{
return CreateEmptyMesh(coord, 4);
}
// Process each block
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
BlockData block = state.blocks[i];
// Skip empty blocks
if (block.IsEmpty) continue;
// Skip undiscovered blocks if onlyDiscovered is true
if (onlyDiscovered && !block.IsDiscovered) continue;
Vector3Int localPos = ChunkCoord.IndexToLocal(i);
Vector3 blockOffset = new Vector3(localPos.x, localPos.y, localPos.z);
// Determine which submesh to use based on block type and visibility
bool currentlyVisible = isVisible != null ? isVisible(i) : true;
int submeshIndex;
if (block.IsResource)
{
submeshIndex = currentlyVisible ? 1 : 3; // resource-visible or resource-dark
}
else
{
submeshIndex = currentlyVisible ? 0 : 2; // normal-visible or normal-dark
}
var data = meshData[submeshIndex];
// Check each face
for (int f = 0; f < 6; f++)
{
Vector3Int neighborLocal = localPos + FaceDirections[f];
bool shouldRenderFace = false;
if (ChunkCoord.IsValidLocal(neighborLocal))
{
int neighborIndex = ChunkCoord.LocalToIndex(neighborLocal);
BlockData neighborBlock = state.blocks[neighborIndex];
shouldRenderFace = neighborBlock.IsEmpty || (onlyDiscovered && !neighborBlock.IsDiscovered);
}
else
{
if (neighborProvider != null)
{
Vector3Int neighborGridPos = coord.LocalToGrid(neighborLocal);
shouldRenderFace = !neighborProvider.IsBlockSolid(neighborGridPos);
}
else
{
shouldRenderFace = true;
}
}
if (shouldRenderFace)
{
AddFace(data.vertices, data.normals, data.uvs, data.triangles, blockOffset, f);
}
}
}
// Create the mesh
Mesh mesh = new Mesh();
mesh.name = $"Chunk_{coord.chunkPos.x}_{coord.chunkPos.y}_{coord.chunkPos.z}";
// Combine all vertices from all submeshes
int totalVerts = 0;
int[] vertOffsets = new int[4];
for (int i = 0; i < 4; i++)
{
vertOffsets[i] = totalVerts;
totalVerts += meshData[i].vertices.Count;
}
var allVertices = new Vector3[totalVerts];
var allNormals = new Vector3[totalVerts];
var allUVs = new Vector2[totalVerts];
for (int i = 0; i < 4; i++)
{
meshData[i].vertices.CopyTo(allVertices, vertOffsets[i]);
meshData[i].normals.CopyTo(allNormals, vertOffsets[i]);
meshData[i].uvs.CopyTo(allUVs, vertOffsets[i]);
}
mesh.vertices = allVertices;
mesh.normals = allNormals;
mesh.uv = allUVs;
// Set submeshes with adjusted triangle indices
mesh.subMeshCount = 4;
for (int i = 0; i < 4; i++)
{
var triangles = meshData[i].triangles;
for (int t = 0; t < triangles.Count; t++)
{
triangles[t] += vertOffsets[i];
}
mesh.SetTriangles(triangles, i);
}
mesh.RecalculateBounds();
return mesh;
}
/// <summary>
/// Helper class to hold mesh data for each submesh
/// </summary>
private class MeshData
{
public List<Vector3> vertices = new List<Vector3>();
public List<Vector3> normals = new List<Vector3>();
public List<Vector2> uvs = new List<Vector2>();
public List<int> triangles = new List<int>();
}
/// <summary>
/// Create an empty mesh with proper submesh configuration
/// </summary>
private static Mesh CreateEmptyMesh(ChunkCoord coord, int submeshCount = 4)
{
Mesh mesh = new Mesh();
mesh.name = $"Chunk_{coord.chunkPos.x}_{coord.chunkPos.y}_{coord.chunkPos.z}_Empty";
mesh.subMeshCount = submeshCount;
for (int i = 0; i < submeshCount; i++)
{
mesh.SetTriangles(new int[0], i);
}
return mesh;
}
/// <summary>
/// Add a face to the mesh data
/// </summary>
private static void AddFace(List<Vector3> vertices, List<Vector3> normals, List<Vector2> uvs, List<int> triangles, Vector3 blockOffset, int faceIndex)
{
int startVertex = vertices.Count;
// Add vertices
for (int i = 0; i < 4; i++)
{
vertices.Add(FaceVertices[faceIndex][i] + blockOffset);
normals.Add(FaceNormals[faceIndex]);
uvs.Add(FaceUVs[i]);
}
// Add triangles (two triangles per face)
triangles.Add(startVertex);
triangles.Add(startVertex + 1);
triangles.Add(startVertex + 2);
triangles.Add(startVertex);
triangles.Add(startVertex + 2);
triangles.Add(startVertex + 3);
}
/// <summary>
/// Count visible faces in a chunk (for debugging/stats)
/// </summary>
public static int CountVisibleFaces(ChunkState state, bool onlyDiscovered = true)
{
if (state.blocks == null) return 0;
int faceCount = 0;
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
BlockData block = state.blocks[i];
if (block.IsEmpty) continue;
if (onlyDiscovered && !block.IsDiscovered) continue;
Vector3Int localPos = ChunkCoord.IndexToLocal(i);
for (int f = 0; f < 6; f++)
{
Vector3Int neighborLocal = localPos + FaceDirections[f];
if (ChunkCoord.IsValidLocal(neighborLocal))
{
int neighborIndex = ChunkCoord.LocalToIndex(neighborLocal);
BlockData neighborBlock = state.blocks[neighborIndex];
if (neighborBlock.IsEmpty || (onlyDiscovered && !neighborBlock.IsDiscovered))
{
faceCount++;
}
}
else
{
faceCount++;
}
}
}
return faceCount;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02008c1a2b561f049ae0f8b78dd9d22c

View File

@@ -0,0 +1,389 @@
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Generates underground terrain using chunk-based system.
/// Replaces individual MineableBlock NetworkObjects with MineableChunk NetworkObjects.
/// </summary>
public class ChunkedUndergroundGenerator : NetworkBehaviour
{
public static ChunkedUndergroundGenerator Instance { get; private set; }
[Header("Generation Range (in blocks)")]
[SerializeField] private Vector3Int generationRange = new Vector3Int(20, 30, 10);
[SerializeField] private float noiseScale = 0.12f;
[Header("Thresholds (0 to 1)")]
[SerializeField, Range(0, 1)] private float hollowThreshold = 0.35f;
[SerializeField, Range(0, 1)] private float baseResourceThreshold = 0.85f;
[Header("Resource Distribution")]
[SerializeField] private float resourceNoiseScale = 0.25f; // Larger = more spread out
[SerializeField, Range(0, 1)] private float resourceSpawnChance = 0.3f; // Additional random chance
[Header("Depth Settings")]
[SerializeField] private bool increaseResourceWithDepth = true;
[SerializeField] private float depthFactor = 0.003f;
[Header("Block Health")]
[SerializeField] private byte normalBlockHealth = 100;
[SerializeField] private byte resourceBlockHealth = 150;
[Header("Chunk Prefab")]
[SerializeField] private GameObject chunkPrefab;
[Header("Organization")]
[SerializeField] private string containerName = "UndergroundChunks";
private Transform _chunkContainer;
// Chunk registry for neighbor queries
private Dictionary<Vector3Int, MineableChunk> _chunkRegistry = new Dictionary<Vector3Int, MineableChunk>();
// Noise seeds
private float _seedX, _seedY, _seedZ;
private float _resourceSeedX, _resourceSeedY, _resourceSeedZ;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
return;
}
_seedX = Random.Range(0f, 99999f);
_seedY = Random.Range(0f, 99999f);
_seedZ = Random.Range(0f, 99999f);
// Separate seeds for resource distribution
_resourceSeedX = Random.Range(0f, 99999f);
_resourceSeedY = Random.Range(0f, 99999f);
_resourceSeedZ = Random.Range(0f, 99999f);
// Create container for hierarchy organization
_chunkContainer = new GameObject(containerName).transform;
}
public override void OnNetworkSpawn()
{
// Register with MineableChunk for neighbor queries
MineableChunk.SetChunkManager(this);
if (IsServer)
{
GenerateChunks();
}
}
/// <summary>
/// Generate all chunks for the underground area
/// </summary>
private void GenerateChunks()
{
Vector3Int originGrid = BuildManager.Instance.WorldToGrid3D(transform.position);
// Calculate chunk range
int chunkSizeX = Mathf.CeilToInt((float)generationRange.x / ChunkCoord.CHUNK_SIZE);
int chunkSizeY = Mathf.CeilToInt((float)generationRange.y / ChunkCoord.CHUNK_SIZE);
int chunkSizeZ = Mathf.CeilToInt((float)generationRange.z / ChunkCoord.CHUNK_SIZE);
Debug.Log($"[ChunkedGenerator] Generating {chunkSizeX}x{chunkSizeY}x{chunkSizeZ} chunks = {chunkSizeX * chunkSizeY * chunkSizeZ} total");
int chunksGenerated = 0;
int chunksSkipped = 0;
// Generate chunks
for (int cx = 0; cx < chunkSizeX; cx++)
{
for (int cy = 0; cy < chunkSizeY; cy++)
{
for (int cz = 0; cz < chunkSizeZ; cz++)
{
// Calculate chunk grid origin (going downward for Y)
Vector3Int chunkGridOrigin = originGrid + new Vector3Int(
cx * ChunkCoord.CHUNK_SIZE,
-cy * ChunkCoord.CHUNK_SIZE, // Negative Y (underground)
cz * ChunkCoord.CHUNK_SIZE
);
// Generate block data for this chunk
ChunkState state = GenerateChunkState(chunkGridOrigin);
// Check if chunk has any blocks
bool hasBlocks = false;
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
if (!state.blocks[i].IsEmpty)
{
hasBlocks = true;
break;
}
}
if (!hasBlocks)
{
chunksSkipped++;
continue;
}
// Spawn chunk
SpawnChunk(chunkGridOrigin, state);
chunksGenerated++;
}
}
}
Debug.Log($"[ChunkedGenerator] Generated {chunksGenerated} chunks, skipped {chunksSkipped} empty chunks");
}
/// <summary>
/// Generate block state for a chunk
/// </summary>
private ChunkState GenerateChunkState(Vector3Int chunkGridOrigin)
{
ChunkState state = ChunkState.CreateEmpty();
for (int lx = 0; lx < ChunkCoord.CHUNK_SIZE; lx++)
{
for (int ly = 0; ly < ChunkCoord.CHUNK_SIZE; ly++)
{
for (int lz = 0; lz < ChunkCoord.CHUNK_SIZE; lz++)
{
Vector3Int gridPos = chunkGridOrigin + new Vector3Int(lx, ly, lz);
int index = ChunkCoord.LocalToIndex(lx, ly, lz);
// Check if within generation range
Vector3Int originGrid = BuildManager.Instance.WorldToGrid3D(transform.position);
Vector3Int offset = gridPos - originGrid;
if (offset.x < 0 || offset.x >= generationRange.x ||
offset.y > 0 || offset.y <= -generationRange.y ||
offset.z < 0 || offset.z >= generationRange.z)
{
state.blocks[index] = BlockData.Empty;
continue;
}
// Sample noise
float noise = Get3DNoise(gridPos.x, gridPos.y, gridPos.z);
// Check hollow threshold
if (noise < hollowThreshold)
{
state.blocks[index] = BlockData.Empty;
continue;
}
// Determine block type using separate resource noise for better distribution
float resourceNoise = GetResourceNoise(gridPos.x, gridPos.y, gridPos.z);
float currentThreshold = baseResourceThreshold;
if (increaseResourceWithDepth)
{
currentThreshold += gridPos.y * depthFactor; // Lower threshold = more resources at depth
}
// Resource spawns only if: resource noise > threshold AND random chance passes
bool isResource = resourceNoise > currentThreshold &&
Random.value < resourceSpawnChance;
if (isResource)
{
state.blocks[index] = BlockData.Resource(resourceBlockHealth);
}
else
{
state.blocks[index] = BlockData.Normal(normalBlockHealth);
}
}
}
}
return state;
}
/// <summary>
/// Spawn a chunk NetworkObject
/// </summary>
private void SpawnChunk(Vector3Int chunkGridOrigin, ChunkState state)
{
if (chunkPrefab == null)
{
Debug.LogError("[ChunkedGenerator] Chunk prefab not assigned!");
return;
}
// Calculate world position for chunk
Vector3 worldPos = BuildManager.Instance.GridToWorld(chunkGridOrigin);
// Instantiate chunk
GameObject chunkObj = Instantiate(chunkPrefab, worldPos, Quaternion.identity, _chunkContainer);
// Spawn on network
NetworkObject netObj = chunkObj.GetComponent<NetworkObject>();
netObj.Spawn();
// Initialize chunk with block data
MineableChunk chunk = chunkObj.GetComponent<MineableChunk>();
if (chunk != null)
{
chunk.InitializeBlocks(state);
// Register in dictionary
ChunkCoord coord = ChunkCoord.FromGridPos(chunkGridOrigin);
_chunkRegistry[coord.chunkPos] = chunk;
}
}
/// <summary>
/// 3D Perlin noise sampling for terrain shape
/// </summary>
private float Get3DNoise(int x, int y, int z)
{
float xCoord = (x + _seedX + 10000f) * noiseScale;
float yCoord = (y + _seedY + 10000f) * noiseScale;
float zCoord = (z + _seedZ + 10000f) * noiseScale;
float ab = Mathf.PerlinNoise(xCoord, yCoord);
float bc = Mathf.PerlinNoise(yCoord, zCoord);
float ac = Mathf.PerlinNoise(xCoord, zCoord);
return (ab + bc + ac) / 3f;
}
/// <summary>
/// Separate 3D noise for resource distribution (more spread out)
/// </summary>
private float GetResourceNoise(int x, int y, int z)
{
float xCoord = (x + _resourceSeedX + 10000f) * resourceNoiseScale;
float yCoord = (y + _resourceSeedY + 10000f) * resourceNoiseScale;
float zCoord = (z + _resourceSeedZ + 10000f) * resourceNoiseScale;
float ab = Mathf.PerlinNoise(xCoord, yCoord);
float bc = Mathf.PerlinNoise(yCoord, zCoord);
float ac = Mathf.PerlinNoise(xCoord, zCoord);
return (ab + bc + ac) / 3f;
}
#region Public Query Methods
/// <summary>
/// Get chunk at grid position
/// </summary>
public MineableChunk GetChunkAtGrid(Vector3Int gridPos)
{
ChunkCoord coord = ChunkCoord.FromGridPos(gridPos);
_chunkRegistry.TryGetValue(coord.chunkPos, out MineableChunk chunk);
return chunk;
}
/// <summary>
/// Get chunk at world position
/// </summary>
public MineableChunk GetChunkAtWorld(Vector3 worldPos)
{
ChunkCoord coord = ChunkCoord.FromWorldPos(worldPos);
_chunkRegistry.TryGetValue(coord.chunkPos, out MineableChunk chunk);
return chunk;
}
/// <summary>
/// Check if block at grid position is solid (for cross-chunk face culling)
/// </summary>
public bool IsBlockSolid(Vector3Int gridPos)
{
MineableChunk chunk = GetChunkAtGrid(gridPos);
if (chunk == null) return false;
Vector3Int localPos = ChunkCoord.GridToLocal(gridPos);
int index = ChunkCoord.LocalToIndex(localPos);
BlockData block = chunk.GetBlock(index);
return !block.IsEmpty && block.IsDiscovered;
}
/// <summary>
/// Reveal blocks in radius around a world position (for fog of war)
/// </summary>
public void RevealBlocksInRadius(Vector3 worldPos, float radius)
{
if (!IsServer) return;
// Find all chunks that could be affected
foreach (var chunk in _chunkRegistry.Values)
{
if (chunk == null) continue;
// Quick bounds check - chunk center distance
float chunkDist = Vector3.Distance(worldPos, chunk.transform.position);
float maxChunkRadius = ChunkCoord.CHUNK_SIZE * 0.866f; // diagonal
if (chunkDist <= radius + maxChunkRadius)
{
chunk.RevealBlocksInRadius(worldPos, radius);
}
}
}
/// <summary>
/// Get all active chunks
/// </summary>
public IEnumerable<MineableChunk> GetAllChunks()
{
return _chunkRegistry.Values;
}
#endregion
#region Gizmos
private void OnDrawGizmosSelected()
{
BuildManager bm = BuildManager.Instance;
if (bm == null) bm = FindFirstObjectByType<BuildManager>();
if (bm == null) return;
Vector3Int originGrid = bm.WorldToGrid3D(transform.position);
// Draw chunk boundaries
int chunkSizeX = Mathf.CeilToInt((float)generationRange.x / ChunkCoord.CHUNK_SIZE);
int chunkSizeY = Mathf.CeilToInt((float)generationRange.y / ChunkCoord.CHUNK_SIZE);
int chunkSizeZ = Mathf.CeilToInt((float)generationRange.z / ChunkCoord.CHUNK_SIZE);
Gizmos.color = new Color(0, 1, 0, 0.3f);
for (int cx = 0; cx < chunkSizeX; cx++)
{
for (int cy = 0; cy < chunkSizeY; cy++)
{
for (int cz = 0; cz < chunkSizeZ; cz++)
{
Vector3Int chunkGridOrigin = originGrid + new Vector3Int(
cx * ChunkCoord.CHUNK_SIZE,
-cy * ChunkCoord.CHUNK_SIZE,
cz * ChunkCoord.CHUNK_SIZE
);
Vector3 worldPos = bm.GridToWorld(chunkGridOrigin);
// Offset to center of chunk
worldPos += new Vector3(
(ChunkCoord.CHUNK_SIZE - 1) * 0.5f,
(ChunkCoord.CHUNK_SIZE - 1) * 0.5f,
(ChunkCoord.CHUNK_SIZE - 1) * 0.5f
);
Gizmos.DrawWireCube(worldPos, Vector3.one * ChunkCoord.CHUNK_SIZE);
}
}
}
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d90f6724f14d49489261782c9672f11

View File

@@ -0,0 +1,680 @@
using System.Collections;
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// NetworkBehaviour for a mineable chunk containing 4x4x4 blocks.
/// Manages block state, mesh generation, damage, and item drops.
/// </summary>
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(MeshCollider))]
public class MineableChunk : NetworkBehaviour, ChunkMeshBuilder.INeighborProvider
{
[Header("Block Settings")]
[SerializeField] private byte normalBlockHealth = 100;
[SerializeField] private byte resourceBlockHealth = 150;
[Header("Drop Settings")]
[SerializeField] private ItemData normalDropItem;
[SerializeField] private ItemData resourceDropItem;
[SerializeField] private GameObject genericDropPrefab;
[Header("Materials")]
[SerializeField] private Material normalBlockMaterial;
[SerializeField] private Material resourceBlockMaterial;
[Header("Visual Settings")]
[SerializeField] private float meshRebuildDelay = 0.1f;
[Header("Highlight Settings")]
[SerializeField] private Material highlightMaterial;
private GameObject _highlightCube;
private MeshRenderer _highlightRenderer;
[Header("Fog of War")]
[SerializeField] private float visibilityTimeout = 0.5f; // Time before block goes from "visible" to "discovered"
[SerializeField] private Color discoveredTint = new Color(0.3f, 0.3f, 0.3f, 1f); // Darkened color for discovered-but-not-visible
private float[] _blockVisibilityTime; // Last time each block was visible (local only)
private MaterialPropertyBlock _propBlock;
// Chunk state synced to all clients
private NetworkVariable<ChunkState> _networkState = new NetworkVariable<ChunkState>(
default,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
// Local cache for quick access
private ChunkState _localState;
private ChunkCoord _chunkCoord;
private bool _isInitialized = false;
// Components
private MeshFilter _meshFilter;
private MeshRenderer _meshRenderer;
private MeshCollider _meshCollider;
private Outline _outline;
// Mesh rebuild state
private bool _meshDirty = false;
private Coroutine _rebuildCoroutine;
// Highlight state
private int _highlightedBlockIndex = -1;
// Reference to chunk manager for neighbor queries
private static ChunkedUndergroundGenerator _chunkManager;
public ChunkCoord Coord => _chunkCoord;
public ChunkState State => _localState;
// Visibility check interval
private float _lastVisibilityCheck;
private const float VISIBILITY_CHECK_INTERVAL = 0.3f;
private bool _hasVisibleBlocks = false;
private void Awake()
{
_meshFilter = GetComponent<MeshFilter>();
_meshRenderer = GetComponent<MeshRenderer>();
_meshCollider = GetComponent<MeshCollider>();
_outline = GetComponent<Outline>();
// Initialize local state
_localState = ChunkState.CreateEmpty();
// Initialize visibility tracking (local only, not networked)
_blockVisibilityTime = new float[ChunkState.BLOCKS_PER_CHUNK];
_propBlock = new MaterialPropertyBlock();
// Set materials: 0=normal-visible, 1=resource-visible, 2=normal-dark, 3=resource-dark
SetupMaterials();
// Create highlight cube for per-block highlighting
CreateHighlightCube();
}
private void SetupMaterials()
{
if (normalBlockMaterial != null && resourceBlockMaterial != null)
{
// Create darkened versions of materials for discovered-but-not-visible state
Material normalDark = new Material(normalBlockMaterial);
ApplyDarkTint(normalDark, normalBlockMaterial);
Material resourceDark = new Material(resourceBlockMaterial);
ApplyDarkTint(resourceDark, resourceBlockMaterial);
_meshRenderer.materials = new Material[]
{
normalBlockMaterial, // Submesh 0: Normal blocks (visible)
resourceBlockMaterial, // Submesh 1: Resource blocks (visible)
normalDark, // Submesh 2: Normal blocks (discovered/dark)
resourceDark // Submesh 3: Resource blocks (discovered/dark)
};
}
}
private void ApplyDarkTint(Material darkMat, Material sourceMat)
{
// Try URP _BaseColor first, then fallback to _Color
if (sourceMat.HasProperty("_BaseColor"))
{
Color baseColor = sourceMat.GetColor("_BaseColor");
darkMat.SetColor("_BaseColor", baseColor * discoveredTint);
}
else if (sourceMat.HasProperty("_Color"))
{
Color baseColor = sourceMat.GetColor("_Color");
darkMat.SetColor("_Color", baseColor * discoveredTint);
}
}
private void CreateHighlightCube()
{
_highlightCube = GameObject.CreatePrimitive(PrimitiveType.Cube);
_highlightCube.name = "BlockHighlight";
_highlightCube.transform.SetParent(transform);
_highlightCube.transform.localScale = Vector3.one * 1.02f; // Slightly larger than block
// Remove collider - this is visual only
var collider = _highlightCube.GetComponent<Collider>();
if (collider != null) Destroy(collider);
// Setup renderer
_highlightRenderer = _highlightCube.GetComponent<MeshRenderer>();
if (highlightMaterial != null)
{
_highlightRenderer.material = highlightMaterial;
}
else
{
// Create default highlight material if none assigned
var mat = new Material(Shader.Find("Standard"));
mat.color = new Color(1f, 1f, 0f, 0.3f); // Yellow transparent
mat.SetFloat("_Mode", 3); // Transparent mode
mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
mat.SetInt("_ZWrite", 0);
mat.DisableKeyword("_ALPHATEST_ON");
mat.EnableKeyword("_ALPHABLEND_ON");
mat.DisableKeyword("_ALPHAPREMULTIPLY_ON");
mat.renderQueue = 3000;
_highlightRenderer.material = mat;
}
_highlightCube.SetActive(false);
}
public override void OnNetworkSpawn()
{
// Calculate chunk coordinates from world position
_chunkCoord = ChunkCoord.FromWorldPos(transform.position);
// Subscribe to state changes
_networkState.OnValueChanged += OnChunkStateChanged;
// Initial sync
SyncLocalState();
RebuildMeshImmediate();
// Find chunk manager if not set
if (_chunkManager == null)
{
_chunkManager = FindFirstObjectByType<ChunkedUndergroundGenerator>();
}
}
private void Update()
{
// Only check visibility timeout periodically and if we had visible blocks
if (!_hasVisibleBlocks) return;
if (Time.time - _lastVisibilityCheck < VISIBILITY_CHECK_INTERVAL) return;
_lastVisibilityCheck = Time.time;
CheckVisibilityTimeout();
}
private void CheckVisibilityTimeout()
{
bool anyStillVisible = false;
bool needsRebuild = false;
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
if (_blockVisibilityTime[i] <= 0) continue;
bool wasVisible = (Time.time - _blockVisibilityTime[i]) < visibilityTimeout;
if (wasVisible)
{
anyStillVisible = true;
}
else if (_blockVisibilityTime[i] > 0)
{
// Block just transitioned from visible to discovered
// Check if it was visible last frame (within timeout + check interval)
float timeSinceVisible = Time.time - _blockVisibilityTime[i];
if (timeSinceVisible < visibilityTimeout + VISIBILITY_CHECK_INTERVAL * 2)
{
needsRebuild = true;
}
}
}
_hasVisibleBlocks = anyStillVisible;
if (needsRebuild)
{
ScheduleMeshRebuild();
}
}
public override void OnNetworkDespawn()
{
_networkState.OnValueChanged -= OnChunkStateChanged;
}
/// <summary>
/// Initialize chunk with generated block data (called by generator on server)
/// </summary>
public void InitializeBlocks(ChunkState initialState)
{
if (!IsServer) return;
_networkState.Value = initialState;
SyncLocalState();
RebuildMeshImmediate();
}
/// <summary>
/// Get block data at local coordinates
/// </summary>
public BlockData GetBlock(int x, int y, int z)
{
if (!ChunkCoord.IsValidLocal(x, y, z)) return BlockData.Empty;
if (_localState.blocks == null) return BlockData.Empty;
int index = ChunkCoord.LocalToIndex(x, y, z);
return _localState.blocks[index];
}
/// <summary>
/// Get block data at local index
/// </summary>
public BlockData GetBlock(int index)
{
if (index < 0 || index >= ChunkState.BLOCKS_PER_CHUNK) return BlockData.Empty;
if (_localState.blocks == null) return BlockData.Empty;
return _localState.blocks[index];
}
#region Damage System
/// <summary>
/// Request damage to a specific block (called by clients)
/// </summary>
[Rpc(SendTo.Server)]
public void DamageBlockServerRpc(int localIndex, byte damage)
{
if (localIndex < 0 || localIndex >= ChunkState.BLOCKS_PER_CHUNK) return;
var state = _networkState.Value;
BlockData block = state.blocks[localIndex];
if (block.IsEmpty) return;
// Apply damage
int newHealth = Mathf.Max(0, block.health - damage);
block.health = (byte)newHealth;
// Update state
state.blocks[localIndex] = block;
_networkState.Value = state;
// Sync to clients
UpdateBlockClientRpc(localIndex, block);
// Play hit effect
PlayHitEffectClientRpc(localIndex);
// Check for destruction
if (newHealth <= 0)
{
DestroyBlock(localIndex, state.blocks[localIndex].blockType);
}
}
/// <summary>
/// Server-side damage application (for direct server calls)
/// </summary>
public void DamageBlock(int localIndex, float damageAmount)
{
if (!IsServer) return;
DamageBlockServerRpc(localIndex, (byte)Mathf.Min(255, damageAmount));
}
/// <summary>
/// Update a single block on all clients
/// </summary>
[ClientRpc]
private void UpdateBlockClientRpc(int localIndex, BlockData block)
{
if (localIndex < 0 || localIndex >= ChunkState.BLOCKS_PER_CHUNK) return;
_localState.blocks[localIndex] = block;
// Schedule mesh rebuild
ScheduleMeshRebuild();
}
/// <summary>
/// Play hit visual effect
/// </summary>
[ClientRpc]
private void PlayHitEffectClientRpc(int localIndex)
{
// Get block world position for effect
Vector3Int localPos = ChunkCoord.IndexToLocal(localIndex);
Vector3 worldPos = _chunkCoord.LocalToWorld(localPos);
// TODO: Spawn particle effect at worldPos
// For now, just a debug visualization
}
/// <summary>
/// Handle block destruction (server-side)
/// </summary>
private void DestroyBlock(int localIndex, byte blockType)
{
if (!IsServer) return;
// Spawn dropped item
Vector3Int localPos = ChunkCoord.IndexToLocal(localIndex);
Vector3 worldPos = _chunkCoord.LocalToWorld(localPos);
SpawnDrop(worldPos, blockType);
// Clear block
var state = _networkState.Value;
state.blocks[localIndex] = BlockData.Empty;
_networkState.Value = state;
// Full sync and rebuild
SyncStateClientRpc(state);
}
/// <summary>
/// Spawn item drop at position
/// </summary>
private void SpawnDrop(Vector3 position, byte blockType)
{
if (genericDropPrefab == null) return;
ItemData dropItem = blockType == BlockData.TYPE_RESOURCE ? resourceDropItem : normalDropItem;
if (dropItem == null) return;
GameObject dropObj = Instantiate(genericDropPrefab, position + Vector3.up * 0.5f, Quaternion.identity);
NetworkObject netObj = dropObj.GetComponent<NetworkObject>();
netObj.Spawn();
if (dropObj.TryGetComponent<DroppedItem>(out var droppedItem))
{
droppedItem.Initialize(dropItem.itemID);
}
}
#endregion
#region Fog of War
/// <summary>
/// Check if block is currently visible (in player's sight right now)
/// </summary>
public bool IsBlockCurrentlyVisible(int localIndex)
{
if (_blockVisibilityTime == null || localIndex < 0 || localIndex >= _blockVisibilityTime.Length)
return false;
return (Time.time - _blockVisibilityTime[localIndex]) < visibilityTimeout;
}
/// <summary>
/// Update local visibility for blocks in range (called by local player)
/// </summary>
public void UpdateLocalVisibility(Vector3 playerPos, float radius)
{
bool needsRebuild = false;
bool anyVisible = false;
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
BlockData block = _localState.blocks[i];
if (block.IsEmpty || !block.IsDiscovered) continue;
Vector3 blockWorldPos = GetBlockWorldPosition(i);
bool wasVisible = IsBlockCurrentlyVisible(i);
if (Vector3.Distance(playerPos, blockWorldPos) <= radius)
{
_blockVisibilityTime[i] = Time.time;
anyVisible = true;
// If block just became visible, need to rebuild mesh
if (!wasVisible)
{
needsRebuild = true;
}
}
}
// Track if we have any visible blocks (for timeout checking in Update)
_hasVisibleBlocks = anyVisible || _hasVisibleBlocks;
if (needsRebuild)
{
ScheduleMeshRebuild();
}
}
/// <summary>
/// Request reveal of a specific block (called by clients)
/// </summary>
[Rpc(SendTo.Server)]
public void RevealBlockServerRpc(int localIndex)
{
if (localIndex < 0 || localIndex >= ChunkState.BLOCKS_PER_CHUNK) return;
var state = _networkState.Value;
BlockData block = state.blocks[localIndex];
if (block.IsEmpty || block.IsDiscovered) return;
// Mark as discovered
block.IsDiscovered = true;
state.blocks[localIndex] = block;
_networkState.Value = state;
// Sync to clients
UpdateBlockClientRpc(localIndex, block);
}
/// <summary>
/// Reveal all blocks within radius of a world position
/// </summary>
public void RevealBlocksInRadius(Vector3 worldPos, float radius)
{
if (!IsServer) return;
bool anyRevealed = false;
var state = _networkState.Value;
for (int i = 0; i < ChunkState.BLOCKS_PER_CHUNK; i++)
{
BlockData block = state.blocks[i];
if (block.IsEmpty || block.IsDiscovered) continue;
Vector3Int localPos = ChunkCoord.IndexToLocal(i);
Vector3 blockWorldPos = _chunkCoord.LocalToWorld(localPos);
if (Vector3.Distance(worldPos, blockWorldPos) <= radius)
{
block.IsDiscovered = true;
state.blocks[i] = block;
anyRevealed = true;
}
}
if (anyRevealed)
{
_networkState.Value = state;
SyncStateClientRpc(state);
}
}
/// <summary>
/// Check if block at local index is discovered
/// </summary>
public bool IsBlockDiscovered(int localIndex)
{
if (localIndex < 0 || localIndex >= ChunkState.BLOCKS_PER_CHUNK) return false;
return _localState.blocks[localIndex].IsDiscovered;
}
#endregion
#region Mesh Building
/// <summary>
/// Full state sync from server
/// </summary>
[ClientRpc]
private void SyncStateClientRpc(ChunkState state)
{
_localState = state;
RebuildMeshImmediate();
}
private void OnChunkStateChanged(ChunkState oldState, ChunkState newState)
{
SyncLocalState();
ScheduleMeshRebuild();
}
private void SyncLocalState()
{
_localState = _networkState.Value;
// Ensure blocks array is initialized
if (_localState.blocks == null)
{
_localState = ChunkState.CreateEmpty();
}
}
private void ScheduleMeshRebuild()
{
if (_meshDirty) return;
_meshDirty = true;
if (_rebuildCoroutine != null)
{
StopCoroutine(_rebuildCoroutine);
}
_rebuildCoroutine = StartCoroutine(DelayedMeshRebuild());
}
private IEnumerator DelayedMeshRebuild()
{
yield return new WaitForSeconds(meshRebuildDelay);
RebuildMeshImmediate();
_meshDirty = false;
_rebuildCoroutine = null;
}
private void RebuildMeshImmediate()
{
if (_meshFilter == null) return;
// Build visual mesh with fog of war (4 submeshes: normal-visible, resource-visible, normal-dark, resource-dark)
Mesh visualMesh = ChunkMeshBuilder.BuildMeshWithVisibility(
_localState,
_chunkCoord,
this,
true, // only discovered
IsBlockCurrentlyVisible // visibility checker
);
_meshFilter.mesh = visualMesh;
// Build collision mesh (all blocks, including undiscovered, no visibility distinction)
if (_meshCollider != null)
{
Mesh collisionMesh = ChunkMeshBuilder.BuildMesh(_localState, _chunkCoord, this, false);
_meshCollider.sharedMesh = null; // Force refresh
_meshCollider.sharedMesh = collisionMesh;
}
}
#endregion
#region INeighborProvider Implementation
/// <summary>
/// Check if block at world grid position is solid (for cross-chunk face culling)
/// </summary>
public bool IsBlockSolid(Vector3Int gridPos)
{
// Check if position is in this chunk
ChunkCoord coord = ChunkCoord.FromGridPos(gridPos);
if (coord == _chunkCoord)
{
Vector3Int local = ChunkCoord.GridToLocal(gridPos);
int index = ChunkCoord.LocalToIndex(local);
var block = _localState.blocks[index];
return !block.IsEmpty && block.IsDiscovered;
}
// Query chunk manager for other chunks
if (_chunkManager != null)
{
return _chunkManager.IsBlockSolid(gridPos);
}
// Default: assume not solid at chunk boundaries
return false;
}
#endregion
#region Interaction Support
/// <summary>
/// Convert world hit point to local block index
/// </summary>
public int WorldPointToBlockIndex(Vector3 worldPoint)
{
// Convert world point to local space relative to chunk transform
Vector3 localPoint = transform.InverseTransformPoint(worldPoint);
// Each block is 1 unit, centered at integer coordinates
// So block at (0,0,0) spans from -0.5 to 0.5
int lx = Mathf.Clamp(Mathf.RoundToInt(localPoint.x), 0, ChunkCoord.CHUNK_SIZE - 1);
int ly = Mathf.Clamp(Mathf.RoundToInt(localPoint.y), 0, ChunkCoord.CHUNK_SIZE - 1);
int lz = Mathf.Clamp(Mathf.RoundToInt(localPoint.z), 0, ChunkCoord.CHUNK_SIZE - 1);
return ChunkCoord.LocalToIndex(lx, ly, lz);
}
/// <summary>
/// Get world position of block at local index
/// </summary>
public Vector3 GetBlockWorldPosition(int localIndex)
{
Vector3Int localPos = ChunkCoord.IndexToLocal(localIndex);
// Convert local position to world using chunk transform
return transform.TransformPoint(new Vector3(localPos.x, localPos.y, localPos.z));
}
/// <summary>
/// Set highlight state for visual feedback
/// </summary>
public void SetHighlight(bool isOn, int blockIndex = -1)
{
_highlightedBlockIndex = isOn ? blockIndex : -1;
// Use per-block highlight cube instead of outline
if (_highlightCube != null)
{
if (isOn && blockIndex >= 0)
{
// Position highlight at specific block
Vector3Int localPos = ChunkCoord.IndexToLocal(blockIndex);
_highlightCube.transform.localPosition = new Vector3(localPos.x, localPos.y, localPos.z);
_highlightCube.SetActive(true);
}
else
{
_highlightCube.SetActive(false);
}
}
// Disable outline component if present (we use highlight cube instead)
if (_outline != null)
{
_outline.enabled = false;
}
}
/// <summary>
/// Get the currently highlighted block index
/// </summary>
public int HighlightedBlockIndex => _highlightedBlockIndex;
#endregion
#region Static Registration
public static void SetChunkManager(ChunkedUndergroundGenerator manager)
{
_chunkManager = manager;
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53bb340e9008b024ba035f6ee8fa21a4