인터랙션 및 액션 구조 생성

기본 채광 인터랙션 생성
채광 인터랙션을 위한 인터랙션 대상인 광산 생성
Kaykit Resource 애셋 추가
This commit is contained in:
2026-01-24 22:54:23 +09:00
parent ec37a3261e
commit 05233497e7
591 changed files with 52864 additions and 14 deletions

View File

@@ -0,0 +1,115 @@
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 액션 - 공격 (대상 없이도 실행 가능)
/// </summary>
public class AttackAction : NetworkBehaviour, IAction
{
[Header("Attack Settings")]
public float attackRange = 2f;
public int attackDamage = 10;
public float attackCooldown = 0.5f;
public LayerMask attackableLayer = ~0;
[Header("Animation")]
public string attackAnimationTrigger = "Attack"; // 공격 애니메이션 트리거
[Header("Visual")]
public GameObject attackEffectPrefab;
public Transform attackPoint;
private float _lastAttackTime;
private Animator _animator;
private void Awake()
{
_animator = GetComponent<Animator>();
}
public bool CanExecute(ulong playerId)
{
return Time.time - _lastAttackTime >= attackCooldown;
}
public void Execute(ulong playerId)
{
if (!CanExecute(playerId))
return;
_lastAttackTime = Time.time;
// 애니메이션 재생
PlayAttackAnimation();
// 범위 내 적이 있으면 데미지
Vector3 attackOrigin = attackPoint != null ? attackPoint.position : transform.position;
Collider[] hits = Physics.OverlapSphere(attackOrigin, attackRange, attackableLayer);
foreach (Collider hit in hits)
{
// 자기 자신은 제외
if (hit.transform.root == transform.root)
continue;
// 적에게 데미지
var enemy = hit.GetComponent<IDamageable>();
if (enemy != null)
{
var netObj = hit.GetComponent<NetworkObject>();
if (netObj != null)
{
AttackServerRpc(playerId, netObj.NetworkObjectId);
}
}
}
Debug.Log($"플레이어 {playerId} 공격! (적중: {hits.Length}개)");
}
[ServerRpc(RequireOwnership = false)]
private void AttackServerRpc(ulong playerId, ulong targetNetworkId)
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetNetworkId, out NetworkObject targetObj))
{
var damageable = targetObj.GetComponent<IDamageable>();
damageable?.TakeDamage(attackDamage, playerId);
}
}
private void PlayAttackAnimation()
{
// 애니메이션 트리거
if (_animator != null && !string.IsNullOrEmpty(attackAnimationTrigger))
{
_animator.SetTrigger(attackAnimationTrigger);
}
// 이펙트 생성
if (attackEffectPrefab != null && attackPoint != null)
{
GameObject effect = Instantiate(attackEffectPrefab, attackPoint.position, attackPoint.rotation);
Destroy(effect, 1f);
}
}
public string GetActionName()
{
return "Attack";
}
public string GetActionAnimation()
{
return attackAnimationTrigger;
}
private void OnDrawGizmosSelected()
{
Vector3 attackOrigin = attackPoint != null ? attackPoint.position : transform.position;
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(attackOrigin, attackRange);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 66ec3984614d8a64b8eae821376d038d

View File

@@ -0,0 +1,112 @@
using UnityEngine;
using System.Collections.Generic;
namespace Northbound
{
/// <summary>
/// 플레이어의 장비 소켓 관리 (손, 등, 허리 등)
/// </summary>
public class EquipmentSocket : MonoBehaviour
{
[System.Serializable]
public class Socket
{
public string socketName; // "RightHand", "LeftHand", "Back" 등
public Transform socketTransform; // 실제 본 Transform
[HideInInspector] public GameObject currentEquipment; // 현재 장착된 장비
}
[Header("Available Sockets")]
public List<Socket> sockets = new List<Socket>();
private Dictionary<string, Socket> _socketDict = new Dictionary<string, Socket>();
private void Awake()
{
// 빠른 검색을 위한 딕셔너리 생성
foreach (var socket in sockets)
{
if (!string.IsNullOrEmpty(socket.socketName))
{
_socketDict[socket.socketName] = socket;
}
}
}
/// <summary>
/// 소켓에 장비 부착
/// </summary>
public GameObject AttachToSocket(string socketName, GameObject equipmentPrefab)
{
if (!_socketDict.TryGetValue(socketName, out Socket socket))
{
Debug.LogWarning($"소켓을 찾을 수 없습니다: {socketName}");
return null;
}
if (socket.socketTransform == null)
{
Debug.LogWarning($"소켓 Transform이 없습니다: {socketName}");
return null;
}
// 기존 장비 제거
DetachFromSocket(socketName);
// 새 장비 생성
if (equipmentPrefab != null)
{
GameObject equipment = Instantiate(equipmentPrefab, socket.socketTransform);
equipment.transform.localPosition = Vector3.zero;
equipment.transform.localRotation = Quaternion.identity;
socket.currentEquipment = equipment;
return equipment;
}
return null;
}
/// <summary>
/// 소켓에서 장비 제거
/// </summary>
public void DetachFromSocket(string socketName)
{
if (!_socketDict.TryGetValue(socketName, out Socket socket))
return;
if (socket.currentEquipment != null)
{
Destroy(socket.currentEquipment);
socket.currentEquipment = null;
}
}
/// <summary>
/// 모든 소켓에서 장비 제거
/// </summary>
public void DetachAll()
{
foreach (var socket in sockets)
{
if (socket.currentEquipment != null)
{
Destroy(socket.currentEquipment);
socket.currentEquipment = null;
}
}
}
/// <summary>
/// 특정 소켓에 장비가 있는지 확인
/// </summary>
public bool HasEquipment(string socketName)
{
if (_socketDict.TryGetValue(socketName, out Socket socket))
{
return socket.currentEquipment != null;
}
return false;
}
}
}

View File

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

28
Assets/Scripts/IAction.cs Normal file
View File

@@ -0,0 +1,28 @@
namespace Northbound
{
/// <summary>
/// 상호작용 대상 없이도 실행 가능한 행동 (공격, 점프 등)
/// </summary>
public interface IAction
{
/// <summary>
/// 액션 실행 가능한지 여부 (쿨다운, 스테미나 등)
/// </summary>
bool CanExecute(ulong playerId);
/// <summary>
/// 액션 실행
/// </summary>
void Execute(ulong playerId);
/// <summary>
/// 액션 이름
/// </summary>
string GetActionName();
/// <summary>
/// 플레이어가 재생할 애니메이션 트리거 이름 (없으면 null 또는 빈 문자열)
/// </summary>
string GetActionAnimation();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5177351de8fc83742b52f04dc18dd309

View File

@@ -0,0 +1,10 @@
namespace Northbound
{
/// <summary>
/// 데미지를 받을 수 있는 오브젝트
/// </summary>
public interface IDamageable
{
void TakeDamage(int damage, ulong attackerId);
}
}

View File

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

View File

@@ -0,0 +1,40 @@
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 상호작용 대상이 반드시 필요한 행동 (채광, 문 열기 등)
/// </summary>
public interface IInteractable
{
/// <summary>
/// 상호작용 가능한지 여부
/// </summary>
bool CanInteract(ulong playerId);
/// <summary>
/// 상호작용 실행
/// </summary>
void Interact(ulong playerId);
/// <summary>
/// 상호작용 UI에 표시될 텍스트 (예: "[E] 채광하기")
/// </summary>
string GetInteractionPrompt();
/// <summary>
/// 플레이어가 재생할 애니메이션 트리거 이름 (없으면 null 또는 빈 문자열)
/// </summary>
string GetInteractionAnimation();
/// <summary>
/// 상호작용 시 사용할 장비 정보 (없으면 null)
/// </summary>
InteractionEquipmentData GetEquipmentData();
/// <summary>
/// 상호작용 오브젝트의 Transform
/// </summary>
Transform GetTransform();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 722ea71942d46274d9590acea8f70015

View File

@@ -0,0 +1,23 @@
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 상호작용 시 필요한 장비 정보
/// </summary>
[System.Serializable]
public class InteractionEquipmentData
{
[Tooltip("장비를 부착할 소켓 이름 (예: RightHand, LeftHand)")]
public string socketName = "RightHand";
[Tooltip("부착할 장비 프리팹 (예: 곡괭이, 도끼)")]
public GameObject equipmentPrefab;
[Tooltip("상호작용 시작 시 자동으로 부착")]
public bool attachOnStart = true;
[Tooltip("상호작용 종료 시 자동으로 제거")]
public bool detachOnEnd = true;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 883a3042cf05b3b4e9629710b6f4e83f

141
Assets/Scripts/Mine.cs Normal file
View File

@@ -0,0 +1,141 @@
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 상호작용 대상 - 광산 (채광하기)
/// </summary>
public class Mine : NetworkBehaviour, IInteractable
{
[Header("Mine Settings")]
public bool infiniteResources = false; // 무제한 자원
public int maxResources = 100;
public int resourcesPerMining = 10;
public float miningCooldown = 2f;
public string resourceName = "광석";
[Header("Animation")]
public string interactionAnimationTrigger = "Mining"; // 플레이어 애니메이션 트리거
[Header("Equipment")]
public InteractionEquipmentData equipmentData = new InteractionEquipmentData
{
socketName = "RightHand",
attachOnStart = true,
detachOnEnd = true
};
[Header("Visual")]
public GameObject miningEffectPrefab;
public Transform effectSpawnPoint;
private NetworkVariable<int> _currentResources = new NetworkVariable<int>(
100,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private float _lastMiningTime;
public override void OnNetworkSpawn()
{
if (IsServer)
{
_currentResources.Value = maxResources;
}
}
public bool CanInteract(ulong playerId)
{
// 무제한 자원이면 항상 채굴 가능
if (infiniteResources)
return Time.time - _lastMiningTime >= miningCooldown;
if (_currentResources.Value <= 0)
return false;
return Time.time - _lastMiningTime >= miningCooldown;
}
public void Interact(ulong playerId)
{
if (!CanInteract(playerId))
return;
MineResourceServerRpc(playerId);
}
[ServerRpc(RequireOwnership = false)]
private void MineResourceServerRpc(ulong playerId)
{
if (!CanInteract(playerId))
return;
int minedAmount = resourcesPerMining;
// 무제한이 아니면 자원 감소
if (!infiniteResources)
{
minedAmount = Mathf.Min(resourcesPerMining, _currentResources.Value);
_currentResources.Value -= minedAmount;
}
_lastMiningTime = Time.time;
Debug.Log($"플레이어 {playerId}가 {minedAmount} {resourceName}을(를) 채굴했습니다. " +
(infiniteResources ? "(무제한)" : $"남은 자원: {_currentResources.Value}"));
ShowMiningEffectClientRpc();
if (!infiniteResources && _currentResources.Value <= 0)
{
OnResourcesDepleted();
}
}
[ClientRpc]
private void ShowMiningEffectClientRpc()
{
if (miningEffectPrefab != null && effectSpawnPoint != null)
{
GameObject effect = Instantiate(miningEffectPrefab, effectSpawnPoint.position, effectSpawnPoint.rotation);
Destroy(effect, 2f);
}
}
private void OnResourcesDepleted()
{
Debug.Log("광산이 고갈되었습니다!");
}
public string GetInteractionPrompt()
{
if (infiniteResources)
{
return $"[E] {resourceName} 채굴 (무제한)";
}
if (_currentResources.Value > 0)
{
return $"[E] {resourceName} 채굴 ({_currentResources.Value}/{maxResources})";
}
return "고갈된 광산";
}
public string GetInteractionAnimation()
{
return interactionAnimationTrigger;
}
public InteractionEquipmentData GetEquipmentData()
{
return equipmentData;
}
public Transform GetTransform()
{
return transform;
}
}
}

View File

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

View File

@@ -0,0 +1,91 @@
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections.Generic;
namespace Northbound
{
/// <summary>
/// 상호작용 대상 없이 실행 가능한 액션들을 관리
/// </summary>
public class PlayerActionSystem : NetworkBehaviour
{
[Header("Actions")]
public List<MonoBehaviour> actionComponents = new List<MonoBehaviour>();
[Header("Animation")]
public bool playAnimations = true;
private PlayerInputActions _inputActions;
private Dictionary<string, IAction> _actions = new Dictionary<string, IAction>();
private Animator _animator;
private void Awake()
{
_animator = GetComponent<Animator>();
}
public override void OnNetworkSpawn()
{
if (!IsOwner) return;
// 액션 컴포넌트들을 딕셔너리에 등록
foreach (var component in actionComponents)
{
if (component is IAction action)
{
_actions[action.GetActionName()] = action;
}
}
_inputActions = new PlayerInputActions();
_inputActions.Player.Attack.performed += OnAttack;
// 다른 액션들도 여기에 바인딩
_inputActions.Enable();
}
public override void OnNetworkDespawn()
{
if (IsOwner && _inputActions != null)
{
_inputActions.Player.Attack.performed -= OnAttack;
_inputActions.Disable();
_inputActions.Dispose();
}
}
private void OnAttack(InputAction.CallbackContext context)
{
ExecuteAction("Attack");
}
public void ExecuteAction(string actionName)
{
if (_actions.TryGetValue(actionName, out IAction action))
{
if (action.CanExecute(OwnerClientId))
{
// 애니메이션 재생 (액션 실행 전)
if (playAnimations && _animator != null)
{
string animTrigger = action.GetActionAnimation();
if (!string.IsNullOrEmpty(animTrigger))
{
_animator.SetTrigger(animTrigger);
}
}
action.Execute(OwnerClientId);
}
}
}
private void OnDestroy()
{
if (_inputActions != null)
{
_inputActions.Dispose();
}
}
}
}

View File

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

View File

@@ -0,0 +1,246 @@
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Northbound
{
/// <summary>
/// 플레이어가 월드의 오브젝트와 상호작용하는 시스템
/// </summary>
public class PlayerInteraction : NetworkBehaviour
{
[Header("Interaction Settings")]
public float interactionRange = 3f;
public LayerMask interactableLayer = ~0;
[Header("Detection")]
public Transform rayOrigin;
public bool useForwardDirection = true;
[Header("Animation")]
public bool playAnimations = true;
public bool useAnimationEvents = true;
public bool blockDuringAnimation = true;
[Header("Equipment")]
public bool useEquipment = true;
[Header("Debug")]
public bool showDebugRay = true;
private PlayerInputActions _inputActions;
private IInteractable _currentInteractable;
private Camera _mainCamera;
private Animator _animator;
private EquipmentSocket _equipmentSocket;
private InteractionEquipmentData _pendingEquipmentData;
private string _currentEquipmentSocket;
private bool _isInteracting = false;
public override void OnNetworkSpawn()
{
if (!IsOwner) return;
_mainCamera = Camera.main;
_animator = GetComponent<Animator>();
_equipmentSocket = GetComponent<EquipmentSocket>();
if (rayOrigin == null)
rayOrigin = transform;
_inputActions = new PlayerInputActions();
_inputActions.Player.Interact.performed += OnInteract;
_inputActions.Enable();
}
public override void OnNetworkDespawn()
{
if (IsOwner && _inputActions != null)
{
_inputActions.Player.Interact.performed -= OnInteract;
_inputActions.Disable();
_inputActions.Dispose();
}
}
private void Update()
{
if (!IsOwner) return;
DetectInteractable();
}
private void DetectInteractable()
{
Vector3 origin = rayOrigin.position;
Vector3 direction = useForwardDirection ? transform.forward : _mainCamera.transform.forward;
Ray ray = new Ray(origin, direction);
if (showDebugRay)
{
Debug.DrawRay(ray.origin, ray.direction * interactionRange,
_currentInteractable != null ? Color.green : Color.yellow);
}
if (Physics.Raycast(ray, out RaycastHit hit, interactionRange, interactableLayer))
{
IInteractable interactable = hit.collider.GetComponent<IInteractable>();
if (interactable == null)
interactable = hit.collider.GetComponentInParent<IInteractable>();
if (interactable != null && interactable.CanInteract(OwnerClientId))
{
_currentInteractable = interactable;
return;
}
}
_currentInteractable = null;
}
private void OnInteract(InputAction.CallbackContext context)
{
if (blockDuringAnimation && _isInteracting)
return;
if (_currentInteractable != null)
{
_isInteracting = true;
_pendingEquipmentData = _currentInteractable.GetEquipmentData();
if (!useAnimationEvents && useEquipment && _equipmentSocket != null && _pendingEquipmentData != null)
{
if (_pendingEquipmentData.attachOnStart && _pendingEquipmentData.equipmentPrefab != null)
{
AttachEquipment();
if (_pendingEquipmentData.detachOnEnd)
{
StartCoroutine(DetachEquipmentAfterDelay(2f));
}
}
}
if (playAnimations && _animator != null)
{
string animTrigger = _currentInteractable.GetInteractionAnimation();
if (!string.IsNullOrEmpty(animTrigger))
{
_animator.SetTrigger(animTrigger);
}
}
_currentInteractable.Interact(OwnerClientId);
}
}
// ========================================
// Animation Event 함수들
// ========================================
public void OnEquipTool()
{
if (!useAnimationEvents || !useEquipment) return;
AttachEquipment();
}
public void OnEquipTool(string socketName)
{
if (!useAnimationEvents || !useEquipment) return;
AttachEquipment(socketName);
}
public void OnUnequipTool()
{
if (!useAnimationEvents || !useEquipment) return;
DetachEquipment();
}
public void OnUnequipTool(string socketName)
{
if (!useAnimationEvents || !useEquipment) return;
DetachEquipment(socketName);
}
public void OnInteractionComplete()
{
_isInteracting = false;
}
// ========================================
// 내부 헬퍼 함수들
// ========================================
private void AttachEquipment(string socketName = null)
{
if (_equipmentSocket == null || _pendingEquipmentData == null)
return;
if (_pendingEquipmentData.equipmentPrefab == null)
return;
string socket = socketName ?? _pendingEquipmentData.socketName;
_equipmentSocket.AttachToSocket(socket, _pendingEquipmentData.equipmentPrefab);
_currentEquipmentSocket = socket;
}
private void DetachEquipment(string socketName = null)
{
if (_equipmentSocket == null)
return;
string socket = socketName ?? _currentEquipmentSocket;
if (!string.IsNullOrEmpty(socket))
{
_equipmentSocket.DetachFromSocket(socket);
if (socket == _currentEquipmentSocket)
_currentEquipmentSocket = null;
}
}
private System.Collections.IEnumerator DetachEquipmentAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
DetachEquipment();
if (!useAnimationEvents)
{
_isInteracting = false;
}
}
private void OnGUI()
{
if (!IsOwner || _currentInteractable == null) return;
GUIStyle style = new GUIStyle(GUI.skin.label)
{
fontSize = 24,
alignment = TextAnchor.MiddleCenter
};
style.normal.textColor = Color.white;
string prompt = _currentInteractable.GetInteractionPrompt();
if (_isInteracting)
{
prompt += " (진행 중...)";
style.normal.textColor = Color.yellow;
}
GUI.Label(new Rect(Screen.width / 2 - 200, Screen.height - 100, 400, 50), prompt, style);
}
private void OnDestroy()
{
if (_inputActions != null)
{
_inputActions.Dispose();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8af14bc868e5822428f5a0a89b2bbb44