feat: Player

This commit is contained in:
2026-10-07 15:46:51 +02:00
parent 074eb51084
commit d21f0c9b6c
45 changed files with 1801 additions and 74 deletions

View File

@@ -0,0 +1,276 @@
using System;
using UnityEngine;
using UnityEngine.InputSystem;
using Arena.Combat;
namespace Arena.Characters
{
public class Player : MonoBehaviour, IDamageable
{
[System.Serializable]
public class Settings
{
[Tooltip("Movement speed, in km/h.")]
[Min(0f)] public float Speed = 5f;
}
[System.Serializable]
public struct References
{
public CharacterController Controller;
public ProjectileLauncher Launcher;
public InputActionReference MoveAction;
public InputActionReference LookAction;
public InputActionReference AttackAction;
public InputActionReference InteractAction;
}
[System.Serializable]
public class StateContainer
{
public bool IsMoving;
}
#region Fields
private const float KMH_TO_MS = 1f / 3.6f;
private const float AIM_STICK_DEADZONE = 0.2f;
[SerializeField] private Settings _settings;
[SerializeField] private References _references;
[SerializeField] private StateContainer _state;
[SerializeField] private Health _health;
/// <summary>
/// Raised when the interact action is performed.
/// </summary>
public event Action OnInteract;
/// <summary>
/// The only player of the scene.
/// </summary>
public static Player Instance { get; private set; }
public StateContainer State => _state;
public Health Health => _health;
public ProjectileLauncher Launcher => _references.Launcher;
private Camera _camera;
private Vector2 _moveInput;
private Vector2 _aimStickInput;
private bool _isAimingWithStick;
private bool _isAttacking;
private bool _isInteracting;
private Vector3 _aimDirection = Vector3.forward;
#endregion
#region Unity Lifecycle
void Awake()
{
// Only one player can exist: a second one is removed
if (Instance && Instance != this)
{
Debug.LogWarning($"A second Player was found on {name}, it is destroyed.");
Destroy(gameObject);
return;
}
Instance = this;
// Camera.main is the camera tagged MainCamera
_camera = Camera.main;
_health.Initialize();
}
void OnEnable()
{
SetInputsEnabled(true);
}
void OnDisable()
{
SetInputsEnabled(false);
}
void OnDestroy()
{
if (Instance == this)
Instance = null;
}
void Update()
{
// A dead player no longer moves nor shoots
if (_health.IsDead)
return;
float deltaTime = Time.deltaTime;
GetInputs();
Move(deltaTime);
Aim();
Attack();
Interact();
}
#endregion
#region Public Methods
public void TakeDamage(int amount)
{
_health.TakeDamage(amount);
}
/// <summary>
/// Teleports the player.
/// </summary>
/// <param name="pose">x, y, z: position. w: angle around the vertical axis, in degrees.</param>
public void Teleport(Vector4 pose)
{
// An enabled CharacterController would overwrite the new position
_references.Controller.enabled = false;
Vector3 position = new Vector3(pose.x, pose.y, pose.z);
Quaternion rotation = Quaternion.Euler(0f, pose.w, 0f);
transform.SetPositionAndRotation(position, rotation);
_references.Controller.enabled = true;
}
/// <summary>
/// Teleports the player to the position and heading of a target, such as a spawn point.
/// </summary>
public void Teleport(Transform target)
{
Vector3 position = target.position;
Vector4 pose = new Vector4(position.x, position.y, position.z, target.eulerAngles.y);
Teleport(pose);
}
#endregion
#region Private Methods
private void SetInputsEnabled(bool isEnabled)
{
InputActionReference[] actions =
{
_references.MoveAction,
_references.LookAction,
_references.AttackAction,
_references.InteractAction
};
foreach (InputActionReference action in actions)
{
if (isEnabled)
action.action.Enable();
else
action.action.Disable();
}
}
private void GetInputs()
{
_moveInput = _references.MoveAction.action.ReadValue<Vector2>();
_isAttacking = _references.AttackAction.action.IsPressed();
_isInteracting = _references.InteractAction.action.WasPerformedThisFrame();
InputAction look = _references.LookAction.action;
_aimStickInput = look.ReadValue<Vector2>();
// Look is also bound to the mouse delta: only the gamepad counts as a stick
bool isStick = look.activeControl != null && look.activeControl.device is Gamepad;
// The last aiming device used wins: the stick when pushed, the mouse when moved
if (isStick && IsStickPushed())
_isAimingWithStick = true;
else if (Pointer.current != null && Pointer.current.delta.ReadValue().sqrMagnitude > 0f)
_isAimingWithStick = false;
}
private void Move(float deltaTime)
{
Vector3 direction = ToWorldDirection(_moveInput);
_state.IsMoving = direction.sqrMagnitude > 0f;
Vector3 velocity = direction * _settings.Speed * KMH_TO_MS;
_references.Controller.Move(velocity * deltaTime);
}
private void Aim()
{
if (_isAimingWithStick)
{
// Released stick: the player keeps its last direction
if (IsStickPushed())
_aimDirection = ToWorldDirection(_aimStickInput);
}
else if (TryGetPointerDirection(out Vector3 direction))
{
_aimDirection = direction;
}
transform.rotation = Quaternion.LookRotation(_aimDirection);
}
private void Attack()
{
if (_isAttacking)
_references.Launcher.TryFire();
}
private void Interact()
{
if (_isInteracting)
OnInteract?.Invoke();
}
private bool IsStickPushed()
{
return _aimStickInput.sqrMagnitude > AIM_STICK_DEADZONE * AIM_STICK_DEADZONE;
}
/// <summary>
/// Converts a 2D input into a world direction on the ground, relative to the camera heading.
/// </summary>
private Vector3 ToWorldDirection(Vector2 input)
{
// Only the camera rotation around the vertical axis is kept
Quaternion cameraHeading = Quaternion.Euler(0f, _camera.transform.eulerAngles.y, 0f);
return cameraHeading * new Vector3(input.x, 0f, input.y);
}
/// <summary>
/// Direction from the player to the point under the pointer, on a horizontal plane at the player's height.
/// </summary>
private bool TryGetPointerDirection(out Vector3 direction)
{
direction = Vector3.zero;
if (Pointer.current == null)
return false;
// Mouse position read directly: the Look action only gives its movement
Ray ray = _camera.ScreenPointToRay(Pointer.current.position.ReadValue());
Plane ground = new Plane(Vector3.up, transform.position);
if (!ground.Raycast(ray, out float distance))
return false;
direction = ray.GetPoint(distance) - transform.position;
direction.y = 0f;
return direction.sqrMagnitude > 0.01f;
}
[ContextMenu("Debug/Take 25 Damage")]
private void DebugTakeDamage()
{
TakeDamage(25);
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 502392351c6ed7e4c9a3a895792f5b68

View File

@@ -0,0 +1,89 @@
using UnityEngine;
namespace Arena.Characters
{
[ExecuteAlways]
public class PlayerCamera : MonoBehaviour
{
[System.Serializable]
public class Settings
{
[Tooltip("x: horizontal distance from the player, y: height above the player, in meters.")]
public Vector2 Offset = new Vector2(8f, 12f);
[Tooltip("Rotation of the offset around the player, in degrees.")]
public float Angle = 0f;
[Tooltip("Time to catch up with the player, in seconds. 0: no smoothing.")]
[Range(0f, 1f)] public float Smooth = 0.2f;
}
[System.Serializable]
public struct References
{
[Tooltip("Followed player. Found automatically when empty.")]
public Player Player;
}
#region Fields
[SerializeField] private Settings _settings;
[SerializeField] private References _references;
private Vector3 _velocity;
#endregion
#region Unity Lifecycle
// LateUpdate: the player has already moved this frame
void LateUpdate()
{
if (!GetPlayer())
return;
Vector3 targetPos = GetTargetPosition();
// No smoothing in edit mode: the camera snaps while the scene is edited
if (!Application.isPlaying || _settings.Smooth <= 0f)
{
transform.position = targetPos;
_velocity = Vector3.zero;
}
else
{
transform.position = Vector3.SmoothDamp(transform.position, targetPos, ref _velocity, _settings.Smooth);
}
}
#endregion
#region Private Methods
/// <summary>
/// Looks for the player: singleton first, then a search in the scene. Returns false if none is found.
/// </summary>
private bool GetPlayer()
{
if (_references.Player)
return true;
// In edit mode, Player.Awake has not run, so the singleton is empty
else if (Player.Instance)
_references.Player = Player.Instance;
else
_references.Player = FindFirstObjectByType<Player>();
return _references.Player != null;
}
private Vector3 GetTargetPosition()
{
Quaternion rotation = Quaternion.Euler(0f, _settings.Angle, 0f);
Vector3 offset = rotation * new Vector3(0f, _settings.Offset.y, -_settings.Offset.x);
return _references.Player.transform.position + offset;
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,96 @@
using UnityEngine;
using UnityEngine.Events;
using Arena.Characters;
namespace Arena.Interactions
{
/// <summary>
/// Zone that raises OnPlayer when the player enters it, or when the player interacts inside it.
/// It never acts on the player itself: the actions are set up in the Inspector.
/// </summary>
[RequireComponent(typeof(Collider))]
public class PlayerEvent : MonoBehaviour
{
public enum Trigger
{
OnPlayerEnter,
OnPlayerInteract
}
[System.Serializable]
public class Settings
{
[Tooltip("OnPlayerEnter: as soon as the player enters. OnPlayerInteract: when the player interacts inside the zone.")]
public Trigger Trigger;
}
#region Fields
[SerializeField] private Settings _settings;
[Tooltip("Actions raised for the player: heal, ammo, sound, hide this object...")]
[SerializeField] private UnityEvent _onPlayer;
private bool _isListening;
#endregion
#region Unity Lifecycle
private void OnDisable()
{
// The object can be hidden while the player is inside
StopListening();
}
private void OnTriggerEnter(Collider other)
{
if (!IsPlayer(other))
return;
if (_settings.Trigger == Trigger.OnPlayerEnter)
_onPlayer.Invoke();
else
StartListening();
}
private void OnTriggerExit(Collider other)
{
if (IsPlayer(other))
StopListening();
}
#endregion
#region Private Methods
private bool IsPlayer(Collider other)
{
return Player.Instance != null && other.gameObject == Player.Instance.gameObject;
}
private void StartListening()
{
Player.Instance.OnInteract += HandleInteract;
_isListening = true;
}
private void StopListening()
{
if (!_isListening)
return;
if (Player.Instance != null)
Player.Instance.OnInteract -= HandleInteract;
_isListening = false;
}
private void HandleInteract()
{
_onPlayer.Invoke();
}
#endregion
}
}

View File

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