[Add] DiceRoller — beautiful physics-based dice throw animation

Adds a coroutine-driven roller that tosses the dice with random impulse
and torque, waits for physics to settle, then smoothly snaps to the
nearest aligned face via Slerp. Exposes OnRollFinished event with the
rolled value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 15:46:38 +07:00
parent 688b623625
commit 55e4857af4
+157
View File
@@ -0,0 +1,157 @@
using System;
using System.Collections;
using UnityEngine;
using Random = UnityEngine.Random;
/// <summary>
/// Красиво подбрасывает и раскручивает кубик, ждёт пока он остановится,
/// плавно доворачивает до ровного положения и сообщает результат.
/// </summary>
public sealed class DiceRoller : MonoBehaviour
{
// ─────────────────────────── Inspector ───────────────────────────
[Header("References")]
[SerializeField] private Dice dice;
[SerializeField] private Rigidbody rb;
[Header("Throw Settings")]
[Tooltip("Сила подброса вверх")]
[SerializeField] private float throwUpForce = 6f;
[Tooltip("Горизонтальный разброс")]
[SerializeField] private float throwScatter = 1.5f;
[Tooltip("Минимальный крутящий момент")]
[SerializeField] private float torqueMin = 15f;
[Tooltip("Максимальный крутящий момент")]
[SerializeField] private float torqueMax = 30f;
[Header("Settle Detection")]
[Tooltip("Порог скорости, ниже которого кубик считается остановившимся")]
[SerializeField] private float settleSpeed = 0.05f;
[Tooltip("Сколько секунд кубик должен быть неподвижен, чтобы засчитать остановку")]
[SerializeField] private float settleDelay = 0.3f;
[Header("Snap Alignment")]
[Tooltip("Длительность плавного доворота к ровному положению")]
[SerializeField] private float snapDuration = 0.35f;
[SerializeField]
private AnimationCurve snapCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
// ─────────────────────────── Events ──────────────────────────────
/// <summary>Вызывается когда кубик полностью остановился. Аргумент — выпавшее значение.</summary>
public event Action<int> OnRollFinished;
// ─────────────────────────── State ───────────────────────────────
/// <summary>Идёт ли сейчас бросок.</summary>
public bool IsRolling { get; private set; }
private Coroutine rollRoutine;
// ─────────────────────────── Unity ───────────────────────────────
private void Reset()
{
dice = GetComponent<Dice>();
rb = GetComponent<Rigidbody>();
}
// ─────────────────────────── Public API ──────────────────────────
/// <summary>Бросить кубик. Повторный вызов во время броска игнорируется.</summary>
public void Roll()
{
if (IsRolling) return;
if (rollRoutine != null)
StopCoroutine(rollRoutine);
rollRoutine = StartCoroutine(RollSequence());
}
// ─────────────────────────── Core ────────────────────────────────
private IEnumerator RollSequence()
{
IsRolling = true;
// ── 1. Подготовка ────────────────────────────────────────────
rb.isKinematic = false;
rb.useGravity = true;
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
// ── 2. Импульс: подбросить вверх с лёгким разбросом ─────────
Vector3 force = new Vector3(
Random.Range(-throwScatter, throwScatter),
throwUpForce,
Random.Range(-throwScatter, throwScatter)
);
rb.AddForce(force, ForceMode.Impulse);
// ── 3. Случайный крутящий момент — красивое вращение ────────
Vector3 torque = Random.onUnitSphere * Random.Range(torqueMin, torqueMax);
rb.AddTorque(torque, ForceMode.Impulse);
// Даём кубику взлететь
yield return new WaitForSeconds(0.2f);
// ── 4. Ждём пока кубик успокоится ───────────────────────────
float stillTimer = 0f;
float sqrThreshold = settleSpeed * settleSpeed;
while (stillTimer < settleDelay)
{
yield return new WaitForFixedUpdate();
bool isSlow = rb.linearVelocity.sqrMagnitude < sqrThreshold
&& rb.angularVelocity.sqrMagnitude < sqrThreshold;
stillTimer = isSlow ? stillTimer + Time.fixedDeltaTime : 0f;
}
// ── 5. Замораживаем физику и читаем верхнюю грань ───────────
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
rb.isKinematic = true;
if (!dice.TryGetTopValue(out int topValue))
{
Debug.LogWarning("DiceRoller: не удалось определить верхнюю грань.");
IsRolling = false;
yield break;
}
// ── 6. Плавный доворот до ровного положения ─────────────────
Quaternion startRot = transform.rotation;
// Вычисляем целевой поворот через Dice.AlignToTopByLocalAngles
dice.AlignToTopByLocalAngles();
Quaternion targetRot = transform.rotation;
// Откатываемся обратно — будем интерполировать
transform.rotation = startRot;
float elapsed = 0f;
while (elapsed < snapDuration)
{
elapsed += Time.deltaTime;
float t = snapCurve.Evaluate(Mathf.Clamp01(elapsed / snapDuration));
transform.rotation = Quaternion.Slerp(startRot, targetRot, t);
yield return null;
}
transform.rotation = targetRot;
// ── 7. Готово ───────────────────────────────────────────────
IsRolling = false;
OnRollFinished?.Invoke(topValue);
Debug.Log($"🎲 Выпало: <b>{topValue}</b>");
}
}