Initial Unity project commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc04ef6c523954b4a94148d156261c5e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public interface IInteractCondition
|
||||
{
|
||||
bool CanInteract(GameObject interactor, out string failReason);
|
||||
void OnInteractSucceeded(GameObject interactor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 276a36eb0f3667446bb5302f67108f9f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public abstract class InteractConditionBehaviour : MonoBehaviour, IInteractCondition
|
||||
{
|
||||
public abstract bool CanInteract(GameObject interactor, out string failReason);
|
||||
public abstract void OnInteractSucceeded(GameObject interactor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d8be21aac1d2e84abce8f6482630586
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public class InteractCountAccumulator : InteractConditionBehaviour
|
||||
{
|
||||
[SerializeField] private Interaction.InteractCountThresholdSystem targetSystem;
|
||||
[SerializeField] private int addAmount = 1;
|
||||
[SerializeField] private bool autoFindTargetSystemIfNull = true;
|
||||
|
||||
private bool hadTargetSystemAtEnable = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
hadTargetSystemAtEnable = targetSystem != null;
|
||||
}
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string failReason)
|
||||
{
|
||||
failReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
if (addAmount <= 0) return;
|
||||
|
||||
if (targetSystem == null)
|
||||
{
|
||||
if (hadTargetSystemAtEnable) return;
|
||||
if (!autoFindTargetSystemIfNull) return;
|
||||
targetSystem = FindObjectOfType<Interaction.InteractCountThresholdSystem>(true);
|
||||
}
|
||||
|
||||
if (targetSystem == null) return;
|
||||
|
||||
targetSystem.Add(addAmount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf38ecf9d6a2b4e4db75e90f1e7cebaa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using Inventory;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public class RequireItemsInteractCondition : InteractConditionBehaviour
|
||||
{
|
||||
public enum RequireMode
|
||||
{
|
||||
RequireAll = 0,
|
||||
RequireAny = 1
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private struct Requirement
|
||||
{
|
||||
public string requiredItemId;
|
||||
[Min(1)] public int requiredAmount;
|
||||
}
|
||||
|
||||
[Header("需求")]
|
||||
[SerializeField] private RequireMode mode = RequireMode.RequireAll;
|
||||
[SerializeField] private Requirement[] requirements;
|
||||
|
||||
[Header("成功后处理")]
|
||||
[SerializeField] private bool consumeOnSuccess;
|
||||
[SerializeField] private bool unlockAfterSuccess;
|
||||
[SerializeField] private bool unlocked;
|
||||
[SerializeField] private InteractUnlockState unlockState;
|
||||
|
||||
[Header("失败提示")]
|
||||
[SerializeField] private string failReason;
|
||||
|
||||
[Header("容错")]
|
||||
[SerializeField] private bool allowIfInventoryMissing;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLog;
|
||||
|
||||
private int lastMatchedIndex = -1;
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
lastMatchedIndex = -1;
|
||||
|
||||
if (debugLog)
|
||||
{
|
||||
Debug.Log(
|
||||
$"[RequireItemsInteractCondition] CanInteract on '{name}' " +
|
||||
$"mode={mode} consumeOnSuccess={consumeOnSuccess} unlockAfterSuccess={unlockAfterSuccess} unlocked={GetUnlocked()} " +
|
||||
$"requirementsCount={(requirements == null ? 0 : requirements.Length)}",
|
||||
this);
|
||||
}
|
||||
|
||||
if (unlockAfterSuccess && GetUnlocked())
|
||||
{
|
||||
if (debugLog) Debug.Log("[RequireItemsInteractCondition] Bypass: unlocked=true", this);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (requirements == null || requirements.Length <= 0)
|
||||
{
|
||||
if (debugLog) Debug.Log("[RequireItemsInteractCondition] Bypass: requirements empty", this);
|
||||
return true;
|
||||
}
|
||||
|
||||
var inventory = InventorySystem.Instance;
|
||||
if (inventory == null)
|
||||
{
|
||||
if (allowIfInventoryMissing) return true;
|
||||
reason = string.IsNullOrWhiteSpace(failReason) ? "缺少背包系统" : failReason;
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Blocked: inventory missing, reason='{reason}'", this);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode == RequireMode.RequireAny)
|
||||
{
|
||||
for (int i = 0; i < requirements.Length; i++)
|
||||
{
|
||||
var req = requirements[i];
|
||||
if (string.IsNullOrWhiteSpace(req.requiredItemId)) continue;
|
||||
|
||||
int amount = req.requiredAmount <= 0 ? 1 : req.requiredAmount;
|
||||
int count = CountItemsById(inventory, req.requiredItemId);
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Check(Any) id='{req.requiredItemId}' need={amount} have={count}", this);
|
||||
if (count >= amount)
|
||||
{
|
||||
lastMatchedIndex = i;
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Passed(Any) matchedIndex={lastMatchedIndex}", this);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
reason = BuildFailReason();
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Blocked(Any) reason='{reason}'", this);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < requirements.Length; i++)
|
||||
{
|
||||
var req = requirements[i];
|
||||
if (string.IsNullOrWhiteSpace(req.requiredItemId)) continue;
|
||||
|
||||
int amount = req.requiredAmount <= 0 ? 1 : req.requiredAmount;
|
||||
int count = CountItemsById(inventory, req.requiredItemId);
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Check(All) id='{req.requiredItemId}' need={amount} have={count}", this);
|
||||
if (count < amount)
|
||||
{
|
||||
reason = BuildFailReason();
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] Blocked(All) reason='{reason}'", this);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (debugLog) Debug.Log("[RequireItemsInteractCondition] Passed(All)", this);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
if (unlockAfterSuccess && GetUnlocked()) return;
|
||||
|
||||
if (consumeOnSuccess)
|
||||
{
|
||||
var inventory = InventorySystem.Instance;
|
||||
if (inventory != null)
|
||||
{
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] OnInteractSucceeded: consuming, mode={mode}, matchedIndex={lastMatchedIndex}", this);
|
||||
Consume(inventory);
|
||||
}
|
||||
}
|
||||
|
||||
if (unlockAfterSuccess) SetUnlocked(true);
|
||||
if (debugLog) Debug.Log($"[RequireItemsInteractCondition] OnInteractSucceeded: done, unlocked={GetUnlocked()}", this);
|
||||
}
|
||||
|
||||
private bool GetUnlocked()
|
||||
{
|
||||
if (unlockState != null) return unlockState.IsUnlocked;
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
private void SetUnlocked(bool value)
|
||||
{
|
||||
if (unlockState != null)
|
||||
{
|
||||
unlockState.SetUnlocked(value);
|
||||
return;
|
||||
}
|
||||
|
||||
unlocked = value;
|
||||
}
|
||||
|
||||
private void Consume(InventorySystem inventory)
|
||||
{
|
||||
if (requirements == null || requirements.Length <= 0) return;
|
||||
|
||||
if (mode == RequireMode.RequireAny)
|
||||
{
|
||||
if (lastMatchedIndex < 0 || lastMatchedIndex >= requirements.Length) return;
|
||||
|
||||
var req = requirements[lastMatchedIndex];
|
||||
if (string.IsNullOrWhiteSpace(req.requiredItemId)) return;
|
||||
int amount = req.requiredAmount <= 0 ? 1 : req.requiredAmount;
|
||||
ConsumeItemsById(inventory, req.requiredItemId, amount);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < requirements.Length; i++)
|
||||
{
|
||||
var req = requirements[i];
|
||||
if (string.IsNullOrWhiteSpace(req.requiredItemId)) continue;
|
||||
|
||||
int amount = req.requiredAmount <= 0 ? 1 : req.requiredAmount;
|
||||
ConsumeItemsById(inventory, req.requiredItemId, amount);
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildFailReason()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(failReason)) return failReason;
|
||||
|
||||
if (requirements == null || requirements.Length <= 0) return "需要特定道具";
|
||||
|
||||
if (mode == RequireMode.RequireAny)
|
||||
{
|
||||
return $"需要任意一种物品: {BuildRequirementsList()}";
|
||||
}
|
||||
|
||||
return $"需要全部物品: {BuildRequirementsList()}";
|
||||
}
|
||||
|
||||
private string BuildRequirementsList()
|
||||
{
|
||||
string result = null;
|
||||
for (int i = 0; i < requirements.Length; i++)
|
||||
{
|
||||
var req = requirements[i];
|
||||
if (string.IsNullOrWhiteSpace(req.requiredItemId)) continue;
|
||||
|
||||
int amount = req.requiredAmount <= 0 ? 1 : req.requiredAmount;
|
||||
string token = amount == 1 ? req.requiredItemId : $"{req.requiredItemId}x{amount}";
|
||||
result = string.IsNullOrEmpty(result) ? token : $"{result}, {token}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(result) ? "(未配置)" : result;
|
||||
}
|
||||
|
||||
private static int CountItemsById(InventorySystem inventory, string itemId)
|
||||
{
|
||||
int total = 0;
|
||||
var list = inventory.inventory;
|
||||
if (list == null) return 0;
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
var entry = list[i];
|
||||
if (entry?.data == null) continue;
|
||||
if (entry.data.id != itemId) continue;
|
||||
total += entry.stackSize;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void ConsumeItemsById(InventorySystem inventory, string itemId, int amount)
|
||||
{
|
||||
int remaining = amount;
|
||||
var list = inventory.inventory;
|
||||
if (list == null) return;
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (remaining <= 0) break;
|
||||
|
||||
var entry = list[i];
|
||||
if (entry?.data == null) continue;
|
||||
if (entry.data.id != itemId) continue;
|
||||
|
||||
int take = Mathf.Min(entry.stackSize, remaining);
|
||||
if (take <= 0) continue;
|
||||
|
||||
inventory.RemoveFromSlot(i, take);
|
||||
remaining -= take;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b8090c7c4e156d4da8e0c291573d6d5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public class RequireUnlockedInteractCondition : InteractConditionBehaviour
|
||||
{
|
||||
[SerializeField] private InteractUnlockState unlockState;
|
||||
|
||||
[Header("失败提示")]
|
||||
[SerializeField] private string failReason = "尚未解锁";
|
||||
|
||||
[Header("容错")]
|
||||
[SerializeField] private bool allowIfUnlockStateMissing;
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
|
||||
var state = unlockState != null ? unlockState : GetComponentInParent<InteractUnlockState>();
|
||||
if (state == null)
|
||||
{
|
||||
if (allowIfUnlockStateMissing) return true;
|
||||
reason = string.IsNullOrWhiteSpace(failReason) ? "尚未解锁" : failReason;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.IsUnlocked) return true;
|
||||
|
||||
reason = string.IsNullOrWhiteSpace(failReason) ? "尚未解锁" : failReason;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 710fa35292f9cb441828c7da5df3d633
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,56 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction.Conditions
|
||||
{
|
||||
public class UnlockStatesOnInteractSucceeded : InteractConditionBehaviour
|
||||
{
|
||||
[SerializeField] private InteractUnlockState selfState;
|
||||
[SerializeField] private bool unlockSelf = true;
|
||||
[SerializeField] private InteractUnlockState[] targetStates;
|
||||
|
||||
[Header("Behavior")]
|
||||
[SerializeField] private bool onlyOnce = true;
|
||||
[SerializeField] private bool triggered;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLog;
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string failReason)
|
||||
{
|
||||
failReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
if (onlyOnce && triggered) return;
|
||||
|
||||
if (unlockSelf)
|
||||
{
|
||||
var state = selfState != null ? selfState : GetComponentInParent<InteractUnlockState>();
|
||||
if (state != null) state.SetUnlocked(true);
|
||||
}
|
||||
|
||||
if (targetStates != null)
|
||||
{
|
||||
for (int i = 0; i < targetStates.Length; i++)
|
||||
{
|
||||
var state = targetStates[i];
|
||||
if (state == null) continue;
|
||||
state.SetUnlocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
triggered = true;
|
||||
|
||||
if (debugLog)
|
||||
{
|
||||
Debug.Log(
|
||||
$"[UnlockStatesOnInteractSucceeded] Triggered on '{name}' " +
|
||||
$"unlockSelf={unlockSelf} targets={(targetStates == null ? 0 : targetStates.Length)}",
|
||||
this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d572c93dda995242bd2be40cfecc54c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,111 @@
|
||||
using UnityEngine;
|
||||
using DG.Tweening; // 引入 DOTween 命名空间
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class DoorController : MonoBehaviour, IInteractable
|
||||
{
|
||||
[System.Serializable]
|
||||
public class DoorLeaf
|
||||
{
|
||||
[Tooltip("门页对象")]
|
||||
public Transform doorTransform;
|
||||
|
||||
[Tooltip("开门时,该门页的 Y 轴角度 (例如 90 或 -90)")]
|
||||
public float openAngle = 90f;
|
||||
}
|
||||
|
||||
[Header("门页设置")]
|
||||
[Tooltip("请绑定门页对象 (最多两个),并设置各自的开门角度。关门角度默认为 0 度。")]
|
||||
[SerializeField] private DoorLeaf[] doorLeaves;
|
||||
|
||||
[Header("动画设置")]
|
||||
[Tooltip("开门需要几秒钟")]
|
||||
[SerializeField] private float duration = 1.0f;
|
||||
[SerializeField] private Ease animationEase = Ease.OutQuad;
|
||||
|
||||
// 运行时状态
|
||||
private bool isOpen = false; //默认关闭
|
||||
|
||||
public bool IsOpen => isOpen;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 校验配置
|
||||
if (doorLeaves == null || doorLeaves.Length == 0)
|
||||
{
|
||||
Debug.LogWarning("[DoorController] 未绑定任何门页 (Door Leaves)!请在 Inspector 中设置。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化状态判断:
|
||||
// 我们以"第一个门页"的状态为准来判断当前整个门是开还是关
|
||||
var firstLeaf = doorLeaves[0];
|
||||
if (firstLeaf.doorTransform == null) return;
|
||||
|
||||
float currentY = firstLeaf.doorTransform.localEulerAngles.y;
|
||||
|
||||
// 处理欧拉角 360 度问题 (比如 -90 度可能是 270)
|
||||
if (currentY > 180) currentY -= 360;
|
||||
|
||||
// 关门角度默认为 0
|
||||
float distToClose = Mathf.Abs(Mathf.DeltaAngle(currentY, 0f));
|
||||
float distToOpen = Mathf.Abs(Mathf.DeltaAngle(currentY, firstLeaf.openAngle));
|
||||
|
||||
isOpen = distToOpen < distToClose;
|
||||
|
||||
Debug.Log($"[Door] 初始化完成。当前角度: {currentY}, 判定为: {(isOpen ? "开" : "关")}");
|
||||
}
|
||||
|
||||
public void Interact()
|
||||
{
|
||||
if (doorLeaves == null || doorLeaves.Length == 0) return;
|
||||
|
||||
Debug.Log($"<color=green>[Door] 收到交互请求!当前状态 isOpen: {isOpen}</color>");
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public string GetInteractPrompt()
|
||||
{
|
||||
return isOpen ? "关闭" : "打开";
|
||||
}
|
||||
|
||||
public void OpenDoor()
|
||||
{
|
||||
if (isOpen) return;
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public void CloseDoor()
|
||||
{
|
||||
if (!isOpen) return;
|
||||
ToggleDoor();
|
||||
}
|
||||
|
||||
public void ToggleDoor()
|
||||
{
|
||||
isOpen = !isOpen;
|
||||
|
||||
// 遍历所有门页进行旋转
|
||||
foreach (var leaf in doorLeaves)
|
||||
{
|
||||
if (leaf == null || leaf.doorTransform == null) continue;
|
||||
|
||||
Vector3 currentRot = leaf.doorTransform.localEulerAngles;
|
||||
// 关门目标为 0,开门目标为 leaf.openAngle
|
||||
float targetY = isOpen ? leaf.openAngle : 0f;
|
||||
|
||||
Vector3 targetRotation = new Vector3(currentRot.x, targetY, currentRot.z);
|
||||
|
||||
// --- DOTween 核心代码 ---
|
||||
|
||||
// 1. 杀掉该对象上可能正在运行的旋转动画,防止冲突
|
||||
leaf.doorTransform.DOKill();
|
||||
|
||||
// 2. 创建新动画
|
||||
leaf.doorTransform.DOLocalRotate(targetRotation, duration)
|
||||
.SetEase(animationEase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36e48be81385bb64494fc83d369c8303
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Inventory;
|
||||
using Interaction.Conditions;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class HydroponicsGrowRack : InteractConditionBehaviour, IInteractable
|
||||
{
|
||||
[Serializable]
|
||||
private class ItemAmount
|
||||
{
|
||||
public ItemData item;
|
||||
[Min(1)]
|
||||
public int amount = 1;
|
||||
}
|
||||
|
||||
private enum RackState
|
||||
{
|
||||
Empty = 0,
|
||||
Growing = 1,
|
||||
Mature = 2
|
||||
}
|
||||
|
||||
private enum PendingAction
|
||||
{
|
||||
None = 0,
|
||||
Plant = 1,
|
||||
Harvest = 2
|
||||
}
|
||||
|
||||
[Header("物品配置")]
|
||||
[SerializeField] private List<ItemAmount> plantCosts = new List<ItemAmount>();
|
||||
[SerializeField] private List<ItemAmount> harvestRewards = new List<ItemAmount>();
|
||||
|
||||
[Header("事件")]
|
||||
[SerializeField] private UnityEvent onHarvested;
|
||||
|
||||
[Header("成长配置")]
|
||||
[Tooltip("按顺序填写各阶段子物体(例如 S1,S2,S3,S4)。脚本会在运行时只启用当前阶段。")]
|
||||
[SerializeField] private List<GameObject> growthStages = new List<GameObject>();
|
||||
|
||||
[Tooltip("若填写且长度等于阶段数,则按每个阶段持续时间驱动;否则使用 totalGrowSeconds 均分。")]
|
||||
[SerializeField] private List<float> stageDurationsSeconds = new List<float>();
|
||||
|
||||
[Min(0.01f)]
|
||||
[SerializeField] private float totalGrowSeconds = 120f;
|
||||
|
||||
[Header("状态(运行时)")]
|
||||
[SerializeField] private RackState state = RackState.Empty;
|
||||
|
||||
[SerializeField] private int currentStageIndex = -1;
|
||||
[SerializeField] private float plantedAtTime = -1f;
|
||||
|
||||
private PendingAction pendingAction = PendingAction.None;
|
||||
private RackState stateBeforeAction;
|
||||
private int stageBeforeAction;
|
||||
private float plantedAtBeforeAction;
|
||||
|
||||
private float[] cumulativeStageEnds;
|
||||
private float totalDurationCached;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
RebuildDurationsCache();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (growthStages == null) growthStages = new List<GameObject>();
|
||||
if (growthStages.Count == 0) AutoFillGrowthStagesFromChildrenRuntime();
|
||||
|
||||
RebuildDurationsCache();
|
||||
|
||||
int stageCount = growthStages == null ? 0 : growthStages.Count;
|
||||
if (stageCount > 0)
|
||||
{
|
||||
int activeIndex = -1;
|
||||
for (int i = 0; i < stageCount; i++)
|
||||
{
|
||||
var go = growthStages[i];
|
||||
if (go == null) continue;
|
||||
if (!go.activeSelf) continue;
|
||||
activeIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (activeIndex >= 0)
|
||||
{
|
||||
currentStageIndex = Mathf.Clamp(activeIndex, 0, stageCount - 1);
|
||||
ApplyVisuals(currentStageIndex);
|
||||
|
||||
if (currentStageIndex >= stageCount - 1)
|
||||
{
|
||||
state = RackState.Mature;
|
||||
plantedAtTime = Time.time - totalDurationCached;
|
||||
}
|
||||
else
|
||||
{
|
||||
state = RackState.Growing;
|
||||
float stageStartElapsed = currentStageIndex <= 0 ? 0f : cumulativeStageEnds[currentStageIndex - 1];
|
||||
plantedAtTime = Time.time - stageStartElapsed;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state == RackState.Empty)
|
||||
{
|
||||
currentStageIndex = -1;
|
||||
plantedAtTime = -1f;
|
||||
ApplyVisuals(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (growthStages == null || growthStages.Count == 0)
|
||||
{
|
||||
currentStageIndex = -1;
|
||||
ApplyVisuals(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == RackState.Growing)
|
||||
{
|
||||
if (plantedAtTime < 0f) plantedAtTime = Time.time;
|
||||
currentStageIndex = Mathf.Clamp(currentStageIndex, 0, growthStages.Count - 1);
|
||||
if (currentStageIndex < 0) currentStageIndex = 0;
|
||||
ApplyVisuals(currentStageIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == RackState.Mature)
|
||||
{
|
||||
currentStageIndex = Mathf.Clamp(growthStages.Count - 1, 0, growthStages.Count - 1);
|
||||
ApplyVisuals(currentStageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (totalGrowSeconds < 0.01f) totalGrowSeconds = 0.01f;
|
||||
|
||||
if (growthStages == null) growthStages = new List<GameObject>();
|
||||
if (stageDurationsSeconds == null) stageDurationsSeconds = new List<float>();
|
||||
if (plantCosts == null) plantCosts = new List<ItemAmount>();
|
||||
if (harvestRewards == null) harvestRewards = new List<ItemAmount>();
|
||||
|
||||
for (int i = 0; i < plantCosts.Count; i++)
|
||||
{
|
||||
if (plantCosts[i] == null) continue;
|
||||
if (plantCosts[i].amount < 1) plantCosts[i].amount = 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < harvestRewards.Count; i++)
|
||||
{
|
||||
if (harvestRewards[i] == null) continue;
|
||||
if (harvestRewards[i].amount < 1) harvestRewards[i].amount = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (state != RackState.Growing) return;
|
||||
if (growthStages == null || growthStages.Count == 0) return;
|
||||
if (plantedAtTime == -1f) return;
|
||||
|
||||
float elapsed = Time.time - plantedAtTime;
|
||||
int stageIndex = GetStageIndexByElapsed(elapsed);
|
||||
if (stageIndex != currentStageIndex)
|
||||
{
|
||||
currentStageIndex = stageIndex;
|
||||
ApplyVisuals(currentStageIndex);
|
||||
}
|
||||
|
||||
if (elapsed >= totalDurationCached)
|
||||
{
|
||||
state = RackState.Mature;
|
||||
currentStageIndex = Mathf.Clamp(growthStages.Count - 1, 0, growthStages.Count - 1);
|
||||
ApplyVisuals(currentStageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string failReason)
|
||||
{
|
||||
failReason = string.Empty;
|
||||
|
||||
if (InventorySystem.Instance == null)
|
||||
{
|
||||
failReason = "未找到玩家背包";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (plantCosts == null || plantCosts.Count == 0 || harvestRewards == null || harvestRewards.Count == 0)
|
||||
{
|
||||
failReason = "未配置种植消耗或收获产出";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state == RackState.Growing)
|
||||
{
|
||||
failReason = "正在生长";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state == RackState.Empty)
|
||||
{
|
||||
for (int i = 0; i < plantCosts.Count; i++)
|
||||
{
|
||||
var cost = plantCosts[i];
|
||||
if (cost == null || cost.item == null)
|
||||
{
|
||||
failReason = "未配置种植消耗";
|
||||
return false;
|
||||
}
|
||||
|
||||
int have = GetItemCount(InventorySystem.Instance, cost.item);
|
||||
if (have < Mathf.Max(1, cost.amount))
|
||||
{
|
||||
failReason = "缺少种植材料";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state == RackState.Mature)
|
||||
{
|
||||
if (!CanAddAll(InventorySystem.Instance, harvestRewards))
|
||||
{
|
||||
failReason = "背包已满";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
failReason = "状态错误";
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
if (InventorySystem.Instance == null)
|
||||
{
|
||||
pendingAction = PendingAction.None;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingAction == PendingAction.Plant)
|
||||
{
|
||||
if (plantCosts == null || plantCosts.Count == 0)
|
||||
{
|
||||
RestoreBeforePending();
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < plantCosts.Count; i++)
|
||||
{
|
||||
var cost = plantCosts[i];
|
||||
if (cost == null || cost.item == null)
|
||||
{
|
||||
RestoreBeforePending();
|
||||
pendingAction = PendingAction.None;
|
||||
return;
|
||||
}
|
||||
|
||||
int amount = Mathf.Max(1, cost.amount);
|
||||
if (GetItemCount(InventorySystem.Instance, cost.item) < amount)
|
||||
{
|
||||
RestoreBeforePending();
|
||||
pendingAction = PendingAction.None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < plantCosts.Count; i++)
|
||||
{
|
||||
var cost = plantCosts[i];
|
||||
if (cost == null || cost.item == null) continue;
|
||||
InventorySystem.Instance.Remove(cost.item, Mathf.Max(1, cost.amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (pendingAction == PendingAction.Harvest)
|
||||
{
|
||||
if (harvestRewards == null || harvestRewards.Count == 0)
|
||||
{
|
||||
RestoreBeforePending();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!CanAddAll(InventorySystem.Instance, harvestRewards))
|
||||
{
|
||||
RestoreBeforePending();
|
||||
}
|
||||
else
|
||||
{
|
||||
bool allAdded = true;
|
||||
for (int i = 0; i < harvestRewards.Count; i++)
|
||||
{
|
||||
var reward = harvestRewards[i];
|
||||
if (reward == null || reward.item == null)
|
||||
{
|
||||
allAdded = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!InventorySystem.Instance.Add(reward.item, Mathf.Max(1, reward.amount)))
|
||||
{
|
||||
allAdded = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allAdded)
|
||||
{
|
||||
onHarvested?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
RestoreBeforePending();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction = PendingAction.None;
|
||||
}
|
||||
|
||||
public void Interact()
|
||||
{
|
||||
if (pendingAction != PendingAction.None) return;
|
||||
|
||||
if (state == RackState.Empty)
|
||||
{
|
||||
BeginPending(PendingAction.Plant);
|
||||
StartGrowing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == RackState.Mature)
|
||||
{
|
||||
BeginPending(PendingAction.Harvest);
|
||||
ResetToEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[ContextMenu("Auto Fill Growth Stages From Children")]
|
||||
private void AutoFillGrowthStagesFromChildren()
|
||||
{
|
||||
var children = new List<Transform>();
|
||||
for (int i = 0; i < transform.childCount; i++)
|
||||
{
|
||||
children.Add(transform.GetChild(i));
|
||||
}
|
||||
|
||||
children.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
|
||||
|
||||
growthStages.Clear();
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
if (children[i] == null) continue;
|
||||
if (children[i].name.IndexOf("_S", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
growthStages.Add(children[i].gameObject);
|
||||
}
|
||||
|
||||
RebuildDurationsCache();
|
||||
ApplyVisuals(state == RackState.Empty ? -1 : currentStageIndex);
|
||||
}
|
||||
|
||||
private void AutoFillGrowthStagesFromChildrenRuntime()
|
||||
{
|
||||
var children = new List<Transform>();
|
||||
for (int i = 0; i < transform.childCount; i++)
|
||||
{
|
||||
children.Add(transform.GetChild(i));
|
||||
}
|
||||
|
||||
children.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
|
||||
|
||||
growthStages.Clear();
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
if (children[i] == null) continue;
|
||||
if (children[i].name.IndexOf("_S", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
growthStages.Add(children[i].gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginPending(PendingAction action)
|
||||
{
|
||||
pendingAction = action;
|
||||
stateBeforeAction = state;
|
||||
stageBeforeAction = currentStageIndex;
|
||||
plantedAtBeforeAction = plantedAtTime;
|
||||
}
|
||||
|
||||
private void RestoreBeforePending()
|
||||
{
|
||||
state = stateBeforeAction;
|
||||
currentStageIndex = stageBeforeAction;
|
||||
plantedAtTime = plantedAtBeforeAction;
|
||||
ApplyVisuals(state == RackState.Empty ? -1 : currentStageIndex);
|
||||
}
|
||||
|
||||
private void StartGrowing()
|
||||
{
|
||||
RebuildDurationsCache();
|
||||
plantedAtTime = Time.time;
|
||||
state = RackState.Growing;
|
||||
currentStageIndex = 0;
|
||||
ApplyVisuals(currentStageIndex);
|
||||
}
|
||||
|
||||
private void ResetToEmpty()
|
||||
{
|
||||
state = RackState.Empty;
|
||||
currentStageIndex = -1;
|
||||
plantedAtTime = -1f;
|
||||
ApplyVisuals(-1);
|
||||
}
|
||||
|
||||
private void ApplyVisuals(int activeStageIndex)
|
||||
{
|
||||
if (growthStages == null) return;
|
||||
|
||||
for (int i = 0; i < growthStages.Count; i++)
|
||||
{
|
||||
var go = growthStages[i];
|
||||
if (go == null) continue;
|
||||
go.SetActive(i == activeStageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildDurationsCache()
|
||||
{
|
||||
int stageCount = growthStages == null ? 0 : growthStages.Count;
|
||||
cumulativeStageEnds = stageCount > 0 ? new float[stageCount] : Array.Empty<float>();
|
||||
|
||||
if (stageCount <= 0)
|
||||
{
|
||||
totalDurationCached = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
bool usePerStage = stageDurationsSeconds != null && stageDurationsSeconds.Count == stageCount;
|
||||
float running = 0f;
|
||||
for (int i = 0; i < stageCount; i++)
|
||||
{
|
||||
float dur = usePerStage ? Mathf.Max(0.01f, stageDurationsSeconds[i]) : Mathf.Max(0.01f, totalGrowSeconds / stageCount);
|
||||
running += dur;
|
||||
cumulativeStageEnds[i] = running;
|
||||
}
|
||||
|
||||
totalDurationCached = Mathf.Max(0.01f, running);
|
||||
}
|
||||
|
||||
private int GetStageIndexByElapsed(float elapsed)
|
||||
{
|
||||
if (cumulativeStageEnds == null || cumulativeStageEnds.Length == 0) return 0;
|
||||
|
||||
float t = Mathf.Clamp(elapsed, 0f, totalDurationCached);
|
||||
for (int i = 0; i < cumulativeStageEnds.Length; i++)
|
||||
{
|
||||
if (t < cumulativeStageEnds[i]) return i;
|
||||
}
|
||||
|
||||
return Mathf.Clamp(cumulativeStageEnds.Length - 1, 0, cumulativeStageEnds.Length - 1);
|
||||
}
|
||||
|
||||
private static int GetItemCount(InventorySystem inv, ItemData data)
|
||||
{
|
||||
if (inv == null || data == null || inv.inventory == null) return 0;
|
||||
|
||||
int count = 0;
|
||||
for (int i = 0; i < inv.inventory.Count; i++)
|
||||
{
|
||||
var slot = inv.inventory[i];
|
||||
if (slot == null) continue;
|
||||
if (slot.data != data) continue;
|
||||
count += Mathf.Max(0, slot.stackSize);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool CanAddAll(InventorySystem inv, List<ItemAmount> rewards)
|
||||
{
|
||||
if (inv == null || inv.inventory == null) return false;
|
||||
if (rewards == null || rewards.Count == 0) return true;
|
||||
|
||||
var slots = new List<SlotSim>(inv.inventory.Count);
|
||||
for (int i = 0; i < inv.inventory.Count; i++)
|
||||
{
|
||||
var s = inv.inventory[i];
|
||||
if (s == null || s.data == null || s.stackSize <= 0)
|
||||
{
|
||||
slots.Add(new SlotSim(null, 0));
|
||||
}
|
||||
else
|
||||
{
|
||||
slots.Add(new SlotSim(s.data, s.stackSize));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < rewards.Count; i++)
|
||||
{
|
||||
var reward = rewards[i];
|
||||
if (reward == null || reward.item == null) return false;
|
||||
if (!TrySimulateAdd(slots, reward.item, Mathf.Max(1, reward.amount))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly struct SlotSim
|
||||
{
|
||||
public readonly ItemData data;
|
||||
public readonly int stackSize;
|
||||
|
||||
public SlotSim(ItemData data, int stackSize)
|
||||
{
|
||||
this.data = data;
|
||||
this.stackSize = stackSize;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySimulateAdd(List<SlotSim> slots, ItemData data, int amount)
|
||||
{
|
||||
if (data == null) return false;
|
||||
if (amount <= 0) return true;
|
||||
if (slots == null) return false;
|
||||
|
||||
int remaining = amount;
|
||||
|
||||
if (data.isStackable)
|
||||
{
|
||||
int maxStack = Mathf.Max(1, data.maxStackSize);
|
||||
|
||||
for (int i = 0; i < slots.Count; i++)
|
||||
{
|
||||
var s = slots[i];
|
||||
if (s.data != data) continue;
|
||||
if (s.stackSize >= maxStack) continue;
|
||||
|
||||
int space = maxStack - s.stackSize;
|
||||
int take = Mathf.Min(remaining, space);
|
||||
remaining -= take;
|
||||
slots[i] = new SlotSim(s.data, s.stackSize + take);
|
||||
if (remaining <= 0) return true;
|
||||
}
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int emptyIndex = -1;
|
||||
for (int i = 0; i < slots.Count; i++)
|
||||
{
|
||||
if (slots[i].data != null) continue;
|
||||
emptyIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (emptyIndex < 0) return false;
|
||||
|
||||
int addCount = Mathf.Min(remaining, maxStack);
|
||||
slots[emptyIndex] = new SlotSim(data, addCount);
|
||||
remaining -= addCount;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
while (remaining > 0)
|
||||
{
|
||||
int emptyIndex = -1;
|
||||
for (int i = 0; i < slots.Count; i++)
|
||||
{
|
||||
if (slots[i].data != null) continue;
|
||||
emptyIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (emptyIndex < 0) return false;
|
||||
|
||||
slots[emptyIndex] = new SlotSim(data, 1);
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07cabcb00d8ce3345a68c31f3910b134
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class InteractCountThresholdSystem : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int threshold = 1;
|
||||
[SerializeField] private bool triggerOnlyOnce = true;
|
||||
[SerializeField] private bool resetCountOnTrigger = false;
|
||||
[SerializeField] private UnityEvent onThresholdReached;
|
||||
|
||||
[SerializeField] private int currentCount = 0;
|
||||
[SerializeField] private bool hasTriggered = false;
|
||||
|
||||
public int CurrentCount => currentCount;
|
||||
public int Threshold => threshold;
|
||||
public bool HasTriggered => hasTriggered;
|
||||
|
||||
public void Add(int amount)
|
||||
{
|
||||
if (amount <= 0) return;
|
||||
if (threshold <= 0) return;
|
||||
if (triggerOnlyOnce && hasTriggered) return;
|
||||
|
||||
var before = currentCount;
|
||||
currentCount += amount;
|
||||
|
||||
if (before < threshold && currentCount >= threshold)
|
||||
{
|
||||
hasTriggered = true;
|
||||
onThresholdReached?.Invoke();
|
||||
|
||||
if (resetCountOnTrigger)
|
||||
{
|
||||
currentCount = 0;
|
||||
hasTriggered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
currentCount = 0;
|
||||
hasTriggered = false;
|
||||
}
|
||||
|
||||
public void SetCount(int value)
|
||||
{
|
||||
currentCount = Mathf.Max(0, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 268bfb0de8e2d31448a0fd448ad511ae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,140 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class InteractOutlineTarget : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Renderer[] renderers;
|
||||
[SerializeField] private bool autoFindRenderers = true;
|
||||
[SerializeField] private float outlineWidth = 0.02f;
|
||||
[SerializeField] private float emission = 1f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLog;
|
||||
|
||||
private bool highlighted;
|
||||
private Material outlineMaterial;
|
||||
private MaterialPropertyBlock mpb;
|
||||
private Material[][] originalSharedMaterials;
|
||||
|
||||
private static readonly int OutlineColorId = Shader.PropertyToID("_OutlineColor");
|
||||
private static readonly int OutlineWidthId = Shader.PropertyToID("_OutlineWidth");
|
||||
private static readonly int EmissionId = Shader.PropertyToID("_Emission");
|
||||
|
||||
public void SetOutlineMaterial(Material material)
|
||||
{
|
||||
outlineMaterial = material;
|
||||
}
|
||||
|
||||
public void SetHighlighted(bool value, Color color)
|
||||
{
|
||||
if (outlineMaterial == null)
|
||||
{
|
||||
if (debugLog) Debug.Log($"[InteractOutlineTarget] Missing outlineMaterial on '{name}'", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderers == null || renderers.Length == 0)
|
||||
{
|
||||
if (autoFindRenderers)
|
||||
{
|
||||
renderers = GetComponentsInChildren<Renderer>(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (renderers == null || renderers.Length == 0) return;
|
||||
|
||||
if (mpb == null) mpb = new MaterialPropertyBlock();
|
||||
|
||||
if (value)
|
||||
{
|
||||
mpb.SetColor(OutlineColorId, color);
|
||||
mpb.SetFloat(OutlineWidthId, outlineWidth);
|
||||
mpb.SetFloat(EmissionId, emission);
|
||||
}
|
||||
|
||||
if (value && !highlighted)
|
||||
{
|
||||
CacheOriginalMaterialsIfNeeded();
|
||||
AppendOutlineMaterial();
|
||||
highlighted = true;
|
||||
}
|
||||
else if (!value && highlighted)
|
||||
{
|
||||
RestoreOriginalMaterials();
|
||||
highlighted = false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
var r = renderers[i];
|
||||
if (r == null) continue;
|
||||
r.SetPropertyBlock(value ? mpb : null);
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheOriginalMaterialsIfNeeded()
|
||||
{
|
||||
if (originalSharedMaterials != null && originalSharedMaterials.Length == (renderers == null ? 0 : renderers.Length))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderers == null)
|
||||
{
|
||||
originalSharedMaterials = null;
|
||||
return;
|
||||
}
|
||||
|
||||
originalSharedMaterials = new Material[renderers.Length][];
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
var r = renderers[i];
|
||||
originalSharedMaterials[i] = r != null ? r.sharedMaterials : null;
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendOutlineMaterial()
|
||||
{
|
||||
if (renderers == null) return;
|
||||
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
var r = renderers[i];
|
||||
if (r == null) continue;
|
||||
|
||||
var mats = r.sharedMaterials;
|
||||
if (mats == null)
|
||||
{
|
||||
r.sharedMaterials = new[] { outlineMaterial };
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = 0; j < mats.Length; j++)
|
||||
{
|
||||
if (mats[j] == outlineMaterial) goto NextRenderer;
|
||||
}
|
||||
|
||||
var newMats = new Material[mats.Length + 1];
|
||||
for (int j = 0; j < mats.Length; j++) newMats[j] = mats[j];
|
||||
newMats[mats.Length] = outlineMaterial;
|
||||
r.sharedMaterials = newMats;
|
||||
NextRenderer: ;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreOriginalMaterials()
|
||||
{
|
||||
if (renderers == null || originalSharedMaterials == null) return;
|
||||
|
||||
int count = Mathf.Min(renderers.Length, originalSharedMaterials.Length);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var r = renderers[i];
|
||||
if (r == null) continue;
|
||||
r.sharedMaterials = originalSharedMaterials[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2a707fc3e1b66341942f08bfd77e735
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,111 @@
|
||||
using Interaction.Conditions;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class InteractUnlockState : InteractConditionBehaviour
|
||||
{
|
||||
[SerializeField] private bool unlocked;
|
||||
|
||||
[Header("Interact Gate")]
|
||||
[SerializeField] private bool blockInteractionWhenLocked;
|
||||
[SerializeField] private string lockedFailReason = "尚未解锁";
|
||||
|
||||
[Header("State Visuals")]
|
||||
[SerializeField] private GameObject[] activateWhenUnlocked;
|
||||
[SerializeField] private GameObject[] deactivateWhenUnlocked;
|
||||
|
||||
[Header("State Events")]
|
||||
[SerializeField] private UnityEvent onUnlocked;
|
||||
[SerializeField] private UnityEvent onLocked;
|
||||
[SerializeField] private UnityEvent<bool> onValueChanged;
|
||||
|
||||
[Header("Behavior")]
|
||||
[SerializeField] private bool applyOnEnable = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLog;
|
||||
|
||||
public bool IsUnlocked => unlocked;
|
||||
public bool BlocksInteractionWhenLocked => blockInteractionWhenLocked;
|
||||
|
||||
public override bool CanInteract(GameObject interactor, out string failReason)
|
||||
{
|
||||
failReason = null;
|
||||
if (!blockInteractionWhenLocked) return true;
|
||||
if (unlocked) return true;
|
||||
|
||||
failReason = string.IsNullOrWhiteSpace(lockedFailReason) ? "尚未解锁" : lockedFailReason;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void OnInteractSucceeded(GameObject interactor)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetUnlocked(bool value)
|
||||
{
|
||||
if (unlocked == value)
|
||||
{
|
||||
Apply();
|
||||
return;
|
||||
}
|
||||
|
||||
unlocked = value;
|
||||
Apply();
|
||||
onValueChanged?.Invoke(unlocked);
|
||||
if (unlocked) onUnlocked?.Invoke();
|
||||
else onLocked?.Invoke();
|
||||
}
|
||||
|
||||
public void Unlock()
|
||||
{
|
||||
SetUnlocked(true);
|
||||
}
|
||||
|
||||
public void Lock()
|
||||
{
|
||||
SetUnlocked(false);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (applyOnEnable) Apply();
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
if (activateWhenUnlocked != null)
|
||||
{
|
||||
bool active = unlocked;
|
||||
for (int i = 0; i < activateWhenUnlocked.Length; i++)
|
||||
{
|
||||
var go = activateWhenUnlocked[i];
|
||||
if (go == null) continue;
|
||||
if (go.activeSelf != active) go.SetActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
if (deactivateWhenUnlocked != null)
|
||||
{
|
||||
bool active = !unlocked;
|
||||
for (int i = 0; i < deactivateWhenUnlocked.Length; i++)
|
||||
{
|
||||
var go = deactivateWhenUnlocked[i];
|
||||
if (go == null) continue;
|
||||
if (go.activeSelf != active) go.SetActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
if (debugLog)
|
||||
{
|
||||
Debug.Log(
|
||||
$"[InteractUnlockState] Apply on '{name}' unlocked={unlocked} " +
|
||||
$"activateCount={(activateWhenUnlocked == null ? 0 : activateWhenUnlocked.Length)} " +
|
||||
$"deactivateCount={(deactivateWhenUnlocked == null ? 0 : deactivateWhenUnlocked.Length)}",
|
||||
this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e541dc77d7d73fc47800047b8f039911
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Project.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// 用于控制互动物品的初始物理状态。
|
||||
/// 解决物品放置在只有一个碰撞体的架子上时被挤出的问题。
|
||||
/// </summary>
|
||||
public class ItemPhysicsState : MonoBehaviour
|
||||
{
|
||||
[Tooltip("如果开启,物体在场景初始化时会锁定物理(isKinematic=true),避免从架子上掉落。被玩家拾取后重新丢弃时,该状态会被自动解除。")]
|
||||
public bool startKinematic = false;
|
||||
|
||||
private Rigidbody rb;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
rb = GetComponent<Rigidbody>();
|
||||
if (rb == null)
|
||||
{
|
||||
Debug.LogWarning($"{nameof(ItemPhysicsState)} 需要 Rigidbody,但当前物体未找到(可能已在运行时被移除)。该组件将自动禁用。", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (startKinematic)
|
||||
{
|
||||
rb.isKinematic = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 供外部(如背包丢弃逻辑)调用,强制解除物理锁定,恢复受重力影响的正常物理状态
|
||||
/// </summary>
|
||||
public void ReleaseKinematic()
|
||||
{
|
||||
if (rb == null) rb = GetComponent<Rigidbody>();
|
||||
if (rb == null)
|
||||
{
|
||||
Debug.LogWarning($"{nameof(ItemPhysicsState)}.ReleaseKinematic 调用失败:未找到 Rigidbody(可能已在运行时被移除)。", this);
|
||||
return;
|
||||
}
|
||||
|
||||
rb.isKinematic = false;
|
||||
rb.useGravity = true;
|
||||
rb.detectCollisions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 562bc55d1ad9ac54b87e7e8824b82449
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using Inventory; // 引用 ItemData 所在的命名空间
|
||||
|
||||
namespace Project.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// 监听特定物品的“使用”(左键点击)操作,并在触发时执行相应的事件。
|
||||
/// 可以设置为仅触发一次。
|
||||
/// </summary>
|
||||
public class ItemUseEventTrigger : MonoBehaviour
|
||||
{
|
||||
[Tooltip("需要监听使用的目标物品")]
|
||||
public ItemData targetItem;
|
||||
|
||||
[Tooltip("是否只允许触发一次?")]
|
||||
public bool triggerOnlyOnce = true;
|
||||
|
||||
[Tooltip("当该物品被使用时触发的事件")]
|
||||
public UnityEvent onTargetItemUsed;
|
||||
|
||||
private bool hasTriggered = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 订阅全局的物品使用事件
|
||||
ItemUsageSystem.OnItemUsedGlobal += HandleItemUsed;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// 取消订阅
|
||||
ItemUsageSystem.OnItemUsedGlobal -= HandleItemUsed;
|
||||
}
|
||||
|
||||
private void HandleItemUsed(ItemData itemData)
|
||||
{
|
||||
if (targetItem == null) return;
|
||||
|
||||
// 检查是不是我们关心的那个物品
|
||||
if (itemData == targetItem)
|
||||
{
|
||||
if (triggerOnlyOnce && hasTriggered)
|
||||
{
|
||||
return; // 已经触发过了,且设置为只触发一次,直接返回
|
||||
}
|
||||
|
||||
hasTriggered = true;
|
||||
onTargetItemUsed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于在运行时手动重置触发状态
|
||||
/// </summary>
|
||||
public void ResetTrigger()
|
||||
{
|
||||
hasTriggered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e5edea2ad3873f4a854dd3e9102b01a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
using DG.Tweening;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class LeverController : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("组件设置")]
|
||||
[Tooltip("拉杆的旋转部分")]
|
||||
[SerializeField] private Transform leverTransform;
|
||||
|
||||
[Header("角度设置 (局部 Z轴)")]
|
||||
[SerializeField] private float onAngle = 40f;
|
||||
[SerializeField] private float offAngle = -40f;
|
||||
|
||||
[Header("动画设置")]
|
||||
[SerializeField] private float duration = 1.0f;
|
||||
[SerializeField] private Ease animationEase = Ease.OutQuad;
|
||||
|
||||
// 运行时状态
|
||||
private bool isOn = false;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (leverTransform == null)
|
||||
{
|
||||
Debug.LogError("[LeverController] 未赋值 Lever Transform!请在 Inspector 中设置。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 获取当前局部旋转 (Z轴)
|
||||
float currentZ = leverTransform.localEulerAngles.z;
|
||||
|
||||
// 2. 角度标准化 (-180 到 180)
|
||||
if (currentZ > 180) currentZ -= 360;
|
||||
|
||||
// 3. 初始状态判断 (谁近选谁)
|
||||
float distToOn = Mathf.Abs(Mathf.DeltaAngle(currentZ, onAngle));
|
||||
float distToOff = Mathf.Abs(Mathf.DeltaAngle(currentZ, offAngle));
|
||||
|
||||
isOn = distToOn < distToOff;
|
||||
|
||||
Debug.Log($"[Lever] 初始化完成 | 当前角度: {currentZ:F1} | 判定状态: {(isOn ? "开启 (On)" : "关闭 (Off)")}");
|
||||
}
|
||||
|
||||
public void Interact()
|
||||
{
|
||||
if (leverTransform == null) return;
|
||||
|
||||
Debug.Log($"<color=green>[Lever] 交互触发 | 切换前状态: {isOn}</color>");
|
||||
ToggleLever();
|
||||
}
|
||||
|
||||
public void ToggleLever()
|
||||
{
|
||||
isOn = !isOn;
|
||||
|
||||
// 目标角度
|
||||
float targetZ = isOn ? onAngle : offAngle;
|
||||
|
||||
// 构造目标旋转 (保持 X, Y 为 0,仅旋转 Z)
|
||||
// 注意:如果你希望保留当前的 X/Y 倾斜,请改用:
|
||||
// new Vector3(leverTransform.localEulerAngles.x, leverTransform.localEulerAngles.y, targetZ);
|
||||
Vector3 targetRotation = new Vector3(0, 0, targetZ);
|
||||
|
||||
// 动画执行
|
||||
leverTransform.DOKill(); // 停止旧动画
|
||||
leverTransform.DOLocalRotate(targetRotation, duration)
|
||||
.SetEase(animationEase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前交互提示文本
|
||||
/// </summary>
|
||||
public string GetInteractPrompt()
|
||||
{
|
||||
return isOn ? "关闭拉杆" : "开启拉杆";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 996c2e2624fef2942b08bb69094e630f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using DG.Tweening;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class ToggleMoveInteractable : MonoBehaviour, IInteractable
|
||||
{
|
||||
public enum ToggleState
|
||||
{
|
||||
Off = 0,
|
||||
On = 1
|
||||
}
|
||||
|
||||
public enum InitialStateMode
|
||||
{
|
||||
Auto = 0,
|
||||
ForceOff = 1,
|
||||
ForceOn = 2
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MoveTarget
|
||||
{
|
||||
public Transform target;
|
||||
|
||||
[Header("轴选择")]
|
||||
public bool useX = true;
|
||||
public bool useY = true;
|
||||
public bool useZ = true;
|
||||
|
||||
[Header("关闭状态 (Off)")]
|
||||
public bool captureInitialAsOff = true;
|
||||
public Vector3 offLocalPosition;
|
||||
|
||||
[Header("开启状态 (On)")]
|
||||
public bool useOffsetForOn = true;
|
||||
public Vector3 onOffset;
|
||||
public Vector3 onLocalPosition;
|
||||
|
||||
public Vector3 GetOffLocalPosition()
|
||||
{
|
||||
return offLocalPosition;
|
||||
}
|
||||
|
||||
public Vector3 GetOnLocalPosition()
|
||||
{
|
||||
return useOffsetForOn ? offLocalPosition + onOffset : onLocalPosition;
|
||||
}
|
||||
}
|
||||
|
||||
[Header("目标设置")]
|
||||
[SerializeField] private MoveTarget[] targets;
|
||||
|
||||
[Header("初始化")]
|
||||
[SerializeField] private InitialStateMode initialState = InitialStateMode.Auto;
|
||||
[SerializeField] private ToggleState state = ToggleState.Off;
|
||||
[SerializeField] private bool applyInitialStateOnStart = true;
|
||||
|
||||
[Header("动画设置")]
|
||||
[SerializeField] private float duration = 1.0f;
|
||||
[SerializeField] private Ease animationEase = Ease.OutQuad;
|
||||
|
||||
[Header("交互提示")]
|
||||
[SerializeField] private string promptWhenOff = "打开";
|
||||
[SerializeField] private string promptWhenOn = "关闭";
|
||||
|
||||
[Header("事件")]
|
||||
[SerializeField] private UnityEvent onTurnedOn;
|
||||
[SerializeField] private UnityEvent onTurnedOff;
|
||||
[SerializeField] private UnityEvent<bool> onStateChanged;
|
||||
|
||||
public bool IsOn => state == ToggleState.On;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (targets == null || targets.Length == 0)
|
||||
{
|
||||
Debug.LogWarning($"[{nameof(ToggleMoveInteractable)}] 未绑定任何目标对象。");
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureInitialOffIfNeeded();
|
||||
|
||||
switch (initialState)
|
||||
{
|
||||
case InitialStateMode.ForceOn:
|
||||
state = ToggleState.On;
|
||||
break;
|
||||
case InitialStateMode.ForceOff:
|
||||
state = ToggleState.Off;
|
||||
break;
|
||||
default:
|
||||
state = DetectState();
|
||||
break;
|
||||
}
|
||||
|
||||
if (applyInitialStateOnStart)
|
||||
{
|
||||
ApplyStateToTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Interact()
|
||||
{
|
||||
Toggle(true);
|
||||
}
|
||||
|
||||
public void Toggle(bool animate = true)
|
||||
{
|
||||
SetState(IsOn ? ToggleState.Off : ToggleState.On, animate);
|
||||
}
|
||||
|
||||
public void SetState(ToggleState newState, bool animate = true)
|
||||
{
|
||||
if (targets == null || targets.Length == 0) return;
|
||||
if (state == newState) return;
|
||||
|
||||
state = newState;
|
||||
ApplyStateToTargets(animate);
|
||||
|
||||
bool isOn = IsOn;
|
||||
onStateChanged?.Invoke(isOn);
|
||||
if (isOn) onTurnedOn?.Invoke();
|
||||
else onTurnedOff?.Invoke();
|
||||
}
|
||||
|
||||
public string GetInteractPrompt()
|
||||
{
|
||||
return IsOn ? promptWhenOn : promptWhenOff;
|
||||
}
|
||||
|
||||
private void CaptureInitialOffIfNeeded()
|
||||
{
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null || t.target == null) continue;
|
||||
if (!t.captureInitialAsOff) continue;
|
||||
t.offLocalPosition = t.target.localPosition;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStateToTargets(bool animate)
|
||||
{
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null || t.target == null) continue;
|
||||
|
||||
Vector3 configured = IsOn ? t.GetOnLocalPosition() : t.GetOffLocalPosition();
|
||||
Vector3 desired = BuildDesiredLocalPosition(t, configured);
|
||||
|
||||
t.target.DOKill();
|
||||
|
||||
if (!animate || duration <= 0f)
|
||||
{
|
||||
t.target.localPosition = desired;
|
||||
continue;
|
||||
}
|
||||
|
||||
t.target.DOLocalMove(desired, duration)
|
||||
.SetEase(animationEase);
|
||||
}
|
||||
}
|
||||
|
||||
private ToggleState DetectState()
|
||||
{
|
||||
float distOn = 0f;
|
||||
float distOff = 0f;
|
||||
bool hasAny = false;
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null || t.target == null) continue;
|
||||
hasAny = true;
|
||||
|
||||
Vector3 current = t.target.localPosition;
|
||||
Vector3 onPos = t.GetOnLocalPosition();
|
||||
Vector3 offPos = t.GetOffLocalPosition();
|
||||
|
||||
bool useX = t.useX;
|
||||
bool useY = t.useY;
|
||||
bool useZ = t.useZ;
|
||||
if (!useX && !useY && !useZ)
|
||||
{
|
||||
useX = true;
|
||||
useY = true;
|
||||
useZ = true;
|
||||
}
|
||||
|
||||
if (useX)
|
||||
{
|
||||
distOn += Mathf.Abs(current.x - onPos.x);
|
||||
distOff += Mathf.Abs(current.x - offPos.x);
|
||||
}
|
||||
|
||||
if (useY)
|
||||
{
|
||||
distOn += Mathf.Abs(current.y - onPos.y);
|
||||
distOff += Mathf.Abs(current.y - offPos.y);
|
||||
}
|
||||
|
||||
if (useZ)
|
||||
{
|
||||
distOn += Mathf.Abs(current.z - onPos.z);
|
||||
distOff += Mathf.Abs(current.z - offPos.z);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) return ToggleState.Off;
|
||||
return distOn < distOff ? ToggleState.On : ToggleState.Off;
|
||||
}
|
||||
|
||||
private Vector3 BuildDesiredLocalPosition(MoveTarget t, Vector3 configured)
|
||||
{
|
||||
Vector3 current = t.target.localPosition;
|
||||
|
||||
bool useX = t.useX;
|
||||
bool useY = t.useY;
|
||||
bool useZ = t.useZ;
|
||||
if (!useX && !useY && !useZ)
|
||||
{
|
||||
useX = true;
|
||||
useY = true;
|
||||
useZ = true;
|
||||
}
|
||||
|
||||
if (useX) current.x = configured.x;
|
||||
if (useY) current.y = configured.y;
|
||||
if (useZ) current.z = configured.z;
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 220522297997c3d4eb33ae9d57d91436
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using DG.Tweening;
|
||||
|
||||
namespace Interaction
|
||||
{
|
||||
public class ToggleRotateInteractable : MonoBehaviour, IInteractable
|
||||
{
|
||||
public enum ToggleState
|
||||
{
|
||||
Off = 0,
|
||||
On = 1
|
||||
}
|
||||
|
||||
public enum InitialStateMode
|
||||
{
|
||||
Auto = 0,
|
||||
ForceOff = 1,
|
||||
ForceOn = 2
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class RotationTarget
|
||||
{
|
||||
public Transform target;
|
||||
|
||||
[Header("轴选择")]
|
||||
public bool useX = true;
|
||||
public bool useY = true;
|
||||
public bool useZ = true;
|
||||
|
||||
[Header("关闭状态 (Off)")]
|
||||
public Vector3 offEuler;
|
||||
|
||||
[Header("开启状态 (On)")]
|
||||
public Vector3 onEuler;
|
||||
}
|
||||
|
||||
[Header("目标设置")]
|
||||
[SerializeField] private RotationTarget[] targets;
|
||||
|
||||
[Header("初始化")]
|
||||
[SerializeField] private InitialStateMode initialState = InitialStateMode.Auto;
|
||||
[SerializeField] private ToggleState state = ToggleState.Off;
|
||||
|
||||
[Header("动画设置")]
|
||||
[SerializeField] private float duration = 1.0f;
|
||||
[SerializeField] private Ease animationEase = Ease.OutQuad;
|
||||
[SerializeField] private RotateMode rotateMode = RotateMode.Fast;
|
||||
|
||||
[Header("交互提示")]
|
||||
[SerializeField] private string promptWhenOff = "打开";
|
||||
[SerializeField] private string promptWhenOn = "关闭";
|
||||
|
||||
[Header("事件")]
|
||||
[SerializeField] private UnityEvent onTurnedOn;
|
||||
[SerializeField] private UnityEvent onTurnedOff;
|
||||
[SerializeField] private UnityEvent<bool> onStateChanged;
|
||||
|
||||
public bool IsOn => state == ToggleState.On;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (targets == null || targets.Length == 0)
|
||||
{
|
||||
Debug.LogWarning($"[{nameof(ToggleRotateInteractable)}] 未绑定任何目标对象。");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (initialState)
|
||||
{
|
||||
case InitialStateMode.ForceOn:
|
||||
state = ToggleState.On;
|
||||
break;
|
||||
case InitialStateMode.ForceOff:
|
||||
state = ToggleState.Off;
|
||||
break;
|
||||
default:
|
||||
state = DetectState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Interact()
|
||||
{
|
||||
Toggle(true);
|
||||
}
|
||||
|
||||
public void Toggle(bool animate = true)
|
||||
{
|
||||
SetState(IsOn ? ToggleState.Off : ToggleState.On, animate);
|
||||
}
|
||||
|
||||
public void SetState(ToggleState newState, bool animate = true)
|
||||
{
|
||||
if (targets == null || targets.Length == 0) return;
|
||||
if (state == newState) return;
|
||||
|
||||
state = newState;
|
||||
ApplyStateToTargets(animate);
|
||||
|
||||
bool isOn = IsOn;
|
||||
onStateChanged?.Invoke(isOn);
|
||||
if (isOn) onTurnedOn?.Invoke();
|
||||
else onTurnedOff?.Invoke();
|
||||
}
|
||||
|
||||
public string GetInteractPrompt()
|
||||
{
|
||||
return IsOn ? promptWhenOn : promptWhenOff;
|
||||
}
|
||||
|
||||
private void ApplyStateToTargets(bool animate)
|
||||
{
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null || t.target == null) continue;
|
||||
|
||||
Vector3 desired = BuildDesiredEuler(t, IsOn ? t.onEuler : t.offEuler);
|
||||
|
||||
t.target.DOKill();
|
||||
|
||||
if (!animate || duration <= 0f)
|
||||
{
|
||||
t.target.localEulerAngles = desired;
|
||||
continue;
|
||||
}
|
||||
|
||||
t.target.DOLocalRotate(desired, duration, rotateMode)
|
||||
.SetEase(animationEase);
|
||||
}
|
||||
}
|
||||
|
||||
private ToggleState DetectState()
|
||||
{
|
||||
float distOn = 0f;
|
||||
float distOff = 0f;
|
||||
bool hasAny = false;
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null || t.target == null) continue;
|
||||
hasAny = true;
|
||||
|
||||
Vector3 current = NormalizeEulerSigned(t.target.localEulerAngles);
|
||||
|
||||
bool useX = t.useX;
|
||||
bool useY = t.useY;
|
||||
bool useZ = t.useZ;
|
||||
if (!useX && !useY && !useZ)
|
||||
{
|
||||
useX = true;
|
||||
useY = true;
|
||||
useZ = true;
|
||||
}
|
||||
|
||||
if (useX)
|
||||
{
|
||||
distOn += Mathf.Abs(Mathf.DeltaAngle(current.x, t.onEuler.x));
|
||||
distOff += Mathf.Abs(Mathf.DeltaAngle(current.x, t.offEuler.x));
|
||||
}
|
||||
|
||||
if (useY)
|
||||
{
|
||||
distOn += Mathf.Abs(Mathf.DeltaAngle(current.y, t.onEuler.y));
|
||||
distOff += Mathf.Abs(Mathf.DeltaAngle(current.y, t.offEuler.y));
|
||||
}
|
||||
|
||||
if (useZ)
|
||||
{
|
||||
distOn += Mathf.Abs(Mathf.DeltaAngle(current.z, t.onEuler.z));
|
||||
distOff += Mathf.Abs(Mathf.DeltaAngle(current.z, t.offEuler.z));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) return ToggleState.Off;
|
||||
return distOn < distOff ? ToggleState.On : ToggleState.Off;
|
||||
}
|
||||
|
||||
private Vector3 BuildDesiredEuler(RotationTarget t, Vector3 configured)
|
||||
{
|
||||
Vector3 current = NormalizeEulerSigned(t.target.localEulerAngles);
|
||||
|
||||
bool useX = t.useX;
|
||||
bool useY = t.useY;
|
||||
bool useZ = t.useZ;
|
||||
if (!useX && !useY && !useZ)
|
||||
{
|
||||
useX = true;
|
||||
useY = true;
|
||||
useZ = true;
|
||||
}
|
||||
|
||||
if (useX) current.x = configured.x;
|
||||
if (useY) current.y = configured.y;
|
||||
if (useZ) current.z = configured.z;
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static Vector3 NormalizeEulerSigned(Vector3 euler)
|
||||
{
|
||||
euler.x = NormalizeAngleSigned(euler.x);
|
||||
euler.y = NormalizeAngleSigned(euler.y);
|
||||
euler.z = NormalizeAngleSigned(euler.z);
|
||||
return euler;
|
||||
}
|
||||
|
||||
private static float NormalizeAngleSigned(float angle)
|
||||
{
|
||||
angle %= 360f;
|
||||
if (angle > 180f) angle -= 360f;
|
||||
if (angle < -180f) angle += 360f;
|
||||
return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a5cef82a86ccf74c9e547fa803b914c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user