건설 시스템 기초 생성 및 Kaykit Medival 애셋 추가

This commit is contained in:
2026-01-24 14:10:17 +09:00
parent fb6570a992
commit ec37a3261e
109 changed files with 6743 additions and 7 deletions

View File

@@ -0,0 +1,76 @@
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
public class Building : NetworkBehaviour
{
[Header("References")]
public BuildingData buildingData;
[Header("Runtime Info")]
public Vector3Int gridPosition;
public int rotation; // 0-3 (0=0°, 1=90°, 2=180°, 3=270°)
[Header("Debug")]
public bool showGridBounds = true;
public Color gridBoundsColor = Color.cyan;
public void Initialize(BuildingData data, Vector3Int gridPos, int rot)
{
buildingData = data;
gridPosition = gridPos;
rotation = rot;
}
/// <summary>
/// Gets the grid-based bounds (from BuildingData width/length/height)
/// This is used for placement validation, NOT the actual collider bounds
/// Bounds are slightly shrunk to allow adjacent buildings to touch
/// </summary>
public Bounds GetGridBounds()
{
if (buildingData == null) return new Bounds(transform.position, Vector3.one);
Vector3 gridSize = buildingData.GetSize(rotation);
// Shrink slightly to allow buildings to be adjacent without Intersects() returning true
Vector3 shrunkSize = gridSize - Vector3.one * 0.01f;
return new Bounds(transform.position + Vector3.up * gridSize.y * 0.5f, shrunkSize);
}
/// <summary>
/// Legacy method, use GetGridBounds() instead
/// </summary>
public Bounds GetBounds()
{
return GetGridBounds();
}
private void OnDrawGizmos()
{
if (!showGridBounds || buildingData == null) return;
Bounds bounds = GetGridBounds();
Gizmos.color = gridBoundsColor;
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
private void OnDrawGizmosSelected()
{
if (buildingData == null) return;
Bounds bounds = GetGridBounds();
Gizmos.color = Color.yellow;
Gizmos.DrawWireCube(bounds.center, bounds.size);
// Draw grid position
if (BuildingManager.Instance != null)
{
Vector3 worldPos = BuildingManager.Instance.GridToWorld(gridPosition);
Gizmos.color = Color.magenta;
Gizmos.DrawSphere(worldPos, 0.2f);
}
}
}
}

View File

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

View File

@@ -0,0 +1,35 @@
using UnityEngine;
namespace Northbound
{
[CreateAssetMenu(fileName = "NewBuilding", menuName = "Northbound/Building Data")]
public class BuildingData : ScriptableObject
{
[Header("Building Info")]
public string buildingName;
public GameObject prefab;
[Header("Grid Size")]
[Tooltip("Width in grid units")]
public int width = 1;
[Tooltip("Length in grid units")]
public int length = 1;
[Tooltip("Height for placement validation")]
public float height = 2f;
[Header("Placement Settings")]
[Tooltip("Offset from grid position")]
public Vector3 placementOffset = Vector3.zero;
[Tooltip("Can rotate this building?")]
public bool allowRotation = true;
public Vector3 GetSize(int rotation)
{
// Rotation 0,180 = normal, 90,270 = swap width/length
bool isRotated = (rotation == 1 || rotation == 3);
float w = isRotated ? length : width;
float l = isRotated ? width : length;
return new Vector3(w, height, l);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 937e64980d44d6b46acb35b8046adf34

View File

@@ -0,0 +1,153 @@
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
public class BuildingManager : NetworkBehaviour
{
public static BuildingManager Instance { get; private set; }
[Header("Settings")]
public float gridSize = 1f;
public LayerMask groundLayer;
[Header("Building Database")]
public List<BuildingData> availableBuildings = new List<BuildingData>();
private List<Building> placedBuildings = new List<Building>();
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
public Vector3 SnapToGrid(Vector3 worldPosition)
{
return new Vector3(
Mathf.Round(worldPosition.x / gridSize) * gridSize,
worldPosition.y,
Mathf.Round(worldPosition.z / gridSize) * gridSize
);
}
public Vector3Int WorldToGrid(Vector3 worldPosition)
{
return new Vector3Int(
Mathf.RoundToInt(worldPosition.x / gridSize),
Mathf.RoundToInt(worldPosition.y / gridSize),
Mathf.RoundToInt(worldPosition.z / gridSize)
);
}
public Vector3 GridToWorld(Vector3Int gridPosition)
{
return new Vector3(
gridPosition.x * gridSize,
gridPosition.y * gridSize,
gridPosition.z * gridSize
);
}
public bool IsValidPlacement(BuildingData data, Vector3 position, int rotation, out Vector3 groundPosition)
{
groundPosition = position;
// Ground check
if (!CheckGround(position, out groundPosition))
return false;
// IMPORTANT: Snap to grid BEFORE checking overlap!
// Otherwise we check the raw cursor position which might overlap
Vector3 snappedPosition = SnapToGrid(groundPosition);
groundPosition = snappedPosition; // Update groundPosition to snapped value
// Overlap check using GRID SIZE from BuildingData (not actual colliders)
Vector3 gridSize = data.GetSize(rotation);
// Shrink bounds slightly to allow buildings to touch without overlapping
// This prevents Bounds.Intersects() from returning true for adjacent buildings
Vector3 shrunkSize = gridSize - Vector3.one * 0.01f;
Bounds checkBounds = new Bounds(snappedPosition + Vector3.up * gridSize.y * 0.5f, shrunkSize);
foreach (var building in placedBuildings)
{
if (building == null) continue;
// Compare grid bounds, not collider bounds
Bounds buildingGridBounds = building.GetGridBounds();
if (checkBounds.Intersects(buildingGridBounds))
return false;
}
return true;
}
/// <summary>
/// Get the grid bounds for a building at a given position
/// Useful for preview visualization
/// </summary>
public Bounds GetPlacementBounds(BuildingData data, Vector3 position, int rotation)
{
Vector3 gridSize = data.GetSize(rotation);
return new Bounds(position + Vector3.up * gridSize.y * 0.5f, gridSize);
}
private bool CheckGround(Vector3 position, out Vector3 groundPosition)
{
groundPosition = position;
// Raycast down to find ground
if (Physics.Raycast(position + Vector3.up * 10f, Vector3.down, out RaycastHit hit, 20f, groundLayer))
{
groundPosition = hit.point;
return true;
}
return false;
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void PlaceBuildingServerRpc(int buildingIndex, Vector3 position, int rotation)
{
if (buildingIndex < 0 || buildingIndex >= availableBuildings.Count)
return;
BuildingData data = availableBuildings[buildingIndex];
// IsValidPlacement now returns snapped position in groundPosition
if (!IsValidPlacement(data, position, rotation, out Vector3 snappedPosition))
return;
Vector3Int gridPosition = WorldToGrid(snappedPosition);
// Spawn building at snapped position
GameObject buildingObj = Instantiate(data.prefab, snappedPosition + data.placementOffset, Quaternion.Euler(0, rotation * 90f, 0));
NetworkObject netObj = buildingObj.GetComponent<NetworkObject>();
if (netObj != null)
{
netObj.Spawn();
Building building = buildingObj.GetComponent<Building>();
if (building == null)
building = buildingObj.AddComponent<Building>();
building.Initialize(data, gridPosition, rotation);
placedBuildings.Add(building);
}
}
public void RemoveBuilding(Building building)
{
if (placedBuildings.Contains(building))
placedBuildings.Remove(building);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27ee775fc5a0dc7498f3049e65e32513

View File

@@ -0,0 +1,452 @@
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Northbound
{
public class BuildingPlacement : NetworkBehaviour
{
[Header("Settings")]
public LayerMask groundLayer;
public float maxPlacementDistance = 100f;
[Header("Preview Materials")]
public Material validMaterial;
public Material invalidMaterial;
[Header("Current Selection")]
public int selectedBuildingIndex = 0;
[Header("Debug Visualization")]
public bool showGridBounds = true;
private bool isBuildModeActive = false;
private GameObject previewObject;
private int currentRotation = 0; // 0-3
private Material[] originalMaterials;
private Renderer[] previewRenderers;
private PlayerInputActions _inputActions;
public override void OnNetworkSpawn()
{
if (!IsOwner) return;
_inputActions = new PlayerInputActions();
_inputActions.Player.ToggleBuildMode.performed += OnToggleBuildMode;
_inputActions.Player.Rotate.performed += OnRotate;
_inputActions.Player.Build.performed += OnBuild;
_inputActions.Enable();
// Create default materials if not assigned
if (validMaterial == null)
{
validMaterial = CreateGhostMaterial(new Color(0, 1, 0, 0.5f));
}
if (invalidMaterial == null)
{
invalidMaterial = CreateGhostMaterial(new Color(1, 0, 0, 0.5f));
}
}
private Material CreateGhostMaterial(Color color)
{
// Try to find appropriate shader for current render pipeline
Shader shader = Shader.Find("Universal Render Pipeline/Lit");
if (shader == null)
shader = Shader.Find("Standard");
if (shader == null)
shader = Shader.Find("Diffuse");
Material mat = new Material(shader);
// Set base color
if (mat.HasProperty("_BaseColor"))
mat.SetColor("_BaseColor", color); // URP
else if (mat.HasProperty("_Color"))
mat.SetColor("_Color", color); // Standard
// Enable transparency
if (mat.HasProperty("_Surface"))
{
// URP Transparency
mat.SetFloat("_Surface", 1); // Transparent
mat.SetFloat("_Blend", 0); // Alpha
mat.SetFloat("_AlphaClip", 0);
mat.SetFloat("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
mat.SetFloat("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
mat.SetFloat("_ZWrite", 0);
mat.renderQueue = 3000;
mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
mat.EnableKeyword("_ALPHAPREMULTIPLY_ON");
}
else
{
// Standard RP Transparency
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;
}
return mat;
}
public override void OnNetworkDespawn()
{
if (IsOwner && _inputActions != null)
{
_inputActions.Player.ToggleBuildMode.performed -= OnToggleBuildMode;
_inputActions.Player.Rotate.performed -= OnRotate;
_inputActions.Player.Build.performed -= OnBuild;
_inputActions.Disable();
_inputActions.Dispose();
}
}
private void Update()
{
if (!IsOwner) return;
if (isBuildModeActive)
{
UpdatePreviewPosition();
}
}
private void OnDrawGizmos()
{
if (!IsOwner || !isBuildModeActive || !showGridBounds) return;
if (BuildingManager.Instance == null || previewObject == null || !previewObject.activeSelf) return;
BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex];
if (data == null) return;
// Draw grid bounds being used for collision detection
Bounds bounds = BuildingManager.Instance.GetPlacementBounds(data, previewObject.transform.position, currentRotation);
// Check if valid
bool isValid = BuildingManager.Instance.IsValidPlacement(data, previewObject.transform.position, currentRotation, out _);
Gizmos.color = isValid ? new Color(0, 1, 0, 0.5f) : new Color(1, 0, 0, 0.5f);
Gizmos.DrawWireCube(bounds.center, bounds.size);
// Draw grid cells
Gizmos.color = Color.yellow;
float gridSize = BuildingManager.Instance.gridSize;
Vector3 snappedPos = BuildingManager.Instance.SnapToGrid(previewObject.transform.position);
// Draw grid origin point
Gizmos.DrawSphere(snappedPos, 0.1f);
// Draw grid outline for each cell
int width = Mathf.RoundToInt(bounds.size.x / gridSize);
int length = Mathf.RoundToInt(bounds.size.z / gridSize);
for (int x = 0; x < width; x++)
{
for (int z = 0; z < length; z++)
{
Vector3 cellPos = snappedPos + new Vector3(
(x - width / 2f + 0.5f) * gridSize,
0.01f,
(z - length / 2f + 0.5f) * gridSize
);
Gizmos.DrawWireCube(cellPos, new Vector3(gridSize, 0.01f, gridSize));
}
}
}
private void OnToggleBuildMode(InputAction.CallbackContext context)
{
ToggleBuildMode();
}
private void OnRotate(InputAction.CallbackContext context)
{
if (!isBuildModeActive) return;
float rotateValue = context.ReadValue<float>();
if (rotateValue > 0)
{
currentRotation = (currentRotation + 1) % 4;
}
else if (rotateValue < 0)
{
currentRotation = (currentRotation - 1 + 4) % 4;
}
}
private void OnBuild(InputAction.CallbackContext context)
{
if (!isBuildModeActive) return;
TryPlaceBuilding();
}
private void ToggleBuildMode()
{
isBuildModeActive = !isBuildModeActive;
if (isBuildModeActive)
{
EnterBuildMode();
}
else
{
ExitBuildMode();
}
}
private void EnterBuildMode()
{
currentRotation = 0;
CreatePreview();
}
private void ExitBuildMode()
{
DestroyPreview();
}
private void CreatePreview()
{
if (BuildingManager.Instance == null)
return;
if (BuildingManager.Instance.availableBuildings.Count == 0)
return;
selectedBuildingIndex = Mathf.Clamp(selectedBuildingIndex, 0,
BuildingManager.Instance.availableBuildings.Count - 1);
BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex];
if (data == null || data.prefab == null)
return;
previewObject = Instantiate(data.prefab);
previewObject.name = "BuildingPreview";
// Disable colliders on preview
foreach (var collider in previewObject.GetComponentsInChildren<Collider>())
{
collider.enabled = false;
}
// Remove NetworkObject from preview if exists
NetworkObject netObj = previewObject.GetComponent<NetworkObject>();
if (netObj != null)
{
Destroy(netObj);
}
// Store original materials and setup preview renderers
previewRenderers = previewObject.GetComponentsInChildren<Renderer>();
if (previewRenderers.Length == 0)
{
// Add a debug cube so you can at least see something
GameObject debugCube = GameObject.CreatePrimitive(PrimitiveType.Cube);
debugCube.transform.SetParent(previewObject.transform);
debugCube.transform.localPosition = Vector3.zero;
Destroy(debugCube.GetComponent<Collider>());
previewRenderers = previewObject.GetComponentsInChildren<Renderer>();
}
originalMaterials = new Material[previewRenderers.Length];
for (int i = 0; i < previewRenderers.Length; i++)
{
originalMaterials[i] = previewRenderers[i].material;
// Replace all materials with ghost material
Material[] mats = new Material[previewRenderers[i].materials.Length];
for (int j = 0; j < mats.Length; j++)
{
mats[j] = validMaterial;
}
previewRenderers[i].materials = mats;
// Disable shadow casting
previewRenderers[i].shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
previewRenderers[i].receiveShadows = false;
}
}
private void DestroyPreview()
{
if (previewObject != null)
{
Destroy(previewObject);
previewObject = null;
}
}
private void UpdatePreviewPosition()
{
if (BuildingManager.Instance == null) return;
if (previewObject == null)
{
CreatePreview();
if (previewObject == null) return;
}
if (Mouse.current == null)
return;
Vector2 mousePos = Mouse.current.position.ReadValue();
Ray ray = Camera.main.ScreenPointToRay(mousePos);
Vector3 targetPosition = Vector3.zero;
bool foundPosition = false;
// Robust approach: Find where ray intersects ground plane, then raycast down from there
// This works even when pointing at empty space between buildings
// Step 1: Calculate where ray intersects a horizontal plane at Y=0
// Using plane intersection math
Plane groundPlane = new Plane(Vector3.up, Vector3.zero);
float enter;
if (groundPlane.Raycast(ray, out enter))
{
// Get the XZ position where ray hits the ground plane
Vector3 planeHitPoint = ray.GetPoint(enter);
// Step 2: Cast down from above this XZ position to find actual ground
Vector3 highPoint = new Vector3(planeHitPoint.x, 100f, planeHitPoint.z);
if (Physics.Raycast(highPoint, Vector3.down, out RaycastHit downHit, 200f, groundLayer))
{
// Always use the hit point
targetPosition = downHit.point;
foundPosition = true;
}
else
{
// No ground found via raycast, use the plane intersection point directly
targetPosition = planeHitPoint;
foundPosition = true;
}
}
else
{
// Fallback: Try direct raycast to anything
if (Physics.Raycast(ray, out RaycastHit anyHit, maxPlacementDistance))
{
targetPosition = anyHit.point;
foundPosition = true;
}
}
if (foundPosition)
{
BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex];
// IsValidPlacement now returns the snapped position in groundPosition
bool isValid = BuildingManager.Instance.IsValidPlacement(data, targetPosition, currentRotation, out Vector3 snappedPosition);
previewObject.transform.position = snappedPosition + data.placementOffset;
previewObject.transform.rotation = Quaternion.Euler(0, currentRotation * 90f, 0);
if (!previewObject.activeSelf)
{
previewObject.SetActive(true);
}
// Update material
Material previewMat = isValid ? validMaterial : invalidMaterial;
foreach (var renderer in previewRenderers)
{
Material[] mats = new Material[renderer.materials.Length];
for (int i = 0; i < mats.Length; i++)
{
mats[i] = previewMat;
}
renderer.materials = mats;
}
}
else
{
if (previewObject.activeSelf)
{
previewObject.SetActive(false);
}
}
}
private void TryPlaceBuilding()
{
if (previewObject == null || !previewObject.activeSelf) return;
Vector2 mousePos = Mouse.current.position.ReadValue();
Ray ray = Camera.main.ScreenPointToRay(mousePos);
Vector3 targetPosition = Vector3.zero;
bool foundPosition = false;
// Use plane intersection to get XZ position, then raycast down to find ground
Plane groundPlane = new Plane(Vector3.up, Vector3.zero);
float enter;
if (groundPlane.Raycast(ray, out enter))
{
Vector3 planeHitPoint = ray.GetPoint(enter);
Vector3 highPoint = new Vector3(planeHitPoint.x, 100f, planeHitPoint.z);
if (Physics.Raycast(highPoint, Vector3.down, out RaycastHit downHit, 200f, groundLayer))
{
if (downHit.collider.GetComponentInParent<Building>() == null)
{
targetPosition = downHit.point;
foundPosition = true;
}
else
{
targetPosition = downHit.point;
foundPosition = true;
}
}
else
{
targetPosition = planeHitPoint;
foundPosition = true;
}
}
else
{
// Fallback: direct raycast
if (Physics.Raycast(ray, out RaycastHit anyHit, maxPlacementDistance))
{
targetPosition = anyHit.point;
foundPosition = true;
}
}
if (foundPosition)
{
BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex];
// IsValidPlacement now returns the snapped position
if (BuildingManager.Instance.IsValidPlacement(data, targetPosition, currentRotation, out Vector3 snappedPosition))
{
BuildingManager.Instance.PlaceBuildingServerRpc(selectedBuildingIndex, snappedPosition, currentRotation);
}
}
}
private void OnDestroy()
{
DestroyPreview();
if (_inputActions != null)
{
_inputActions.Dispose();
}
}
}
}

View File

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

View File

@@ -0,0 +1,92 @@
using UnityEngine;
namespace Northbound
{
/// <summary>
/// Simple test script to verify ghost materials work.
/// Attach to any GameObject with a Renderer to see the ghost effect.
/// </summary>
public class GhostMaterialTest : MonoBehaviour
{
[Header("Test Settings")]
public Color ghostColor = new Color(0, 1, 0, 0.5f);
public bool applyOnStart = true;
private void Start()
{
if (applyOnStart)
{
ApplyGhostMaterial();
}
}
[ContextMenu("Apply Ghost Material")]
public void ApplyGhostMaterial()
{
Material ghostMat = CreateGhostMaterial(ghostColor);
Renderer[] renderers = GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
{
Material[] mats = new Material[renderer.materials.Length];
for (int i = 0; i < mats.Length; i++)
{
mats[i] = ghostMat;
}
renderer.materials = mats;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
renderer.receiveShadows = false;
}
Debug.Log($"Applied ghost material to {renderers.Length} renderers");
}
private Material CreateGhostMaterial(Color color)
{
// Try to find appropriate shader for current render pipeline
Shader shader = Shader.Find("Universal Render Pipeline/Lit");
if (shader == null)
shader = Shader.Find("Standard");
if (shader == null)
shader = Shader.Find("Diffuse");
Material mat = new Material(shader);
// Set base color
if (mat.HasProperty("_BaseColor"))
mat.SetColor("_BaseColor", color); // URP
else if (mat.HasProperty("_Color"))
mat.SetColor("_Color", color); // Standard
// Enable transparency
if (mat.HasProperty("_Surface"))
{
// URP Transparency
mat.SetFloat("_Surface", 1); // Transparent
mat.SetFloat("_Blend", 0); // Alpha
mat.SetFloat("_AlphaClip", 0);
mat.SetFloat("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
mat.SetFloat("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
mat.SetFloat("_ZWrite", 0);
mat.renderQueue = 3000;
mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
mat.EnableKeyword("_ALPHAPREMULTIPLY_ON");
}
else
{
// Standard RP Transparency
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;
}
Debug.Log($"Created ghost material with shader: {shader.name}");
return mat;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99bb7a3f13f744642aca504e3c30a571