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,254 @@
using UnityEngine;
using DG.Tweening;
using UnityEngine.SceneManagement;
namespace Player
{
public class CameraLookAtTarget : MonoBehaviour
{
public enum RotationSpace
{
Local = 0,
World = 1
}
[Header("目标")]
[Tooltip("需要被追踪/看向的目标物体。留空可使用“自动按 Tag 寻找”。")]
[SerializeField] private GameObject target;
[Tooltip("当 Target 为空时,自动在场景中按 Tag 寻找目标(适合预设体跨场景复用)。")]
[SerializeField] private bool autoFindTargetByTag = true;
[Tooltip("用于自动寻找的 Tag(例如 Player)。要求目标物体在场景里正确设置 Tag。")]
[SerializeField] private string targetTag = "Player";
[Tooltip("自动寻找的间隔秒数,避免每帧 Find。")]
[SerializeField] private float findInterval = 0.5f;
[Header("旋转对象")]
[Tooltip("实际需要旋转的子物体(例如相机 Pivot)。其 Left(-X) 方向将对准目标。不填则默认使用当前脚本所在物体。")]
[SerializeField] private Transform rotationTarget;
[Header("距离触发")]
[Tooltip("开启后:只有在 Max Distance 距离内才会朝向目标。")]
[SerializeField] private bool useDistance = true;
[Tooltip("距离阈值(单位:米)。")]
[SerializeField] private float maxDistance = 5f;
[Header("更新与空间")]
[Tooltip("旋转使用本地(Local)还是世界(World)空间。相机/子物体在父物体下面时通常用 Local。")]
[SerializeField] private RotationSpace rotationSpace = RotationSpace.Local;
[Tooltip("更新间隔秒数。0=每帧 LateUpdate;例如 0.05=每秒20次。")]
[SerializeField] private float updateInterval = 0f;
[Tooltip("世界 Up 方向,通常保持 Vector3.up。")]
[SerializeField] private Vector3 worldUp = Vector3.up;
[Header("平滑旋转 (DOTween)")]
[Tooltip("看向目标时的旋转过渡时间(秒)。0=瞬间转过去。")]
[SerializeField] private float tweenDuration = 0.15f;
[Tooltip("看向目标时的缓动曲线。")]
[SerializeField] private Ease tweenEase = Ease.OutSine;
[Tooltip("角度小于该值(度)则不触发新的 tween,减少抖动和频繁 Kill。")]
[SerializeField] private float minAngleToTween = 0.2f;
[Tooltip("是否使用不受 Time.timeScale 影响的时间(暂停/慢放时有用)。同时也会影响 Update Interval 与自动寻找的计时。")]
[SerializeField] private bool useUnscaledTime = false;
private Tween currentTween;
private float nextUpdateTime;
private float nextFindTime;
public GameObject Target
{
get => target;
set => target = value;
}
public Transform RotationTarget
{
get => rotationTarget;
set => rotationTarget = value;
}
private void Awake()
{
if (rotationTarget == null)
rotationTarget = transform;
}
private void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
TryAutoResolveTarget(force: true);
}
private void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
if (currentTween != null && currentTween.IsActive())
currentTween.Kill();
}
private void OnValidate()
{
if (maxDistance < 0f) maxDistance = 0f;
if (findInterval < 0f) findInterval = 0f;
if (updateInterval < 0f) updateInterval = 0f;
if (tweenDuration < 0f) tweenDuration = 0f;
if (minAngleToTween < 0f) minAngleToTween = 0f;
}
private void LateUpdate()
{
TryAutoResolveTarget(force: false);
if (updateInterval > 0f)
{
float now = useUnscaledTime ? Time.unscaledTime : Time.time;
if (now < nextUpdateTime) return;
nextUpdateTime = now + updateInterval;
}
bool inRange = target != null;
if (inRange && useDistance)
{
Vector3 origin = rotationTarget != null ? rotationTarget.position : transform.position;
float sqr = (target.transform.position - origin).sqrMagnitude;
float maxSqr = maxDistance * maxDistance;
inRange = sqr <= maxSqr;
}
if (inRange)
{
Quaternion desired = GetDesiredRotationToTarget(target.transform);
TweenToRotation(desired, tweenDuration, tweenEase);
}
}
private Quaternion GetDesiredRotationToTarget(Transform t)
{
Vector3 origin = rotationTarget != null ? rotationTarget.position : transform.position;
Vector3 dir = t.position - origin;
if (dir.sqrMagnitude < 0.000001f)
return rotationSpace == RotationSpace.Local ? rotationTarget.localRotation : rotationTarget.rotation;
Quaternion worldRot = GetWorldRotation(dir, worldUp);
if (rotationSpace == RotationSpace.World)
return worldRot;
if (rotationTarget.parent == null)
return worldRot;
return Quaternion.Inverse(rotationTarget.parent.rotation) * worldRot;
}
private void TryAutoResolveTarget(bool force)
{
if (!autoFindTargetByTag) return;
if (target != null)
{
if (!target.scene.IsValid() || !target.scene.isLoaded)
{
target = null;
}
else
{
return;
}
}
float now = Time.realtimeSinceStartup;
if (!force)
{
if (now < nextFindTime) return;
}
nextFindTime = now + findInterval;
target = ResolveByTag();
}
private GameObject ResolveByTag()
{
if (string.IsNullOrWhiteSpace(targetTag))
return null;
try
{
GameObject go = GameObject.FindGameObjectWithTag(targetTag);
if (go != null) return go;
}
catch
{
}
Transform[] all = FindObjectsOfType<Transform>(true);
for (int i = 0; i < all.Length; i++)
{
GameObject go = all[i].gameObject;
if (!go.scene.IsValid() || !go.scene.isLoaded) continue;
if (go.CompareTag(targetTag))
return go;
}
return null;
}
private Quaternion GetWorldRotation(Vector3 dir, Vector3 up)
{
Vector3 left = dir.normalized;
Vector3 right = -left;
Vector3 upN = up.sqrMagnitude < 0.000001f ? Vector3.up : up.normalized;
Vector3 forward = Vector3.Cross(upN, right);
if (forward.sqrMagnitude < 0.000001f)
{
upN = Vector3.Cross(right, Vector3.forward).sqrMagnitude > 0.000001f ? Vector3.forward : Vector3.up;
forward = Vector3.Cross(upN, right);
}
forward.Normalize();
Vector3 orthoUp = Vector3.Cross(right, forward);
return Quaternion.LookRotation(forward, orthoUp);
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (!autoFindTargetByTag) return;
target = null;
TryAutoResolveTarget(force: true);
}
private void TweenToRotation(Quaternion desired, float duration, Ease ease)
{
if (rotationTarget == null)
rotationTarget = transform;
Quaternion current = rotationSpace == RotationSpace.Local ? rotationTarget.localRotation : rotationTarget.rotation;
if (Quaternion.Angle(current, desired) < minAngleToTween)
return;
if (currentTween != null && currentTween.IsActive())
currentTween.Kill();
if (duration <= 0f)
{
SetRotation(desired);
return;
}
currentTween = rotationSpace == RotationSpace.Local
? rotationTarget.DOLocalRotateQuaternion(desired, duration)
: rotationTarget.DORotateQuaternion(desired, duration);
currentTween.SetEase(ease);
currentTween.SetUpdate(useUnscaledTime);
}
private void SetRotation(Quaternion rot)
{
if (rotationSpace == RotationSpace.Local)
rotationTarget.localRotation = rot;
else
rotationTarget.rotation = rot;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c064e0f1604dcd948b29cc0f7eb46327
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using UnityEngine;
using Inventory;
using DG.Tweening; // 引入 DOTween
using Project.Interaction;
public class HandController : MonoBehaviour
{
[Header("配置")]
[Tooltip("拖入摄像机下的 HandHolder 物体")]
public Transform handPosition;
[Tooltip("切换物品的动画时长")]
public float animationDuration = 0.25f;
[Tooltip("物品收起时的位移偏移量")]
public Vector3 hiddenOffset = new Vector3(0, -1.0f, 0);
private GameObject currentModel;
private Tween currentTween; // 记录当前的动画,防止冲突
// 供外部调用:切换物品
public void EquipItem(ItemData item)
{
// 如果正在播放动画,先杀掉
if (currentTween != null && currentTween.IsActive()) currentTween.Kill();
// 1. 如果手里有东西,先收起来
if (currentModel != null)
{
// 向下移动,并在完成后销毁
currentModel.transform.DOLocalMove(hiddenOffset, animationDuration)
.SetEase(Ease.InBack) // 回收时用 InBack 更有感觉
.OnComplete(() =>
{
Destroy(currentModel);
currentModel = null;
// 旧的收完了,再生成新的
SpawnNewItem(item);
});
}
else
{
// 手里本来就是空的,直接生成新的
SpawnNewItem(item);
}
}
private void SpawnNewItem(ItemData item)
{
if (item == null || item.handModelPrefab == null) return;
// 生成新模型
currentModel = Instantiate(item.handModelPrefab, handPosition);
// 自动清理物理组件(防止手持物品掉落或把玩家挤飞)
// 1. 关闭 Rigidbody 物理影响
Rigidbody rb = currentModel.GetComponent<Rigidbody>();
ItemPhysicsState physicsState = currentModel.GetComponent<ItemPhysicsState>();
if (rb != null)
{
if (physicsState != null)
{
Debug.LogWarning($"{nameof(HandController)} 检测到手持模型带有 {nameof(ItemPhysicsState)}(通常依赖 Rigidbody)。已保留 Rigidbody 并关闭其物理影响,避免运行时报错。", currentModel);
physicsState.enabled = false;
}
rb.isKinematic = true;
rb.useGravity = false;
rb.detectCollisions = false;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
// 2. 禁用所有 Collider
Collider[] cols = currentModel.GetComponentsInChildren<Collider>();
foreach (var c in cols) c.enabled = false;
// 3. (可选) 将层级设置为 "Player" 或 "Weapon" 防止穿模,如果需要的话
// SetLayerRecursively(currentModel, LayerMask.NameToLayer("FirstPerson"));
// 初始位置设在“下面”
Vector3 targetPos = Vector3.zero;
Quaternion targetRot = Quaternion.identity;
Vector3 targetScale = Vector3.one;
if (item.useCustomHoldSettings)
{
targetPos = item.holdPositionOffset;
targetRot = Quaternion.Euler(item.holdRotationOffset);
targetScale = item.holdScale;
}
currentModel.transform.localScale = targetScale;
currentModel.transform.localPosition = hiddenOffset; // 依然从隐藏位置开始
currentModel.transform.localRotation = targetRot;
// 2. 播放“掏出来”动画
currentTween = currentModel.transform.DOLocalMove(targetPos, animationDuration)
.SetEase(Ease.OutBack); // 掏出时用 OutBack 有一种弹出来的动感
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 41abc29bb7b76a944ab3a4b486140247
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,236 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Inventory;
using UI; // 引入 UI 命名空间以访问 TabletController
using Player;
using System;
public class ItemUsageSystem : MonoBehaviour
{
// 全局静态事件,当任何物品被成功使用时触发
public static event Action<ItemData> OnItemUsedGlobal;
[Header("References")]
public PlayerInput playerInput;
[Header("Vitals")]
public PlayerVitalsSystem vitalsSystem;
// 我们需要知道当前选中了哪个格子,所以引用 InventorySystem
// 但更好的方式是让 InventoryUI 或 InventorySystem 告诉我们当前选中了啥
// 这里我们直接访问单例,简单直接
private void Awake()
{
if (playerInput == null)
{
playerInput = GetComponent<PlayerInput>();
if (playerInput == null)
{
var pc = FindObjectOfType<Player.PlayerController>(true);
if (pc != null)
{
playerInput = pc.GetComponent<PlayerInput>();
}
}
}
}
private void OnEnable()
{
if (vitalsSystem == null)
{
vitalsSystem = PlayerVitalsSystem.Instance != null ? PlayerVitalsSystem.Instance : FindObjectOfType<PlayerVitalsSystem>();
}
if (playerInput != null)
{
if (playerInput.currentActionMap != null && playerInput.currentActionMap.name != "Player")
{
Debug.LogWarning($"[ItemUsageSystem] 当前 ActionMap 为 '{playerInput.currentActionMap.name}'Use 可能不会触发。");
}
// [Debug] 尝试绑定输入事件
// 根据报错,你的 Action 名字应该是 "Use" 而不是 "Fire"
var actionName = "Use";
// 安全检查:先确认 Action 是否存在
var useAction = playerInput.actions.FindAction(actionName);
if (useAction != null)
{
useAction.performed += OnUsePerformed;
Debug.Log($"[ItemUsageSystem] 成功绑定输入动作: {actionName}");
}
else
{
Debug.LogError($"[ItemUsageSystem] 错误:在 PlayerInput 中找不到名为 '{actionName}' 的动作!请检查 Input Actions 设置是否有名为 '{actionName}' 的动作。");
}
}
else
{
Debug.LogError("[ItemUsageSystem] 错误:PlayerInput 组件未赋值!");
}
}
private void OnDisable()
{
if (playerInput != null)
{
// 使用 FindAction 避免 KeyNotFoundException
var actionName = "Use";
var useAction = playerInput.actions.FindAction(actionName);
if (useAction != null)
{
useAction.performed -= OnUsePerformed;
}
}
}
private void OnUsePerformed(InputAction.CallbackContext context)
{
// [Debug] 接收到按键输入
Debug.Log("[ItemUsageSystem] 检测到使用键按下 (OnUsePerformed)");
if (InventorySystem.Instance == null)
{
Debug.LogError("[ItemUsageSystem] 错误:找不到 InventorySystem 实例!");
return;
}
// 获取当前选中的物品
int slotIndex = InventorySystem.Instance.selectedSlotIndex;
// 检查槽位是否越界
if (slotIndex < 0 || slotIndex >= InventorySystem.Instance.inventory.Count)
{
Debug.Log($"[ItemUsageSystem] 当前选中的槽位无效: {slotIndex}");
return;
}
var item = InventorySystem.Instance.inventory[slotIndex];
// 检查是否有物品
if (item != null && item.data != null)
{
UseItem(item.data, slotIndex);
}
else
{
Debug.Log($"[ItemUsageSystem] 当前槽位 {slotIndex} 是空的,无法使用物品。");
}
}
private void UseItem(ItemData data, int slotIndex)
{
Debug.Log($"[ItemUsage] 尝试使用物品: {data.displayName} ({data.itemType})");
switch (data.itemType)
{
case ItemType.Consumable:
HandleConsumable(data, slotIndex);
TryShowSpecialUseUI(data);
break;
case ItemType.Equipment:
HandleEquipment(data, slotIndex);
TryShowSpecialUseUI(data);
break;
case ItemType.Resource:
if (!TryShowSpecialUseUI(data))
{
Debug.Log("这是一个资源,不能直接使用。");
}
break;
case ItemType.Tool:
TryShowSpecialUseUI(data);
break;
}
// 触发全局物品使用事件
OnItemUsedGlobal?.Invoke(data);
}
private bool TryShowSpecialUseUI(ItemData data)
{
if (data == null || !data.showSpecialUseUI) return false;
var popup = SpecialUsePopupController.Instance != null
? SpecialUsePopupController.Instance
: FindObjectOfType<SpecialUsePopupController>(true);
if (popup == null) return false;
var sprite = data.specialUseSprite != null ? data.specialUseSprite : data.icon;
string[] pages = null;
if (data.useSpecialUseUIPages && data.specialUseUIPages != null && data.specialUseUIPages.Count > 0)
{
pages = new string[data.specialUseUIPages.Count];
for (int i = 0; i < data.specialUseUIPages.Count; i++)
{
pages[i] = data.specialUseUIPages[i] != null ? data.specialUseUIPages[i].text : string.Empty;
}
}
if (pages == null || pages.Length == 0)
{
var text = !string.IsNullOrWhiteSpace(data.specialUseUIText)
? data.specialUseUIText
: (!string.IsNullOrWhiteSpace(data.description) ? data.description : data.displayName);
pages = new[] { text };
}
popup.ShowPages(pages, sprite, 0);
return true;
}
private void HandleConsumable(ItemData data, int slotIndex)
{
// TODO: 播放吃/喝动画
// TODO: 增加属性 (Health/Hunger)
Debug.Log($"[ItemUsage] 消耗了 {data.displayName}");
if (vitalsSystem != null)
{
vitalsSystem.ApplyDelta(data.thirstDelta, data.hungerDelta, data.sanityDelta);
}
// 消耗逻辑:移除当前物品,添加结果物品(如果有)
InventorySystem.Instance.RemoveFromSlot(slotIndex, 1);
if (data.consumedResult != null)
{
InventorySystem.Instance.Add(data.consumedResult, 1);
}
}
private void HandleEquipment(ItemData data, int slotIndex)
{
// [Debug] 正在处理装备逻辑
Debug.Log($"[ItemUsageSystem] 处理装备: {data.displayName} (ID: {data.id})");
// 判断是否是平板 (建议同时检查 ID 和名称以防万一)
if (data.name.Contains("Tablet") || data.displayName.Contains("Tablet") || data.displayName.Contains("平板") || data.id == "tablet")
{
ToggleTabletUI();
}
else
{
Debug.LogWarning($"[ItemUsageSystem] 这是一个装备,但没有定义具体的使用逻辑: {data.displayName}");
}
}
private void ToggleTabletUI()
{
if (TabletController.Instance != null)
{
Debug.Log(">>> 正在打开 AI 平板界面 <<<");
TabletController.Instance.ShowTablet();
}
else
{
Debug.LogError("[ItemUsageSystem] 找不到 TabletController 实例!请确保场景中有 TabletCanvas 且挂载了该脚本。");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a85850980ccb6041b626fad489e8711
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,138 @@
using Interaction;
using Interaction.Conditions;
using UnityEngine;
namespace Player
{
public class PlayerAimOutlineHighlighter : MonoBehaviour
{
[Header("Raycast")]
[SerializeField] private Transform cameraTransform;
[SerializeField] private float interactRange = 3f;
[SerializeField] private LayerMask interactLayer;
[SerializeField] private float refreshInterval = 0.05f;
[Header("Outline")]
[SerializeField] private Material outlineMaterial;
[SerializeField] private Color canInteractColor = new Color(0.1f, 1f, 0.25f, 1f);
[SerializeField] private Color lockedColor = new Color(1f, 0.55f, 0.1f, 1f);
[SerializeField] private Color blockedColor = new Color(1f, 0.55f, 0.1f, 1f);
[Header("Behavior")]
[SerializeField] private bool useLockedColorWhenLocked = true;
[Header("Debug")]
[SerializeField] private bool debugLog;
private float nextTime;
private InteractOutlineTarget currentTarget;
private Color currentColor;
private void Awake()
{
if (cameraTransform == null)
{
cameraTransform = GetComponentInChildren<Camera>()?.transform;
}
}
private void Update()
{
if (cameraTransform == null) return;
if (outlineMaterial == null) return;
if (refreshInterval > 0f && Time.time < nextTime) return;
nextTime = refreshInterval > 0f ? Time.time + refreshInterval : Time.time;
Ray ray = new Ray(cameraTransform.position, cameraTransform.forward);
if (!Physics.Raycast(ray, out RaycastHit hit, interactRange, interactLayer))
{
Clear();
return;
}
var hitCollider = hit.collider;
if (hitCollider == null)
{
Clear();
return;
}
var interactable = hitCollider.GetComponentInParent<IInteractable>();
if (interactable == null)
{
Clear();
return;
}
var conditions = hitCollider.GetComponentsInParent<InteractConditionBehaviour>();
bool allowed = true;
string failReason = null;
for (int i = 0; i < conditions.Length; i++)
{
var condition = conditions[i];
if (condition == null) continue;
if (!condition.CanInteract(gameObject, out failReason))
{
allowed = false;
break;
}
}
bool locked = false;
if (!allowed && useLockedColorWhenLocked)
{
var unlockGate = hitCollider.GetComponentInParent<RequireUnlockedInteractCondition>();
var unlockState = hitCollider.GetComponentInParent<InteractUnlockState>();
if (unlockState != null && !unlockState.IsUnlocked && (unlockGate != null || unlockState.BlocksInteractionWhenLocked))
{
locked = true;
}
}
Color color = allowed ? canInteractColor : (locked ? lockedColor : blockedColor);
var target = hitCollider.GetComponentInParent<InteractOutlineTarget>();
if (target == null)
{
target = interactable is MonoBehaviour mb ? mb.GetComponentInParent<InteractOutlineTarget>() : null;
}
if (target == null)
{
Clear();
if (debugLog)
{
Debug.Log($"[PlayerAimOutlineHighlighter] Missing InteractOutlineTarget on '{hitCollider.name}'", hitCollider);
}
return;
}
if (currentTarget != target)
{
Clear();
currentTarget = target;
currentTarget.SetOutlineMaterial(outlineMaterial);
}
if (currentTarget != null && currentColor != color)
{
currentColor = color;
currentTarget.SetHighlighted(true, currentColor);
}
else if (currentTarget != null && currentColor == color)
{
currentTarget.SetHighlighted(true, currentColor);
}
}
private void Clear()
{
if (currentTarget != null)
{
currentTarget.SetHighlighted(false, currentColor);
currentTarget = null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7f98b17fdcf498f4389e595fd18e4d2e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,459 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using Interaction.Conditions;
using Michsky.UI.Reach;
namespace Player
{
[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float jumpHeight = 1.2f;
[SerializeField] private float gravity = -9.81f;
[Header("Look Settings")]
[SerializeField] private float mouseSensitivity = 1.2f;
[SerializeField] private Transform camPivot; // 摄像机支架(用于上下看)
[SerializeField] private float maxLookAngle = 80f; // 抬头低头限制
[Header("Interaction Settings")]
[SerializeField] private float interactRange = 3f;
[SerializeField] private LayerMask interactLayer;
[SerializeField] private Transform cameraTransform;
[Header("Interaction Prompt UI")]
[SerializeField] private bool enableAimPrompt = true;
[SerializeField] private FeedNotification canInteractNotification;
[SerializeField] private FeedNotification blockedNotification;
[SerializeField] private float aimPromptRefreshInterval = 0.05f;
[SerializeField] private bool blockedUseFailReason = true;
[Header("References")]
[SerializeField] private Transform bodyTransform;
private CharacterController characterController;
private PlayerInput playerInput; // 引用 PlayerInput
private Vector2 moveInput;
private Vector2 lookInput;
private float xRotation = 0f;
private Vector3 playerVelocity;
private bool isGrounded;
private System.Action<InputAction.CallbackContext> movePerformedHandler;
private System.Action<InputAction.CallbackContext> moveCanceledHandler;
private System.Action<InputAction.CallbackContext> lookPerformedHandler;
private System.Action<InputAction.CallbackContext> lookCanceledHandler;
private enum AimPromptState
{
None = 0,
Allowed = 1,
Blocked = 2
}
private AimPromptState aimPromptState;
private Collider lastAimCollider;
private string lastBlockedReason;
private float nextAimPromptTime;
private string canInteractOriginalText;
private string blockedOriginalText;
public void ResetInputState()
{
moveInput = Vector2.zero;
lookInput = Vector2.zero;
}
public void SetMouseSensitivity(float value)
{
float v = Mathf.Max(0f, value);
if (Mathf.Approximately(mouseSensitivity, v)) { return; }
mouseSensitivity = v;
Debug.Log($"[Player] MouseSensitivity set to {mouseSensitivity}");
}
private void Awake()
{
characterController = GetComponent<CharacterController>();
playerInput = GetComponent<PlayerInput>(); // 获取组件
if (cameraTransform == null)
cameraTransform = GetComponentInChildren<Camera>()?.transform;
if (camPivot == null && cameraTransform != null)
camPivot = cameraTransform.parent;
if (bodyTransform == null)
bodyTransform = transform.Find("Body");
// 锁定鼠标光标
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
if (canInteractNotification != null) canInteractOriginalText = canInteractNotification.notificationText;
if (blockedNotification != null) blockedOriginalText = blockedNotification.notificationText;
}
private void OnEnable()
{
// --- 显式订阅 Input System 事件 ---
// 这种方式比 SendMessage 更明确,不容易出错
if (playerInput != null)
{
// 确保你的 Action Map 名字正确 (默认通常是 "Player")
var moveAction = playerInput.actions["Move"];
if (moveAction != null)
{
if (movePerformedHandler == null) movePerformedHandler = ctx => moveInput = ctx.ReadValue<Vector2>();
if (moveCanceledHandler == null) moveCanceledHandler = _ => moveInput = Vector2.zero;
moveAction.performed += movePerformedHandler;
moveAction.canceled += moveCanceledHandler;
}
var lookAction = playerInput.actions["Look"];
if (lookAction != null)
{
if (lookPerformedHandler == null) lookPerformedHandler = ctx => lookInput = ctx.ReadValue<Vector2>();
if (lookCanceledHandler == null) lookCanceledHandler = _ => lookInput = Vector2.zero;
lookAction.performed += lookPerformedHandler;
lookAction.canceled += lookCanceledHandler;
}
var jumpAction = playerInput.actions["Jump"];
if (jumpAction != null)
{
jumpAction.performed += OnJump;
}
var interactAction = playerInput.actions["Interact"];
if (interactAction != null)
{
interactAction.performed += OnInteract;
Debug.Log("<color=green>[Player] Interact Action 绑定成功</color>");
}
else
{
Debug.LogError("<color=red>[Player] 找不到名为 'Interact' 的 Action!请检查 Input Actions 文件。</color>");
}
}
else
{
Debug.LogError("<color=red>[Player] PlayerInput 组件未找到!</color>");
}
}
private void OnDisable()
{
// 记得取消订阅,防止内存泄漏或报错
if (playerInput != null)
{
var moveAction = playerInput.actions["Move"];
if (moveAction != null)
{
if (movePerformedHandler != null) moveAction.performed -= movePerformedHandler;
if (moveCanceledHandler != null) moveAction.canceled -= moveCanceledHandler;
}
var lookAction = playerInput.actions["Look"];
if (lookAction != null)
{
if (lookPerformedHandler != null) lookAction.performed -= lookPerformedHandler;
if (lookCanceledHandler != null) lookAction.canceled -= lookCanceledHandler;
}
var jumpAction = playerInput.actions["Jump"];
if (jumpAction != null) jumpAction.performed -= OnJump;
var interactAction = playerInput.actions["Interact"];
if (interactAction != null) interactAction.performed -= OnInteract;
}
ResetInputState();
}
private void Update()
{
HandleMovement();
HandleLook();
UpdateAimPrompt();
}
private void UpdateAimPrompt()
{
if (!enableAimPrompt) return;
if (cameraTransform == null) return;
if (aimPromptRefreshInterval > 0f && Time.time < nextAimPromptTime) return;
nextAimPromptTime = aimPromptRefreshInterval > 0f ? Time.time + aimPromptRefreshInterval : Time.time;
Ray ray = new Ray(cameraTransform.position, cameraTransform.forward);
if (!Physics.Raycast(ray, out RaycastHit hit, interactRange, interactLayer))
{
SetAimPromptState(AimPromptState.None, null, null);
return;
}
var hitCollider = hit.collider;
bool hasInteractable = false;
var interactablesOnHit = hitCollider.GetComponents<IInteractable>();
if (interactablesOnHit != null && interactablesOnHit.Length > 0) hasInteractable = true;
else
{
var interactablesInParents = hitCollider.GetComponentsInParent<IInteractable>();
if (interactablesInParents != null && interactablesInParents.Length > 0) hasInteractable = true;
}
if (!hasInteractable)
{
SetAimPromptState(AimPromptState.None, hitCollider, null);
return;
}
bool allowed = true;
string failReason = null;
var conditions = hitCollider.GetComponentsInParent<InteractConditionBehaviour>();
for (int i = 0; i < conditions.Length; i++)
{
var condition = conditions[i];
if (condition == null) continue;
if (!condition.CanInteract(gameObject, out failReason))
{
allowed = false;
break;
}
}
SetAimPromptState(allowed ? AimPromptState.Allowed : AimPromptState.Blocked, hitCollider, failReason);
}
private void SetAimPromptState(AimPromptState state, Collider collider, string failReason)
{
bool colliderChanged = lastAimCollider != collider;
bool stateChanged = aimPromptState != state;
bool reasonChanged = (state == AimPromptState.Blocked) && lastBlockedReason != failReason;
lastAimCollider = collider;
lastBlockedReason = failReason;
if (!colliderChanged && !stateChanged && !reasonChanged) return;
aimPromptState = state;
if (state == AimPromptState.Allowed)
{
SetNotificationActiveFast(blockedNotification, false);
if (canInteractNotification != null)
{
canInteractNotification.UpdateUI();
SetNotificationActiveFast(canInteractNotification, true);
}
return;
}
if (state == AimPromptState.Blocked)
{
SetNotificationActiveFast(canInteractNotification, false);
if (blockedNotification != null)
{
if (blockedUseFailReason)
{
string text = string.IsNullOrWhiteSpace(failReason) ? blockedOriginalText : failReason;
if (!string.IsNullOrEmpty(text))
{
blockedNotification.notificationText = text;
blockedNotification.UpdateUI();
}
}
SetNotificationActiveFast(blockedNotification, true);
}
return;
}
if (canInteractNotification != null)
{
if (!string.IsNullOrEmpty(canInteractOriginalText))
{
canInteractNotification.notificationText = canInteractOriginalText;
canInteractNotification.UpdateUI();
}
SetNotificationActiveFast(canInteractNotification, false);
}
if (blockedNotification != null)
{
if (!string.IsNullOrEmpty(blockedOriginalText))
{
blockedNotification.notificationText = blockedOriginalText;
blockedNotification.UpdateUI();
}
SetNotificationActiveFast(blockedNotification, false);
}
}
private static void SetNotificationActiveFast(FeedNotification notification, bool active)
{
if (notification == null) return;
// 绕过 ReachUI 内部的协程动画,直接控制 GameObject 显隐
// 注意:因为我们不再使用 ExpandNotification/MinimizeNotification
// 需确保 Animator 已经被移除或禁用,以免残留的动画覆盖我们的显示状态。
notification.gameObject.SetActive(active);
}
private void HandleLook()
{
float mouseX = lookInput.x * mouseSensitivity * Time.deltaTime;
float mouseY = lookInput.y * mouseSensitivity * Time.deltaTime;
// 1. 左右旋转 (旋转整个角色身体)
transform.Rotate(Vector3.up * mouseX);
// 2. 上下旋转 (只旋转摄像机支架)
if (camPivot != null)
{
xRotation -= mouseY;
xRotation = Mathf.Clamp(xRotation, -maxLookAngle, maxLookAngle);
camPivot.localRotation = Quaternion.Euler(xRotation, 0f, 0f);
}
}
private void HandleMovement()
{
isGrounded = characterController.isGrounded;
if (isGrounded && playerVelocity.y < 0)
{
playerVelocity.y = -2f;
}
// --- 处理移动 ---
// 直接使用 transform.right/forward,因为身体已经跟随鼠标旋转了
Vector3 move = transform.right * moveInput.x + transform.forward * moveInput.y;
characterController.Move(move * moveSpeed * Time.deltaTime);
// --- 处理重力 ---
playerVelocity.y += gravity * Time.deltaTime;
characterController.Move(playerVelocity * Time.deltaTime);
}
// --- Input System Events ---
// 注意:现在不需要 public void OnMove(...) 这种格式了,因为我们已经在 OnEnable 里手动订阅了
public void OnJump(InputAction.CallbackContext context)
{
if (context.performed && isGrounded)
{
playerVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
}
public void OnInteract(InputAction.CallbackContext context)
{
if (context.performed)
{
Debug.Log($"<color=cyan>[Input] 按下了 Interact 键 (F)</color>");
PerformInteract();
}
}
private void PerformInteract()
{
if (cameraTransform == null)
{
Debug.LogError("<color=red>[Error] CameraTransform 为空!无法发射射线。</color>");
return;
}
Ray ray = new Ray(cameraTransform.position, cameraTransform.forward);
Debug.DrawRay(ray.origin, ray.direction * interactRange, Color.red, 2f); // 红色射线停留2秒
Debug.Log($"<color=yellow>[Raycast] 尝试发射射线... 起点:{ray.origin} 方向:{ray.direction}</color>");
if (Physics.Raycast(ray, out RaycastHit hit, interactRange, interactLayer))
{
Debug.Log($"<color=green>[Hit] 击中了物体: {hit.collider.name}</color> | Layer: {LayerMask.LayerToName(hit.collider.gameObject.layer)}");
var interactables = new List<IInteractable>(8);
var unique = new HashSet<IInteractable>();
var interactablesOnHit = hit.collider.GetComponents<IInteractable>();
for (int i = 0; i < interactablesOnHit.Length; i++)
{
var it = interactablesOnHit[i];
if (it == null) continue;
if (unique.Add(it)) interactables.Add(it);
}
var interactablesInParents = hit.collider.GetComponentsInParent<IInteractable>();
for (int i = 0; i < interactablesInParents.Length; i++)
{
var it = interactablesInParents[i];
if (it == null) continue;
if (unique.Add(it)) interactables.Add(it);
}
if (interactables.Count > 0)
{
var conditions = hit.collider.GetComponentsInParent<InteractConditionBehaviour>();
for (int i = 0; i < conditions.Length; i++)
{
var condition = conditions[i];
if (condition == null) continue;
if (!condition.CanInteract(gameObject, out string failReason))
{
var failedTriggers = hit.collider.GetComponentsInParent<Core.Triggers.InteractFailedTrigger>();
if (failedTriggers == null || failedTriggers.Length == 0)
{
Debug.LogWarning($"<color=orange>[Interact] 条件失败,但在 '{hit.collider.name}' 及其父级找不到 InteractFailedTrigger</color>");
}
for (int t = 0; t < failedTriggers.Length; t++)
{
if (failedTriggers[t] != null) failedTriggers[t].OnFailed(failReason);
}
if (!string.IsNullOrWhiteSpace(failReason))
{
Debug.LogWarning($"<color=orange>[Interact] 条件不满足:{failReason}</color>");
}
else
{
Debug.LogWarning("<color=orange>[Interact] 条件不满足</color>");
}
return;
}
}
Debug.Log($"<color=green>[Success] 找到 {interactables.Count} 个 IInteractable,准备依次调用 Interact()</color>");
for (int i = 0; i < interactables.Count; i++)
{
var interactable = interactables[i];
if (interactable == null) continue;
interactable.Interact();
}
for (int i = 0; i < conditions.Length; i++)
{
var condition = conditions[i];
if (condition == null) continue;
condition.OnInteractSucceeded(gameObject);
}
}
else
{
Debug.LogWarning($"<color=orange>[Fail] 击中了 {hit.collider.name},但找不到 IInteractable 脚本 (也没在父物体找到)</color>");
}
}
else
{
Debug.Log("<color=grey>[Miss] 射线没有击中任何 Interact Layer 的物体</color>");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2152a27f156c69a4899ee98caa3749fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using UnityEngine;
namespace Player
{
public class PlayerVitalsSystem : MonoBehaviour
{
public static PlayerVitalsSystem Instance { get; private set; }
[Header("Vitals")]
public VitalStat health = new VitalStat();
public VitalStat hunger = new VitalStat();
public VitalStat sanity = new VitalStat();
public VitalStat thirst => health;
[Header("Runtime")]
public bool runDecay = true;
void Reset()
{
if (string.IsNullOrWhiteSpace(health.id)) health.id = "health";
if (string.IsNullOrWhiteSpace(hunger.id)) hunger.id = "hunger";
if (string.IsNullOrWhiteSpace(sanity.id)) sanity.id = "sanity";
}
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
if (string.IsNullOrWhiteSpace(health.id)) health.id = "health";
if (string.IsNullOrWhiteSpace(hunger.id)) hunger.id = "hunger";
if (string.IsNullOrWhiteSpace(sanity.id)) sanity.id = "sanity";
health.Initialize(invokeEvents: true);
hunger.Initialize(invokeEvents: true);
sanity.Initialize(invokeEvents: true);
}
void Update()
{
if (!runDecay) return;
float dt = Time.deltaTime;
health.Tick(dt);
hunger.Tick(dt);
sanity.Tick(dt);
}
public void ApplyDelta(float thirstDelta, float hungerDelta, float sanityDelta)
{
if (thirstDelta != 0f) health.Add(thirstDelta);
if (hungerDelta != 0f) hunger.Add(hungerDelta);
if (sanityDelta != 0f) sanity.Add(sanityDelta);
}
public void AddHealth(float delta) => health.Add(delta);
public void AddHunger(float delta) => hunger.Add(delta);
public void AddSanity(float delta) => sanity.Add(delta);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f0bfaee2b32832d45823d99c870c07c4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+110
View File
@@ -0,0 +1,110 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
namespace Player
{
[System.Serializable]
public class VitalStat
{
[System.Serializable]
public class FloatEvent : UnityEvent<float> { }
[System.Serializable]
public class ThresholdRule
{
[Range(0f, 1f)] public float normalizedThreshold = 0.5f;
public UnityEvent onEnter = new UnityEvent();
public UnityEvent onExit = new UnityEvent();
[HideInInspector] public bool isBelow;
}
public string id;
public float minValue = 0f;
public float maxValue = 100f;
public float currentValue = 100f;
[FormerlySerializedAs("decayPerSecond")] public float decayPerMinute = 0f;
public FloatEvent onValueChanged = new FloatEvent();
public FloatEvent onNormalizedChanged = new FloatEvent();
public List<ThresholdRule> thresholds = new List<ThresholdRule>();
public float Normalized
{
get
{
if (Mathf.Approximately(minValue, maxValue)) return 0f;
return Mathf.InverseLerp(minValue, maxValue, currentValue);
}
}
public void Initialize(bool invokeEvents = true)
{
currentValue = Mathf.Clamp(currentValue, minValue, maxValue);
UpdateThresholdStates(invokeEvents: false);
if (invokeEvents) InvokeAll();
}
public void Tick(float deltaTime)
{
if (decayPerMinute == 0f) return;
Add(-(decayPerMinute / 60f) * deltaTime);
}
public void Add(float delta)
{
SetCurrent(currentValue + delta);
}
public void SetCurrent(float value)
{
float clamped = Mathf.Clamp(value, minValue, maxValue);
if (Mathf.Approximately(clamped, currentValue)) return;
currentValue = clamped;
InvokeAll();
UpdateThresholdStates(invokeEvents: true);
}
public void SetRange(float min, float max, bool preserveNormalized = true)
{
float normalized = Normalized;
minValue = min;
maxValue = max;
if (preserveNormalized)
{
currentValue = Mathf.Lerp(minValue, maxValue, normalized);
}
else
{
currentValue = Mathf.Clamp(currentValue, minValue, maxValue);
}
InvokeAll();
UpdateThresholdStates(invokeEvents: true);
}
void InvokeAll()
{
onValueChanged.Invoke(currentValue);
onNormalizedChanged.Invoke(Normalized);
}
void UpdateThresholdStates(bool invokeEvents)
{
float normalized = Normalized;
for (int i = 0; i < thresholds.Count; i++)
{
ThresholdRule rule = thresholds[i];
bool below = normalized <= rule.normalizedThreshold;
if (invokeEvents)
{
if (!rule.isBelow && below) rule.onEnter.Invoke();
else if (rule.isBelow && !below) rule.onExit.Invoke();
}
rule.isBelow = below;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b34826ed834397745b6e718657e3143e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: