460 lines
18 KiB
C#
460 lines
18 KiB
C#
|
|
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>");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|