-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathPlayerStamina.cs
More file actions
223 lines (189 loc) · 8.01 KB
/
PlayerStamina.cs
File metadata and controls
223 lines (189 loc) · 8.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
using System;
using UnityEngine;
using Optimization.Core;
using Ytax.Core;
using Ytax.Core.Events;
/// <summary>
/// Owns player stamina values and runtime drain/recharge logic.
/// - 10s full sprint budget from 100% to 0%
/// - 5s full recharge from 0% to 100%
///
/// <para><b>Architecture:</b> Legacy delegate <see cref="OnStaminaStateChanged"/>
/// is retained for backward compat. New subscribers should use
/// <see cref="GameEventBus"/>{<see cref="StaminaChangedEvent"/>}.</para>
///
/// PlayerMovement drives sprint intent via <see cref="SetSprinting(bool, bool)"/>.
/// UI reads <see cref="StaminaPercent"/> and <see cref="IsSprinting"/>.
/// </summary>
public class PlayerStamina : MonoBehaviour
{
// Percent (0..1) at or below which stamina is considered depleted.
private const float DepletedPercentThresholdConst = 0.04f;
[Header("Stamina Timing")]
[Tooltip("Seconds from full stamina to empty while sprinting continuously.")]
[SerializeField, Min(0.1f)] private float sprintDurationSeconds = 10f;
[Tooltip("Seconds from empty stamina to full while not sprinting.")]
[SerializeField, Min(0.1f)] private float rechargeDurationSeconds = 5f;
[Header("Runtime")]
[Tooltip("Current stamina value.")]
[SerializeField] private float currentStamina = 100f;
[SerializeField, Min(1f)] private float maxStamina = 100f;
private bool isSprinting;
// Whether we've registered OnTick with the centralized UpdateManager.
private bool registeredWithUpdateManager = false;
[Header("Behavior")]
[Tooltip("Cooldown (seconds) after stopping sprint before recharge begins.")]
[SerializeField, Min(0f)] private float rechargeCooldownAfterSprint = 0.5f;
// runtime
private float rechargeDelayTimer = 0f;
private bool runHeldIntent = false;
// When the player depletes stamina while holding run, block recharge until they release run.
private bool blockRechargeUntilRunReleased = false;
/// <summary>True while a recharge delay or cooldown is active.</summary>
public bool IsInRechargeDelay => rechargeDelayTimer > 0f;
/// <summary>Percent threshold used to decide when stamina is considered empty (0..1).</summary>
public float DepletedPercentThreshold => DepletedPercentThresholdConst;
/// <summary>True when stamina is low enough to be treated as depleted.</summary>
public bool IsDepleted => StaminaPercent <= DepletedPercentThresholdConst;
/// <summary>Raised when stamina percent or sprint state changes.</summary>
public event Action<float, bool> OnStaminaStateChanged;
/// <summary>Normalized stamina [0..1] for UI.</summary>
public float StaminaPercent => Mathf.Clamp01(currentStamina / maxStamina);
public float CurrentStamina => currentStamina;
public float MaxStamina => maxStamina;
/// <summary>True while movement is actively sprinting this frame.</summary>
public bool IsSprinting => isSprinting;
/// <summary>Movement checks this before allowing sprint speed.</summary>
public bool CanSprint => StaminaPercent > DepletedPercentThresholdConst;
private float DrainPerSecond => maxStamina / sprintDurationSeconds;
private float RechargePerSecond => maxStamina / rechargeDurationSeconds;
private void Awake()
{
currentStamina = Mathf.Clamp(currentStamina, 0f, maxStamina);
}
private void OnEnable()
{
OnStaminaStateChanged?.Invoke(StaminaPercent, isSprinting);
EnsureRegisteredWithUpdateManager();
}
private void Start()
{
// Backstop: Unity guarantees all Awake() calls complete before any Start().
// If OnEnable() fired before UpdateManager.Awake() set Instance (undefined
// cross-object Awake order), OnTick was never registered. This ensures it is.
EnsureRegisteredWithUpdateManager();
}
private void OnDisable()
{
if (registeredWithUpdateManager && UpdateManager.Instance != null)
{
UpdateManager.Instance.Unregister(this);
}
registeredWithUpdateManager = false;
}
private void OnTick(float dt)
{
ProfilerMarkers.PlayerMovement.Begin();
float beforePercent = StaminaPercent;
bool beforeSprint = isSprinting;
if (isSprinting)
{
currentStamina -= DrainPerSecond * dt;
currentStamina = Mathf.Max(0f, currentStamina);
if (StaminaPercent <= DepletedPercentThresholdConst)
{
currentStamina = 0f;
isSprinting = false;
if (runHeldIntent)
{
blockRechargeUntilRunReleased = true;
rechargeDelayTimer = 0f;
}
else
{
rechargeDelayTimer = rechargeCooldownAfterSprint;
blockRechargeUntilRunReleased = false;
}
}
}
else if (currentStamina < maxStamina)
{
if (runHeldIntent)
{
rechargeDelayTimer = 0f;
if (StaminaPercent <= DepletedPercentThresholdConst)
{
blockRechargeUntilRunReleased = true;
}
}
else
{
if (blockRechargeUntilRunReleased)
{
rechargeDelayTimer = rechargeCooldownAfterSprint;
blockRechargeUntilRunReleased = false;
}
if (rechargeDelayTimer > 0f)
{
rechargeDelayTimer = Mathf.Max(0f, rechargeDelayTimer - dt);
}
else
{
currentStamina += RechargePerSecond * dt;
currentStamina = Mathf.Min(currentStamina, maxStamina);
}
}
}
bool staminaChanged = !Mathf.Approximately(beforePercent, StaminaPercent);
bool sprintChanged = beforeSprint != isSprinting;
if (staminaChanged || sprintChanged)
{
OnStaminaStateChanged?.Invoke(StaminaPercent, isSprinting);
GameEventBus.Publish(new StaminaChangedEvent(StaminaPercent, isSprinting));
}
ProfilerMarkers.PlayerMovement.End();
}
/// <summary>
/// Called by PlayerMovement once per frame.
/// Sprinting can only remain true while stamina is available.
/// </summary>
public void SetSprinting(bool sprinting, bool runHeld)
{
// update run-held intent
bool wasHoldingRun = runHeldIntent;
runHeldIntent = runHeld;
// If the player starts holding run, clear any pending recharge delay and prevent recharge
if (runHeldIntent && !wasHoldingRun)
{
rechargeDelayTimer = 0f;
}
bool next = sprinting && CanSprint;
if (isSprinting == next) return;
isSprinting = next;
OnStaminaStateChanged?.Invoke(StaminaPercent, isSprinting);
GameEventBus.Publish(new StaminaChangedEvent(StaminaPercent, isSprinting));
}
private void Update()
{
// Fallback: if UpdateManager isn't present yet (or at all), tick via MonoBehaviour.Update.
if (!registeredWithUpdateManager)
{
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Register(this, UpdateManager.UpdateGroup.Normal, OnTick);
registeredWithUpdateManager = true;
return; // avoid double-tick this frame
}
OnTick(Time.deltaTime);
}
}
private void EnsureRegisteredWithUpdateManager()
{
if (registeredWithUpdateManager) return;
if (UpdateManager.Instance != null)
{
UpdateManager.Instance.Register(this, UpdateManager.UpdateGroup.Normal, OnTick);
registeredWithUpdateManager = true;
}
}
}