코드 리팩토링

재사용성 및 확장성을 고려하여 코드 전반을 리팩토링함
This commit is contained in:
2026-01-21 01:45:15 +09:00
parent b4ac8f600f
commit db5db4b106
45 changed files with 2775 additions and 248 deletions

View File

@@ -0,0 +1,20 @@
using UnityEngine;
/// <summary>
/// Bridge class that wraps ItemBehavior for use with PlayerActionHandler.
/// This allows the new behavior system to work with the existing action handler.
/// </summary>
[CreateAssetMenu(menuName = "Actions/Behavior Action")]
public class BehaviorActionData : PlayerActionData
{
[HideInInspector]
public ItemBehavior behavior;
public override void ExecuteEffect(GameObject performer, GameObject target)
{
if (behavior != null)
{
behavior.Use(performer, target);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a373ecb07ad66848923d4a455b6d236

View File

@@ -0,0 +1,50 @@
using UnityEngine;
/// <summary>
/// Consumable behavior for healing items, food, etc.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Consumable Behavior")]
public class ConsumableBehavior : ItemBehavior
{
[Header("Consumable Settings")]
[SerializeField] private float healAmount = 20f;
[SerializeField] private float staminaRestore = 0f;
public override bool IsConsumable => true;
private void OnEnable()
{
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Consume";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Consume";
canRepeat = false;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Can use if user has a health component that isn't at full health
var health = user.GetComponent<HealthComponent>();
if (health == null) return false;
// Can use if not at full health (healing) or if it restores stamina
return !health.IsAtFullHealth() || staminaRestore > 0;
}
public override void Use(GameObject user, GameObject target)
{
var health = user.GetComponent<HealthComponent>();
if (health != null && healAmount > 0)
{
health.Heal(healAmount);
}
// Stamina restoration would go here when stamina system is implemented
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
var health = user.GetComponent<HealthComponent>();
if (health == null) return "Cannot use this item";
if (health.IsAtFullHealth() && staminaRestore <= 0) return "Already at full health";
return null;
}
}

View File

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

View File

@@ -0,0 +1,230 @@
using System;
using UnityEngine;
/// <summary>
/// Types of equipment slots available.
/// </summary>
public enum EquipmentSlotType
{
MainHand,
OffHand,
Head,
Body,
Back,
Accessory
}
/// <summary>
/// Represents a single equipment slot that can hold an equipped item.
/// </summary>
[Serializable]
public class EquipmentSlot
{
/// <summary>
/// Type of this equipment slot.
/// </summary>
public EquipmentSlotType SlotType;
/// <summary>
/// Transform where equipment is attached.
/// </summary>
public Transform AttachPoint;
/// <summary>
/// Currently spawned equipment instance.
/// </summary>
[NonSerialized]
public GameObject CurrentEquipment;
/// <summary>
/// Currently equipped item data.
/// </summary>
[NonSerialized]
public ItemData EquippedItem;
/// <summary>
/// Event fired when equipment changes.
/// </summary>
public event Action<EquipmentSlot, ItemData> OnEquipmentChanged;
/// <summary>
/// Equip an item to this slot.
/// </summary>
/// <param name="item">Item to equip (or null to unequip)</param>
public void Equip(ItemData item)
{
// Remove current equipment
Unequip();
if (item == null) return;
// Check if item can be equipped
if (!item.CanBeEquipped) return;
EquippedItem = item;
// Spawn equipment visual
var prefab = item.GetEquipmentPrefab();
if (prefab != null && AttachPoint != null)
{
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, AttachPoint);
CurrentEquipment.transform.localPosition = item.GetPositionOffset();
CurrentEquipment.transform.localRotation = Quaternion.Euler(item.GetRotationOffset());
}
OnEquipmentChanged?.Invoke(this, item);
}
/// <summary>
/// Equip using IEquippableItem interface (more generic).
/// </summary>
/// <param name="equippable">Equippable item</param>
/// <param name="user">The user equipping the item</param>
public void Equip(IEquippableItem equippable, GameObject user)
{
Unequip();
if (equippable == null) return;
var prefab = equippable.GetEquipmentPrefab();
if (prefab == null) return;
// Find or use the configured attach point
Transform attachTo = AttachPoint;
if (attachTo == null && user != null)
{
attachTo = equippable.FindAttachmentPoint(user);
}
if (attachTo != null)
{
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, attachTo);
CurrentEquipment.transform.localPosition = equippable.GetPositionOffset();
CurrentEquipment.transform.localRotation = Quaternion.Euler(equippable.GetRotationOffset());
}
OnEquipmentChanged?.Invoke(this, EquippedItem);
}
/// <summary>
/// Unequip the current item.
/// </summary>
public void Unequip()
{
if (CurrentEquipment != null)
{
UnityEngine.Object.Destroy(CurrentEquipment);
CurrentEquipment = null;
}
var previousItem = EquippedItem;
EquippedItem = null;
if (previousItem != null)
{
OnEquipmentChanged?.Invoke(this, null);
}
}
/// <summary>
/// Check if this slot has equipment.
/// </summary>
public bool HasEquipment => CurrentEquipment != null || EquippedItem != null;
/// <summary>
/// Check if a specific item can be equipped in this slot.
/// </summary>
public bool CanEquip(ItemData item)
{
if (item == null) return true; // Can always "unequip"
return item.CanBeEquipped;
}
}
/// <summary>
/// Manages multiple equipment slots for a character.
/// </summary>
[Serializable]
public class EquipmentManager
{
[SerializeField]
private EquipmentSlot[] _slots;
/// <summary>
/// All equipment slots.
/// </summary>
public EquipmentSlot[] Slots => _slots;
/// <summary>
/// Get a slot by type.
/// </summary>
public EquipmentSlot GetSlot(EquipmentSlotType type)
{
if (_slots == null) return null;
foreach (var slot in _slots)
{
if (slot.SlotType == type)
return slot;
}
return null;
}
/// <summary>
/// Equip an item to the appropriate slot.
/// </summary>
public bool TryEquip(ItemData item, EquipmentSlotType preferredSlot = EquipmentSlotType.MainHand)
{
var slot = GetSlot(preferredSlot);
if (slot != null && slot.CanEquip(item))
{
slot.Equip(item);
return true;
}
return false;
}
/// <summary>
/// Unequip all slots.
/// </summary>
public void UnequipAll()
{
if (_slots == null) return;
foreach (var slot in _slots)
{
slot.Unequip();
}
}
/// <summary>
/// Initialize slots with attach points found on the character.
/// </summary>
public void Initialize(GameObject character, params (EquipmentSlotType type, string attachPointName)[] slotConfigs)
{
_slots = new EquipmentSlot[slotConfigs.Length];
for (int i = 0; i < slotConfigs.Length; i++)
{
var config = slotConfigs[i];
Transform attachPoint = null;
// Find attach point
var transforms = character.GetComponentsInChildren<Transform>();
foreach (var t in transforms)
{
if (t.name == config.attachPointName)
{
attachPoint = t;
break;
}
}
_slots[i] = new EquipmentSlot
{
SlotType = config.type,
AttachPoint = attachPoint
};
}
}
}

View File

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

View File

@@ -1,13 +1,171 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Requirement for performing an action (item cost, etc.).
/// </summary>
[System.Serializable]
public struct ActionRequirement
{
/// <summary>
/// Item ID required (use -1 if no item required).
/// </summary>
public int ItemID;
/// <summary>
/// Amount of the item required.
/// </summary>
public int Amount;
/// <summary>
/// Whether the item is consumed when the action is performed.
/// </summary>
public bool ConsumeOnUse;
public ActionRequirement(int itemID, int amount, bool consumeOnUse = true)
{
ItemID = itemID;
Amount = amount;
ConsumeOnUse = consumeOnUse;
}
/// <summary>
/// No requirement.
/// </summary>
public static ActionRequirement None => new ActionRequirement(-1, 0, false);
}
/// <summary>
/// Describes an action that can be performed.
/// </summary>
[System.Serializable]
public class ActionDescriptor
{
public float duration = 0.5f;
public string animTrigger = "Interact";
// 필요하다면 여기에 사운드 이펙트나 파티클 정보를 추가할 수 있습니다.
/// <summary>
/// Display name of the action.
/// </summary>
public string ActionName = "Action";
/// <summary>
/// Total duration of the action in seconds.
/// </summary>
public float Duration = 0.5f;
/// <summary>
/// Animation trigger name.
/// </summary>
public string AnimTrigger = "Interact";
/// <summary>
/// Animation playback speed multiplier.
/// </summary>
public float AnimSpeed = 1f;
/// <summary>
/// Time within the animation when the effect occurs (for syncing hit with animation).
/// </summary>
public float ImpactDelay = 0f;
/// <summary>
/// Sound effect to play.
/// </summary>
public AudioClip SoundEffect;
/// <summary>
/// Particle effect prefab to spawn.
/// </summary>
public GameObject ParticleEffect;
/// <summary>
/// Stamina cost to perform this action.
/// </summary>
public float StaminaCost = 0f;
/// <summary>
/// Item requirements for this action.
/// </summary>
public ActionRequirement[] ItemRequirements;
/// <summary>
/// Whether this action can be repeated by holding the button.
/// </summary>
public bool CanRepeat = false;
/// <summary>
/// Cooldown time before this action can be performed again.
/// </summary>
public float Cooldown = 0f;
/// <summary>
/// Create a simple action descriptor.
/// </summary>
public static ActionDescriptor Simple(string name, float duration, string animTrigger = "Interact")
{
return new ActionDescriptor
{
ActionName = name,
Duration = duration,
AnimTrigger = animTrigger
};
}
/// <summary>
/// Create an action descriptor for repeatable actions (like mining).
/// </summary>
public static ActionDescriptor Repeatable(string name, float duration, string animTrigger,
float impactDelay, float animSpeed = 1f)
{
return new ActionDescriptor
{
ActionName = name,
Duration = duration,
AnimTrigger = animTrigger,
AnimSpeed = animSpeed,
ImpactDelay = impactDelay,
CanRepeat = true
};
}
}
// 명세를 제공하는 인터페이스
/// <summary>
/// Interface for objects that can provide action descriptors.
/// Implement this to define what actions can be performed on or with an object.
/// </summary>
public interface IActionProvider
{
/// <summary>
/// Get the primary action descriptor for this provider.
/// </summary>
ActionDescriptor GetActionDescriptor();
}
/// <summary>
/// Get all available actions from this provider.
/// Default implementation returns only the primary action.
/// </summary>
IEnumerable<ActionDescriptor> GetAvailableActions()
{
yield return GetActionDescriptor();
}
/// <summary>
/// Check if a specific action can be performed.
/// </summary>
/// <param name="performer">The GameObject attempting the action</param>
/// <param name="action">The action to check</param>
/// <returns>True if the action can be performed</returns>
bool CanPerformAction(GameObject performer, ActionDescriptor action)
{
return true;
}
/// <summary>
/// Get the reason why an action cannot be performed.
/// </summary>
/// <param name="performer">The GameObject attempting the action</param>
/// <param name="action">The action to check</param>
/// <returns>Reason string, or null if action can be performed</returns>
string GetActionBlockedReason(GameObject performer, ActionDescriptor action)
{
return null;
}
}

View File

@@ -7,13 +7,12 @@ public class MiningActionData : PlayerActionData
public override void ExecuteEffect(GameObject performer, GameObject target)
{
if(target == null) return;
if (target == null) return;
if (target.TryGetComponent<MineableBlock>(out var block))
// Use IDamageable interface for all damageable objects
if (target.TryGetComponent<IDamageable>(out var damageable))
{
// 서버 RPC 호출은 블록 내부의 로직을 그대로 사용합니다.
block.TakeDamageRpc(damage);
block.PlayHitEffectClientRpc();
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, performer));
}
}
}
}

View File

@@ -0,0 +1,44 @@
using UnityEngine;
/// <summary>
/// Mining behavior for pickaxes and similar tools.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Mining Behavior")]
public class MiningBehavior : ItemBehavior
{
[Header("Mining Settings")]
[SerializeField] private int damage = 50;
private void OnEnable()
{
// Set default mining values
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Mine";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Attack";
canRepeat = true;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Can always swing, but only deals damage if hitting a mineable block
return true;
}
public override void Use(GameObject user, GameObject target)
{
if (target == null) return;
// Use IDamageable interface for all damageable objects
if (target.TryGetComponent<IDamageable>(out var damageable))
{
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, user));
}
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
if (target == null) return "No target";
if (!target.TryGetComponent<IDamageable>(out _))
return "Cannot mine this object";
return null;
}
}

View File

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

View File

@@ -0,0 +1,41 @@
using UnityEngine;
/// <summary>
/// Placeable behavior for building/placing items.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Placeable Behavior")]
public class PlaceableBehavior : ItemBehavior
{
[Header("Placement Settings")]
[SerializeField] private GameObject placeablePrefab;
[SerializeField] private bool requiresGround = true;
[SerializeField] private float placementRange = 5f;
public override bool IsConsumable => true;
private void OnEnable()
{
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Place";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Place";
canRepeat = false;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Would integrate with BuildManager for placement validation
return placeablePrefab != null;
}
public override void Use(GameObject user, GameObject target)
{
// Actual placement would be handled by BuildManager
// This is a placeholder for the behavior pattern
Debug.Log($"[PlaceableBehavior] Would place {placeablePrefab?.name}");
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
if (placeablePrefab == null) return "Invalid placement item";
return null;
}
}

View File

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

View File

@@ -33,11 +33,11 @@ public class PlayerActionHandler : NetworkBehaviour
private IEnumerator InteractionRoutine(ActionDescriptor desc, IInteractable target)
{
_isBusy = true;
if (desc != null) _animator.SetTrigger(desc.animTrigger);
if (desc != null) _animator.SetTrigger(desc.AnimTrigger);
target.Interact(gameObject); // 로직 실행
yield return new WaitForSeconds(desc?.duration ?? 0.1f);
yield return new WaitForSeconds(desc?.Duration ?? 0.1f);
_isBusy = false;
}

View File

@@ -1,25 +1,63 @@
using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Handles equipment visuals for the player.
/// Uses the new EquipmentSlot system while maintaining backwards compatibility.
/// </summary>
public class PlayerEquipmentHandler : NetworkBehaviour
{
[SerializeField] private Transform toolAnchor; // 캐릭터 손의 소켓 위치
[Header("Equipment Settings")]
[SerializeField] private Transform mainHandAnchor;
[SerializeField] private Transform offHandAnchor;
[Header("Slot Configuration")]
[SerializeField] private EquipmentSlot mainHandSlot;
private PlayerInventory _inventory;
private GameObject _currentToolInstance; // 현재 생성된 도구 모델
private GameObject _currentToolInstance;
void Awake()
{
_inventory = GetComponent<PlayerInventory>();
// Initialize main hand slot if not configured in inspector
if (mainHandSlot == null)
{
mainHandSlot = new EquipmentSlot
{
SlotType = EquipmentSlotType.MainHand,
AttachPoint = mainHandAnchor
};
}
else if (mainHandSlot.AttachPoint == null)
{
mainHandSlot.AttachPoint = mainHandAnchor;
}
}
public override void OnNetworkSpawn()
{
// 인벤토리의 슬롯 변경 이벤트 구독
// OnSlotChanged는 (이전 값, 새 값) 두 개의 인자를 전달합니다.
_inventory.OnSlotChanged += HandleSlotChanged;
// Subscribe to inventory slot changes
if (_inventory != null)
{
_inventory.OnSlotChanged += HandleSlotChanged;
// 게임 시작 시 처음에 들고 있는 아이템 모델 생성
UpdateEquippedModel(_inventory.SelectedSlotIndex);
// Initialize with current slot
UpdateEquippedModel(_inventory.SelectedSlotIndex);
}
}
public override void OnNetworkDespawn()
{
// Unsubscribe to prevent memory leaks
if (_inventory != null)
{
_inventory.OnSlotChanged -= HandleSlotChanged;
}
// Clean up equipment
mainHandSlot?.Unequip();
}
private void HandleSlotChanged(int previousValue, int newValue)
@@ -29,30 +67,58 @@ public class PlayerEquipmentHandler : NetworkBehaviour
private void UpdateEquippedModel(int slotIndex)
{
// 1. 기존 도구가 있다면 파괴
if (_currentToolInstance != null)
// Get item data for the selected slot
ItemData data = _inventory?.GetItemDataInSlot(slotIndex);
// Use new equipment slot system
if (data != null && data.CanBeEquipped)
{
Destroy(_currentToolInstance);
// Use IEquippableItem interface
mainHandSlot.Equip(data);
}
else
{
mainHandSlot.Unequip();
}
// 2. 현재 선택된 슬롯의 데이터 확인
ItemData data = _inventory.GetItemDataInSlot(slotIndex);
// 3. 도구인 경우에만 모델 생성
if (data != null && data.isTool && data.toolPrefab != null)
{
_currentToolInstance = Instantiate(data.toolPrefab, toolAnchor);
// ItemData에 설정된 오프셋 적용
_currentToolInstance.transform.localPosition = data.equipPositionOffset;
_currentToolInstance.transform.localRotation = Quaternion.Euler(data.equipRotationOffset);
}
// Update legacy reference for any code that might check it
_currentToolInstance = mainHandSlot.CurrentEquipment;
}
public override void OnNetworkDespawn()
/// <summary>
/// Get the currently equipped item data.
/// </summary>
public ItemData GetEquippedItem()
{
// 이벤트 구독 해제 (메모리 누수 방지)
if (_inventory != null)
_inventory.OnSlotChanged -= HandleSlotChanged;
return mainHandSlot?.EquippedItem;
}
}
/// <summary>
/// Get the currently equipped tool instance.
/// </summary>
public GameObject GetCurrentToolInstance()
{
return mainHandSlot?.CurrentEquipment ?? _currentToolInstance;
}
/// <summary>
/// Check if player has equipment in main hand.
/// </summary>
public bool HasMainHandEquipment => mainHandSlot?.HasEquipment ?? false;
/// <summary>
/// Force refresh the equipped model.
/// </summary>
public void RefreshEquipment()
{
if (_inventory != null)
{
UpdateEquippedModel(_inventory.SelectedSlotIndex);
}
}
/// <summary>
/// Get the main hand equipment slot for advanced usage.
/// </summary>
public EquipmentSlot MainHandSlot => mainHandSlot;
}

View File

@@ -204,35 +204,50 @@ public class PlayerNetworkController : NetworkBehaviour
if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
// 1. 액션 (좌클릭) - 대상이 없어도 나감
// PlayerNetworkController.cs 중 일부
// 1. Action (Left Click) - executes even without target
private void OnActionInput()
{
if (!IsOwner || _actionHandler.IsBusy) return;
ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null) return;
// 로그 1: 아이템 확인
if (selectedItem == null) { Debug.Log("선택된 아이템이 없음"); return; }
// 로그 2: 도구 여부 및 액션 데이터 확인
Debug.Log($"현재 아이템: {selectedItem.itemName}, 도구여부: {selectedItem.isTool}, 액션데이터: {selectedItem.toolAction != null}");
if (selectedItem.isTool && selectedItem.toolAction != null)
// Check if item has behavior (new system)
if (selectedItem.behavior != null)
{
if (_lastHighlightedBlock != null)
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
// Use the new behavior system
if (selectedItem.CanUse(gameObject, target))
{
Debug.Log($"채광 시작: {_lastHighlightedBlock.name}");
_actionHandler.PerformAction(selectedItem.toolAction, _lastHighlightedBlock.gameObject);
}
else
{
Debug.Log("조준된 블록이 없음 (하이라이트 확인 필요)");
_actionHandler.PerformAction(selectedItem.toolAction, null);
// Get action descriptor and perform action
var actionDesc = selectedItem.GetUseAction();
if (actionDesc != null)
{
_actionHandler.PerformAction(
CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
target
);
}
}
}
}
// Helper to bridge between new ActionDescriptor and legacy PlayerActionData
private PlayerActionData CreateActionDataFromDescriptor(ActionDescriptor desc, ItemBehavior behavior)
{
// Create a temporary runtime action data
var actionData = ScriptableObject.CreateInstance<BehaviorActionData>();
actionData.actionName = desc.ActionName;
actionData.duration = desc.Duration;
actionData.animTrigger = desc.AnimTrigger;
actionData.impactDelay = desc.ImpactDelay;
actionData.baseSpeed = desc.AnimSpeed;
actionData.canRepeat = desc.CanRepeat;
actionData.behavior = behavior;
return actionData;
}
// 2. 인터랙션 (F키) - 대상이 없으면 아예 시작 안 함
private void OnInteractTap()
{
@@ -250,13 +265,13 @@ public class PlayerNetworkController : NetworkBehaviour
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out var target))
{
if (target.TryGetComponent<MineableBlock>(out var block))
// Use IDamageable interface instead of MineableBlock directly
if (target.TryGetComponent<IDamageable>(out var damageable))
{
// 서버에서 최종 거리 검증 후 대미지 적용
// Server-side distance validation before applying damage
if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f)
{
block.TakeDamageRpc(miningDamage);
block.PlayHitEffectClientRpc();
damageable.TakeDamage(new DamageInfo(miningDamage, DamageType.Mining, gameObject));
}
}
}
@@ -487,10 +502,11 @@ public class PlayerNetworkController : NetworkBehaviour
private void HandleContinuousAction()
{
ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null || !selectedItem.isTool || selectedItem.toolAction == null) return;
if (selectedItem == null || selectedItem.behavior == null) return;
// [핵심] 반복 가능한 액션일 때만 Update에서 재실행
if (selectedItem.toolAction.canRepeat)
// Only repeat if action supports it
var actionDesc = selectedItem.GetUseAction();
if (actionDesc != null && actionDesc.CanRepeat)
{
TryExecuteAction();
}
@@ -501,15 +517,23 @@ public class PlayerNetworkController : NetworkBehaviour
if (_actionHandler.IsBusy) return;
ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem != null && selectedItem.isTool && selectedItem.toolAction != null)
if (selectedItem == null || selectedItem.behavior == null) return;
var actionDesc = selectedItem.GetUseAction();
if (actionDesc == null) return;
// Skip if non-repeatable action already executed once
if (!actionDesc.CanRepeat && _hasExecutedOnce) return;
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
if (selectedItem.CanUse(gameObject, target))
{
// 단발성 액션인데 이미 한 번 실행했다면 스킵
if (!selectedItem.toolAction.canRepeat && _hasExecutedOnce) return;
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
_actionHandler.PerformAction(selectedItem.toolAction, target);
_hasExecutedOnce = true; // 실행 기록 저장
_actionHandler.PerformAction(
CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
target
);
_hasExecutedOnce = true;
}
}