Files
rolld/game/Assets/PlayerController.cs
kerboul 32becc12f9 feat: free-roam mode + fix multiplayer sync + remote player polish
Backend (ArenaRoom.js):
- Strip race state machine (lobby/countdown/playing/round/qualify). Persistent
  "playing" phase, no rounds, no checkpoints. Free-roam multi.
- Spawn lowered to y=1.5 (was 5) + MIN_DIST raised to 5 (was 3) to avoid
  ejecting overlapping players at connect.
- Schema kept intact (handshake-safe); deprecated fields default-valued.
- npm run schema:gen wired (anti-drift codegen).

Unity client:
- C# schema generated by schema-codegen into RolldSchema namespace
  (Generated/GameState.cs, Generated/Player.cs). NetworkSchema.cs removed —
  handshake no longer scans global namespace.
- NetworkManager: typed Room<GameState>, callbacks rebound, seeds players
  already in room on join.
- RemotePlayerController:
  * Post-spawn 1.5s grace window (BumpReady) — local PlayerController.HandleBump
    ignores remotes during grace.
  * Solid SphereCollider disabled during grace, re-enabled afterwards — fixes
    the kinematic-vs-dynamic eject when a new client spawns inside someone.
  * NPCBall prefab material switched from invisible-in-URP Default-Material to
    BallShader.shadergraph.
  * TrailRenderer added, tinted with player's chosen color.
  * Name label distance-scales (1x-8x) so pseudos remain readable far away.
- GameHUD: OnGUI emptied — race UI (rounds, mode, timer, playersAlive) gone.
- GameCanvas.jsx: BUILD_PREFIX/VERSION bumped for cache-bust.

Frontend WebGL build (pretty_build): final build with all the above.
2026-05-20 12:25:48 +02:00

697 lines
26 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
public class PlayerController : MonoBehaviour
{
// Reference to the Player Input component
private bool isJumpPressed = false;
private float jumpPressTime = 0f;
private bool _isLocalPlayer = false;
public float maxJumpHoldTime = 0.5f; // Ex. limite la puissance du saut
public float JumpForce = 5f; // Force applied when jumping
public float MovementSpeed = 25f; // Speed of player movement
public float BoostSpeed = 2f; // Multiplicateur de vitesse sur GelOrange
[Header("Steering Feel")]
[Tooltip("Damps velocity perpendicular to input — higher = sharper turns")]
public float turnDamping = 1.5f;
[Tooltip("Horizontal friction when no input is held")]
public float idleDrag = 0.2f;
[Header("Bump Collision")]
public float bumpForce = 4f; // Impulse force when bumping a remote player
public float bumpCooldown = 0.25f; // Minimum time between bumps from the same player
private Dictionary<int, float> _lastBumpTime = new Dictionary<int, float>();
// Ajout des états pour chaque direction
private bool isForwardHeld = false;
private bool isBackwardsHeld = false;
private bool isLeftHeld = false;
private bool isRightHeld = false;
private bool isOnGelOrange = false; // Indique si la boule est sur GelOrange
private bool isOnGelViolet = false; // Indique si la boule est sur GelViolet (sticky)
private Vector3 stickyNormal = Vector3.up; // Normale de la surface sticky en contact
public float StickyForce = 20f; // Force qui plaque la balle contre la surface GelViolet
private float originalDrag = 0f; // Sauvegarde du drag original du Rigidbody
[Header("Limits")]
public float maxVelocity = 120f; // Velocity cap to prevent infinite acceleration
public float respawnY = -10f; // Y threshold for respawn
private Vector3 _spawnPos = new Vector3(0f, 3f, -30f);
private Rigidbody _rb;
// Squash & stretch
private bool _isSquashing = false;
private Transform _meshTransform; // Reference to visual mesh for squash effect
// Fall warning
private static Texture2D _fallWarningTex;
private float _fallWarningAlpha = 0f;
public GameObject CameraReference; // Référence à la caméra (drag & drop dans l'inspecteur)
// --- Local player floating name label ---
private GameObject _nameLabelObj;
private TextMesh _nameLabel;
// --- Jump power (exposed for HUD) ---
public bool IsJumpCharging => isJumpPressed && IsGrounded();
public float JumpChargeNormalized => Mathf.Clamp01(jumpPressTime / maxJumpHoldTime);
// --- Shared font for TextMesh labels (WebGL-safe) ---
private static Font _labelFont;
public static Font LabelFont
{
get
{
if (_labelFont == null)
_labelFont = Resources.Load<Font>("LiberationSans");
return _labelFont;
}
}
void Start()
{
Debug.Log("PlayerController script initialized.");
_rb = GetComponent<Rigidbody>();
_meshTransform = transform;
}
public void SetSpawnPosition(Vector3 pos) => _spawnPos = pos;
/// <summary>
/// Called by LobbyUI after connecting. Sets up the local player
/// with a floating name label and a 50% color tint.
/// </summary>
public void SetupLocalPlayer(string playerName, Color playerColor)
{
_isLocalPlayer = true;
// --- Color tint (multiply blend to keep original pattern visible) ---
var ballRenderer = GetComponent<Renderer>();
if (ballRenderer != null)
{
var mat = new Material(ballRenderer.sharedMaterial);
Color original = Color.white;
if (mat.HasProperty("_BaseColor")) original = mat.GetColor("_BaseColor");
else if (mat.HasProperty("_Color")) original = mat.GetColor("_Color");
// Multiply tint: keeps the original pattern while colorizing
// Strength 0.7 = strong color, 0.3 original preserved
float strength = 0.7f;
Color tint = new Color(
Mathf.Lerp(original.r, original.r * playerColor.r * 2f, strength),
Mathf.Lerp(original.g, original.g * playerColor.g * 2f, strength),
Mathf.Lerp(original.b, original.b * playerColor.b * 2f, strength),
original.a
);
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", tint);
if (mat.HasProperty("_Color")) mat.color = tint;
ballRenderer.material = mat;
}
// --- Floating name label (parented to Player root, NOT the sphere) ---
if (_nameLabelObj != null) Destroy(_nameLabelObj);
_nameLabelObj = new GameObject("LocalNameLabel");
// Parent to the Player root (one level up) so ball rotation doesn't affect it
_nameLabelObj.transform.SetParent(transform.parent, false);
_nameLabelObj.transform.localScale = Vector3.one * 0.1f;
_nameLabel = _nameLabelObj.AddComponent<TextMesh>();
_nameLabel.text = playerName;
_nameLabel.fontSize = 144;
_nameLabel.characterSize = 0.15f;
_nameLabel.anchor = TextAnchor.MiddleCenter;
_nameLabel.alignment = TextAlignment.Center;
_nameLabel.color = playerColor;
if (LabelFont != null) _nameLabel.font = LabelFont;
var renderer = _nameLabel.GetComponent<MeshRenderer>();
if (LabelFont != null && LabelFont.material != null)
renderer.material = LabelFont.material;
else
{
var textShader = Shader.Find("GUI/Text Shader") ?? Shader.Find("Unlit/Texture");
if (textShader != null) renderer.material = new Material(textShader);
}
// --- Speed trail renderer ---
var trail = gameObject.GetComponent<TrailRenderer>();
if (trail == null) trail = gameObject.AddComponent<TrailRenderer>();
trail.time = 0.4f;
trail.startWidth = 0.3f;
trail.endWidth = 0.02f;
trail.material = new Material(Shader.Find("Sprites/Default"));
trail.startColor = new Color(1f, 0.7f, 0.2f, 0.7f);
trail.endColor = new Color(1f, 0.4f, 0.1f, 0f);
trail.minVertexDistance = 0.1f;
trail.autodestruct = false;
trail.emitting = true;
Debug.Log($"[Player] Local setup: {playerName}, tint={playerColor}");
}
void LateUpdate()
{
// Keep local name label floating above the ball and facing the camera
if (_nameLabelObj != null)
{
_nameLabelObj.transform.position = transform.position + Vector3.up * 1.5f;
var cam = Camera.main;
if (cam != null)
{
Vector3 lookDir = cam.transform.position - _nameLabelObj.transform.position;
lookDir.y = 0f;
if (lookDir.sqrMagnitude > 0.001f)
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
}
}
}
// Update is called once per frame
void Update()
{
// Cursor lock: right-click unlocks, left-click re-locks (disabled when any UI panel is open)
if (!ChatUI.IsVisible && !KeyBindingUI.IsVisible && Mouse.current != null)
{
if (Cursor.lockState == CursorLockMode.Locked && Mouse.current.rightButton.wasPressedThisFrame)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
else if (Cursor.lockState != CursorLockMode.Locked && Mouse.current.leftButton.wasPressedThisFrame)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
// Player update logic can be added here
if (isJumpPressed)
{
jumpPressTime += Time.deltaTime;
if (jumpPressTime > maxJumpHoldTime)
{
jumpPressTime = maxJumpHoldTime; // Clamp to max hold time
}
}
// --- Respawn if fallen off the map ---
if (transform.position.y < respawnY)
{
if (_rb != null)
{
_rb.linearVelocity = Vector3.zero;
_rb.angularVelocity = Vector3.zero;
_rb.useGravity = true;
}
transform.position = _spawnPos;
isOnGelViolet = false;
isOnGelOrange = false;
Debug.Log("[Player] Respawned after falling.");
return;
}
// --- Fall warning (tint screen red when low) ---
float fallTarget = (transform.position.y < -3f) ? Mathf.Clamp01((-3f - transform.position.y) / 7f) : 0f;
_fallWarningAlpha = Mathf.Lerp(_fallWarningAlpha, fallTarget, Time.deltaTime * 5f);
// Mouvement continu selon les directions maintenues
Rigidbody rb = _rb;
if (rb != null)
{
float currentSpeed = MovementSpeed;
if (isOnGelOrange)
{
currentSpeed *= BoostSpeed;
}
// Détermination des directions selon la caméra
Vector3 forward = Vector3.forward;
Vector3 right = Vector3.right;
if (CameraReference != null)
{
Vector3 camForward = CameraReference.transform.forward;
Vector3 camRight = CameraReference.transform.right;
if (isOnGelViolet)
{
// PROJECT onto sticky surface plane for surface-relative movement
forward = Vector3.ProjectOnPlane(camForward, stickyNormal).normalized;
right = Vector3.ProjectOnPlane(camRight, stickyNormal).normalized;
// Fallback if projection is degenerate
if (forward.sqrMagnitude < 0.01f)
forward = Vector3.ProjectOnPlane(Vector3.forward, stickyNormal).normalized;
if (right.sqrMagnitude < 0.01f)
right = Vector3.ProjectOnPlane(Vector3.right, stickyNormal).normalized;
}
else
{
// Normal mode: project onto horizontal plane
camForward.y = 0;
camForward.Normalize();
forward = camForward;
camRight.y = 0;
camRight.Normalize();
right = camRight;
}
}
Vector3 inputDir = Vector3.zero;
if (isForwardHeld) inputDir += forward;
if (isBackwardsHeld) inputDir -= forward;
if (isRightHeld) inputDir += right;
if (isLeftHeld) inputDir -= right;
if (inputDir.sqrMagnitude > 0.01f)
{
inputDir.Normalize();
rb.AddForce(inputDir * currentSpeed * Time.deltaTime, ForceMode.VelocityChange);
// Counter-force on the lateral component (makes turns sharper)
Vector3 horizVel = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
Vector3 perp = horizVel - Vector3.Project(horizVel, inputDir);
if (perp.sqrMagnitude > 0.01f)
rb.AddForce(-perp * turnDamping * Time.deltaTime, ForceMode.VelocityChange);
}
else if (!isOnGelViolet)
{
// Gradual horizontal slow-down when no key is held
Vector3 horizVel = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
rb.AddForce(-horizVel * idleDrag * Time.deltaTime, ForceMode.VelocityChange);
}
// GelViolet : colle la balle à la surface (sticky)
if (isOnGelViolet)
{
// Désactive la gravité Unity sur le Rigidbody
rb.useGravity = false;
// Applique une gravité inversée vers la surface
rb.AddForce(-stickyNormal * Physics.gravity.magnitude, ForceMode.Acceleration);
// Force de placage supplémentaire
rb.AddForce(-stickyNormal * StickyForce, ForceMode.Acceleration);
// Annule la vélocité qui s'éloigne de la surface (empêche le rebond)
float velocityAwayFromSurface = Vector3.Dot(rb.linearVelocity, stickyNormal);
if (velocityAwayFromSurface > 0)
{
rb.linearVelocity -= stickyNormal * velocityAwayFromSurface;
}
}
else
{
rb.useGravity = true;
}
// --- Velocity cap ---
if (rb.linearVelocity.magnitude > maxVelocity)
{
rb.linearVelocity = rb.linearVelocity.normalized * maxVelocity;
}
}
}
void OnCollisionStay(Collision collision)
{
Collider col = collision.collider;
if (col == null || col.sharedMaterial == null) return;
if (col.sharedMaterial.name.Contains("GelOrange"))
{
isOnGelOrange = true;
}
if (col.sharedMaterial.name.Contains("GelViolet"))
{
if (!isOnGelViolet)
{
originalDrag = _rb != null ? _rb.linearDamping : 0f;
if (_rb != null) _rb.linearDamping = 1f;
}
isOnGelViolet = true;
Vector3 avgNormal = Vector3.zero;
foreach (ContactPoint contact in collision.contacts)
avgNormal += contact.normal;
stickyNormal = avgNormal.normalized;
}
}
void OnCollisionExit(Collision collision)
{
Collider col = collision.collider;
if (col != null && col.sharedMaterial != null)
{
if (col.sharedMaterial.name.Contains("GelOrange"))
{
isOnGelOrange = false;
}
if (col.sharedMaterial.name.Contains("GelViolet"))
{
isOnGelViolet = false;
// Restaure le drag original et réactive la gravité
Rigidbody rb = GetComponent<Rigidbody>();
if (rb != null)
{
rb.useGravity = true;
rb.linearDamping = originalDrag;
}
}
}
}
public void OnJump(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isJumpPressed = false; jumpPressTime = 0f; return; }
if (context.started)
{
isJumpPressed = true;
jumpPressTime = 0f;
StatsTracker.Instance?.RegisterJump();
}
else if (context.canceled)
{
float jumpForceFactor = Mathf.Clamp01(jumpPressTime / maxJumpHoldTime);
if (IsGrounded())
PerformJump(jumpForceFactor * JumpForce);
isJumpPressed = false;
jumpPressTime = 0f;
}
}
public void PerformJump(float force)
{
if (_rb == null) return;
Vector3 jumpDir = isOnGelViolet ? stickyNormal : Vector3.up;
_rb.AddForce(jumpDir * force, ForceMode.Impulse);
}
private bool IsGrounded()
{
// On sticky surface: raycast toward the surface (opposite of normal)
if (isOnGelViolet)
return Physics.Raycast(transform.position, -stickyNormal, 1.1f);
// Normal: raycast downward
return Physics.Raycast(transform.position, Vector3.down, 1.1f);
}
public void OnForward(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isForwardHeld = false; return; }
if (context.started) isForwardHeld = true;
else if (context.canceled) isForwardHeld = false;
}
public void OnBackwards(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isBackwardsHeld = false; return; }
if (context.started) isBackwardsHeld = true;
else if (context.canceled) isBackwardsHeld = false;
}
public void OnLeft(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isLeftHeld = false; return; }
if (context.started) isLeftHeld = true;
else if (context.canceled) isLeftHeld = false;
}
public void OnRight(InputAction.CallbackContext context)
{
if (ChatUI.IsVisible) { isRightHeld = false; return; }
if (context.started) isRightHeld = true;
else if (context.canceled) isRightHeld = false;
}
// --- Bump collision with remote players ---
void OnTriggerEnter(Collider other)
{
HandleBump(other);
}
void OnTriggerStay(Collider other)
{
HandleBump(other);
}
private void HandleBump(Collider other)
{
var remote = other.GetComponent<RemotePlayerController>();
if (remote == null) return;
// Ignore bumps during the remote's post-spawn grace window — otherwise
// spawn-time overlap with the kinematic remote ball ejects us upward.
if (!remote.BumpReady) return;
int id = other.gameObject.GetInstanceID();
if (_lastBumpTime.TryGetValue(id, out float lastTime) && Time.time - lastTime < bumpCooldown)
return;
_lastBumpTime[id] = Time.time;
StatsTracker.Instance?.RegisterBump();
// Repulsion direction: from remote toward local player
Vector3 dir = (transform.position - other.transform.position).normalized;
// Add slight upward component so the ball lifts off the ground
dir = (dir + Vector3.up * 0.3f).normalized;
if (_rb != null)
_rb.AddForce(dir * bumpForce, ForceMode.Impulse);
}
void OnCollisionEnter(Collision collision)
{
if (!_isLocalPlayer) return;
// Squash & stretch on landing/impact
if (collision.relativeVelocity.magnitude > 2f && !_isSquashing)
{
StartCoroutine(SquashStretch());
}
}
private IEnumerator SquashStretch()
{
_isSquashing = true;
Vector3 original = Vector3.one;
Vector3 squash = new Vector3(1.2f, 0.7f, 1.2f);
float t = 0f;
float dur = 0.08f;
// Squash
while (t < dur)
{
t += Time.deltaTime;
transform.localScale = Vector3.Lerp(original, squash, t / dur);
yield return null;
}
// Stretch back
t = 0f;
dur = 0.12f;
while (t < dur)
{
t += Time.deltaTime;
transform.localScale = Vector3.Lerp(squash, original, t / dur);
yield return null;
}
transform.localScale = original;
_isSquashing = false;
}
public void ResetInputs()
{
isForwardHeld = false;
isBackwardsHeld = false;
isLeftHeld = false;
isRightHeld = false;
isJumpPressed = false;
jumpPressTime = 0f;
}
void OnDestroy()
{
// Clean up name label (it's not parented to the ball)
if (_nameLabelObj != null) Destroy(_nameLabelObj);
}
// ========================
// JUMP POWER GAUGE (HUD)
// ========================
private static Texture2D _gaugeBarTex;
private static Texture2D _gaugeBgTex;
private static Texture2D _gaugeGlowTex;
private float _gaugeDisplayAlpha = 0f; // smooth fade-in/out
private float _lastChargeValue = 0f; // for smooth lerp
void OnGUI()
{
if (!_isLocalPlayer) return;
// Target alpha: 1 when charging, 0 otherwise (keep visible briefly after release)
float targetAlpha = IsJumpCharging ? 1f : 0f;
_gaugeDisplayAlpha = Mathf.MoveTowards(_gaugeDisplayAlpha, targetAlpha, Time.deltaTime * 6f);
// Smooth the charge value for a fluid bar animation
float chargeTarget = IsJumpCharging ? JumpChargeNormalized : 0f;
_lastChargeValue = Mathf.Lerp(_lastChargeValue, chargeTarget, Time.deltaTime * 12f);
if (_gaugeDisplayAlpha < 0.01f) return;
// Gauge dimensions
float barWidth = 400f;
float barHeight = 22f;
float x = (Screen.width - barWidth) / 2f;
float y = Screen.height - 80f;
// Label
var labelStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 12,
fontStyle = FontStyle.Bold
};
labelStyle.normal.textColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.9f);
// Outline: draw 4× in black at ±1px, then once in white
var shadowStyle = new GUIStyle(labelStyle);
shadowStyle.normal.textColor = new Color(0f, 0f, 0f, _gaugeDisplayAlpha * 0.55f);
GUI.Label(new Rect(x + 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x + 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x, y - 26f, barWidth, 24f), "JUMP POWER", labelStyle);
// Ensure textures
if (_gaugeBgTex == null)
{
_gaugeBgTex = new Texture2D(1, 1);
_gaugeBgTex.SetPixel(0, 0, new Color(0.08f, 0.08f, 0.12f, 1f));
_gaugeBgTex.Apply();
}
if (_gaugeBarTex == null)
{
_gaugeBarTex = new Texture2D(1, 1);
_gaugeBarTex.SetPixel(0, 0, Color.white);
_gaugeBarTex.Apply();
}
if (_gaugeGlowTex == null)
{
_gaugeGlowTex = new Texture2D(1, 1);
_gaugeGlowTex.SetPixel(0, 0, Color.white);
_gaugeGlowTex.Apply();
}
Color prevBg = GUI.backgroundColor;
Color prevColor = GUI.color;
// Background (dark panel)
float pad = 4f;
Rect bgRect = new Rect(x - pad, y - pad, barWidth + pad * 2f, barHeight + pad * 2f);
GUI.color = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.85f);
GUI.DrawTexture(bgRect, _gaugeBgTex);
// Border
float b = 1f;
Color borderColor = new Color(0.35f, 0.35f, 0.45f, _gaugeDisplayAlpha * 0.7f);
GUI.color = borderColor;
GUI.DrawTexture(new Rect(bgRect.x, bgRect.y, bgRect.width, b), _gaugeBarTex); // top
GUI.DrawTexture(new Rect(bgRect.x, bgRect.yMax - b, bgRect.width, b), _gaugeBarTex); // bottom
GUI.DrawTexture(new Rect(bgRect.x, bgRect.y, b, bgRect.height), _gaugeBarTex); // left
GUI.DrawTexture(new Rect(bgRect.xMax - b, bgRect.y, b, bgRect.height), _gaugeBarTex); // right
// Filled bar with gradient color (blue -> cyan -> yellow -> red)
float fill = _lastChargeValue;
Color barColor;
if (fill < 0.5f)
barColor = Color.Lerp(new Color(0.2f, 0.6f, 1f), new Color(0.1f, 0.95f, 0.85f), fill * 2f);
else
barColor = Color.Lerp(new Color(0.1f, 0.95f, 0.85f), new Color(1f, 0.3f, 0.2f), (fill - 0.5f) * 2f);
Rect barRect = new Rect(x, y, barWidth * fill, barHeight);
GUI.color = new Color(barColor.r, barColor.g, barColor.b, _gaugeDisplayAlpha);
GUI.DrawTexture(barRect, _gaugeBarTex);
// Glow overlay on the filled portion (bright center highlight)
Color glowColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.2f * fill);
GUI.color = glowColor;
float glowH = barHeight * 0.4f;
GUI.DrawTexture(new Rect(x, y + (barHeight - glowH) * 0.3f, barWidth * fill, glowH), _gaugeGlowTex);
// Pulsing edge glow when near max
if (fill > 0.85f)
{
float pulse = 0.4f + 0.6f * Mathf.Abs(Mathf.Sin(Time.time * 6f));
GUI.color = new Color(1f, 0.3f, 0.15f, _gaugeDisplayAlpha * 0.35f * pulse);
GUI.DrawTexture(bgRect, _gaugeBarTex);
}
// Percentage text
var pctStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 11,
fontStyle = FontStyle.Bold
};
pctStyle.normal.textColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.95f);
GUI.Label(new Rect(x, y, barWidth, barHeight), Mathf.RoundToInt(fill * 100f) + "%", pctStyle);
GUI.color = prevColor;
GUI.backgroundColor = prevBg;
// ========================
// FALL WARNING (red tint)
// ========================
if (_fallWarningAlpha > 0.01f)
{
if (_fallWarningTex == null)
{
_fallWarningTex = new Texture2D(1, 1);
_fallWarningTex.SetPixel(0, 0, Color.white);
_fallWarningTex.Apply();
}
GUI.color = new Color(0.9f, 0.1f, 0.05f, _fallWarningAlpha * 0.35f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), _fallWarningTex);
// Warning text
var warnStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 24,
fontStyle = FontStyle.Bold
};
warnStyle.normal.textColor = new Color(1f, 0.3f, 0.2f, _fallWarningAlpha);
GUI.Label(new Rect(0, Screen.height * 0.35f, Screen.width, 40f), "\u26A0 DANGER - CHUTE !", warnStyle);
}
// ========================
// SPEED INDICATOR
// ========================
if (_rb != null)
{
float speed = _rb.linearVelocity.magnitude;
var speedStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleRight,
fontSize = 12,
fontStyle = FontStyle.Bold
};
float speedAlpha = Mathf.Clamp01(speed / 5f) * 0.8f;
Color speedCol = Color.Lerp(new Color(0.8f, 0.8f, 0.8f), new Color(1f, 0.5f, 0.1f), Mathf.Clamp01(speed / 30f));
speedStyle.normal.textColor = new Color(speedCol.r, speedCol.g, speedCol.b, Mathf.Max(0.3f, speedAlpha));
GUI.Label(new Rect(Screen.width - 160f, Screen.height - 50f, 140f, 24f),
$"{speed:F1} m/s", speedStyle);
}
GUI.color = Color.white;
}
}