Initial Unity project commit

This commit is contained in:
2026-07-08 19:53:15 +08:00
commit 75f254212e
15657 changed files with 11633879 additions and 0 deletions
@@ -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)
{
}
}
}
@@ -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);
}
}
}
}
@@ -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: