feat: Player & Camera

This commit is contained in:
2026-10-08 15:12:19 +02:00
parent 074eb51084
commit a7e8ef1a1f
20 changed files with 576 additions and 70 deletions

View File

@@ -1,13 +0,0 @@
using UnityEngine;
[ExecuteAlways]
public class CameraFollower : MonoBehaviour
{
public GameObject Player;
void Update()
{
if (Player)
transform.position = Player.transform.position;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4ae4fbdc595247448b3d604825bd07fe

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 00d5f0f5167a1a243a9972a98593b122
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,140 @@
using Arena.Combat;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Arena.Characters
{
public class Player : MonoBehaviour, IDamageable
{
[System.Serializable]
private class Settings
{
[Tooltip("Movement speed, in km/h.")]
public float Speed = 10;
}
[System.Serializable]
private struct References
{
public CharacterController Controller;
public InputActionReference MoveAction;
}
[System.Serializable]
public class StateContainer
{
public bool IsMoving = false;
}
#region FIELDS
public static Player Instance { get; private set; }
const float KMH_TO_MS = 1 / 3.6f;
[SerializeField] private Settings _settings;
[SerializeField] private References _references;
[SerializeField] private StateContainer _state;
[SerializeField] private Health _health;
public StateContainer State => _state;
private Vector2 _moveInput;
private Camera _camera;
#endregion
#region UNITY LIFECYCLES
void Awake()
{
if (Instance && Instance != this)
{
Debug.LogWarning($"A second Player was found on {name}, it is destroyed.");
Destroy(gameObject);
return;
}
Instance = this;
_health.Initialize();
_camera = Camera.main;
}
void OnEnable()
{
_references.MoveAction.action.Enable();
}
void OnDisable()
{
if (Instance == this)
_references.MoveAction.action.Disable();
}
void OnDestroy()
{
if (Instance == this)
Instance = null;
}
void Update()
{
if (_health.IsDead)
return;
float t = Time.deltaTime;
GetInputs();
Move(t);
}
#endregion
#region Public Methods
public void TakeDamage(int amount)
{
_health.TakeDamage(amount);
}
#endregion
#region Private Methods
#if UNITY_EDITOR
[ContextMenu("Debug/Take 25 Damage")]
private void DebugTakeDamage()
{
TakeDamage(25);
}
#endif
private void GetInputs()
{
_moveInput = _references.MoveAction.action.ReadValue<Vector2>();
}
private void Move(float t)
{
Vector3 direction = ToWorldDirection(_moveInput);
Vector3 velocity = direction * KMH_TO_MS * _settings.Speed;
_state.IsMoving = velocity.sqrMagnitude > .01f;
_references.Controller.Move(velocity * t);
}
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);
}
#endregion
}
}

View File

@@ -0,0 +1,75 @@
using UnityEngine;
using UnityEngine.InputSystem;
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(7f, 8f);
[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]
private struct References
{
public Player Player;
}
#region FIELDS
[SerializeField] private Settings _settings;
[SerializeField] private References _references;
private Vector3 _velocity;
#endregion
void Update()
{
if (!GetPlayer())
return;
Vector3 targetPos = GetTargetPosition();
if (!Application.isPlaying || _settings.Smooth == 0)
{
transform.position = targetPos;
}
else
{
transform.position = Vector3.SmoothDamp(transform.position, targetPos, ref _velocity, _settings.Smooth);
}
}
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(0, _settings.Angle, 0);
Vector3 offset = rotation * new Vector3(0, _settings.Offset.y, -_settings.Offset.x);
return _references.Player.transform.position + offset;
}
}
}

View File

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

View File

@@ -0,0 +1,16 @@
using UnityEngine;
public class PlayerEvent : MonoBehaviour
{
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
}
// Update is called once per frame
void Update()
{
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d5ab3689e81574b4c8d1b37201093edf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,83 @@
using System;
using UnityEngine;
namespace Arena.Combat
{
/// <summary>
/// Hit points of an entity. Serialized inside the component that owns it (Player, Enemy),
/// which calls Initialize in its Awake.
/// </summary>
[System.Serializable]
public class Health
{
#region Fields
[Tooltip("Hit points when full.")]
[Min(1)] public int Max = 100;
[Tooltip("Current hit points, visible for debugging.")]
[SerializeField] private int _current;
/// <summary>
/// Raised with the current and max hit points each time they change.
/// </summary>
public event Action<int, int> OnChange;
/// <summary>
/// Raised once, when the hit points reach zero.
/// </summary>
public event Action OnDied;
public int Current => _current;
public bool IsDead => _current <= 0;
#endregion
#region Public Methods
/// <summary>
/// Fills the hit points. Called by the owner in its Awake.
/// </summary>
public void Initialize()
{
SetCurrent(Max);
}
/// <summary>
/// Removes hit points and raises OnDied the first time they reach zero.
/// </summary>
public void TakeDamage(int amount)
{
if (IsDead || amount <= 0)
return;
SetCurrent(_current - amount);
if (IsDead)
OnDied?.Invoke();
}
/// <summary>
/// Gives hit points back without exceeding Max. Has no effect once dead.
/// </summary>
public void Heal(int amount)
{
if (IsDead || amount <= 0)
return;
SetCurrent(_current + amount);
}
#endregion
#region Private Methods
private void SetCurrent(int value)
{
_current = Mathf.Clamp(value, 0, Max);
OnChange?.Invoke(_current, Max);
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 220d6096ae28c12449f72c38860cbc2e

View File

@@ -0,0 +1,10 @@
namespace Arena.Combat
{
/// <summary>
/// Anything that can receive damage: player, enemy, destructible object.
/// </summary>
public interface IDamageable
{
void TakeDamage(int amount);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14df4410feff3d044afb4ec552a18997

View File

@@ -1,53 +0,0 @@
using System.ComponentModel;
using UnityEngine;
public class PlayerMover : MonoBehaviour
{
[System.Serializable]
private class Settings
{
public float Speed = 5;
}
[System.Serializable]
private struct References
{
public CharacterController Controller;
}
[System.Serializable]
public class StateContainer
{
public bool IsJumping = false;
}
#region FIELDS
const float MS_TO_KMH = 1 / 3.6f;
[SerializeField] private Settings _settings;
[SerializeField] private References _references;
[SerializeField] private StateContainer _state;
public StateContainer State => _state;
#endregion
#region UNITY LIFECYCLES
void Update()
{
float t = Time.deltaTime;
Move(t);
}
private void Move(float t)
{
transform.position += Vector3.forward * t * MS_TO_KMH * _settings.Speed;
}
#endregion
}