MiniGames/Assets/Scripts/ClawGrab/ClawBubbleSpawner.cs

732 lines
25 KiB
C#
Raw Normal View History

using UnityEngine;
using System.Collections.Generic;
2025-09-06 00:08:22 +05:00
using DG.Tweening;
public class ClawBubbleSpawner : MonoBehaviour
{
public static ClawBubbleSpawner Instance;
2025-09-06 00:08:22 +05:00
public GameObject spawnPointLid;
public List<Material> bubbleTypeMaterials;
2025-09-22 17:26:19 +05:00
[Header("Single Prefab")]
[Tooltip("The ONLY bubble prefab (must contain ClawBubble + Rigidbody).")]
public GameObject bubblePrefab;
[Header("Spawn Point & Limits")]
[Tooltip("Where bubbles emerge (center opening).")]
public Transform spawnPoint;
[Tooltip("Seed bubbles at round start.")]
public int initialSeedCount = 6;
[Tooltip("Max number of bubbles alive at once.")]
public int maxConcurrentBubbles = 12;
[Tooltip("Spawn this many after every grab.")]
public int spawnPerGrab = 1;
[Tooltip("Extra spawns when a trap is grabbed (Bomb/Shock).")]
public int extraOnTrap = 2;
[Tooltip("Cooldown between spawns to avoid burst spam.")]
public float spawnCooldown = 0.15f;
[Header("Motion (Upward + Lateral)")]
public float riseImpulse = 8f;
[Header("Radial Ejection")]
[Tooltip("Use radial angles so balls peel away from the center without overlapping.")]
public bool useRadialEjection = true;
[Tooltip("Horizontal speed applied radially on spawn.")]
public float radialImpulse = 1.8f;
[Tooltip("Small offset from the exact center so spawns aren't coplanar.")]
public float spawnRadialOffset = 0.12f;
[Tooltip("Distribute subsequent spawns using the golden angle (~137.5<EFBFBD>).")]
public float goldenAngleDeg = 137.50776f;
[Tooltip("Minimum angular separation from existing balls (0 = off).")]
public float minAngleSeparationDeg = 18f;
[Tooltip("Tries to find a clear angle before giving up.")]
public int maxAngleTries = 16;
[Header("Initial Seed Layout")]
[Tooltip("Evenly space the first batch on a ring so they never overlap.")]
public bool seedOnEvenRing = true;
public float seedRingRadius = 0.5f;
public float seedAngleJitterDeg = 6f;
[Header("Fallback (Non-overlap by distance)")]
public float minSpawnSpacing = 0.3f;
public int maxPlacementTries = 10;
public LayerMask ballLayerMask = ~0;
[System.Serializable]
public struct WeightedType { public ClawBubbleType type; public float weight; }
public List<WeightedType> weights = new List<WeightedType>
{
new WeightedType{ type = ClawBubbleType.Wolf, weight = 50f },
new WeightedType{ type = ClawBubbleType.GoldenWolf, weight = 2f },
new WeightedType{ type = ClawBubbleType.Star, weight = 15f },
new WeightedType{ type = ClawBubbleType.Crystal, weight = 10f },
new WeightedType{ type = ClawBubbleType.Freeze, weight = 4f },
new WeightedType{ type = ClawBubbleType.Time, weight = 6f },
new WeightedType{ type = ClawBubbleType.Turbo, weight = 5f },
new WeightedType{ type = ClawBubbleType.Bomb, weight = 5f },
new WeightedType{ type = ClawBubbleType.Shock, weight = 3f },
};
2025-09-22 17:26:19 +05:00
// ----- Internal -----
private readonly HashSet<GameObject> live = new HashSet<GameObject>();
private float lastSpawnTime;
2025-09-22 17:26:19 +05:00
private float nextAngleDeg; // golden-angle progression
// Forced types queue (guaranteed next spawns)
private readonly Queue<ClawBubbleType> _forcedTypes = new Queue<ClawBubbleType>();
// Weight bias management
private List<WeightedType> _weightsOriginal;
// === Public helpers for mode managers ===
public void QueueType(ClawBubbleType t, int count = 1)
{
for (int i = 0; i < count; i++) _forcedTypes.Enqueue(t);
}
// Guarantees at least one of 't' exists; spawns immediately (ignores cooldown) if needed
public void EnsureTypePresent(ClawBubbleType t)
{
foreach (var g in live)
{
if (!g) continue;
var b = g.GetComponent<ClawBubble>();
if (b && b.type == t) return; // already present
}
_forcedTypes.Enqueue(t);
TrySpawn(1, ignoreCooldown: true);
}
/// <summary>
/// Temporarily biases spawn odds. Example: (3f, 0.6f) = triple target, 60% others.
/// </summary>
public void ApplyTargetBias(ClawBubbleType target, float targetMult = 3f, float othersMult = 0.6f)
{
if (_weightsOriginal == null || _weightsOriginal.Count == 0) return;
if (weights == null) return;
for (int i = 0; i < weights.Count; i++)
{
var original = _weightsOriginal.Find(w => w.type == weights[i].type);
var w = weights[i];
w.weight = (w.type == target)
? Mathf.Max(0f, original.weight * targetMult)
: Mathf.Max(0f, original.weight * othersMult);
weights[i] = w;
}
}
/// <summary>Restore original authoring weights.</summary>
public void ClearWeightBias()
{
if (_weightsOriginal == null) return;
weights.Clear();
foreach (var w in _weightsOriginal) weights.Add(w);
}
void Awake()
{
if (Instance == null) Instance = this;
2025-09-22 17:26:19 +05:00
else { Destroy(gameObject); return; }
nextAngleDeg = Random.Range(0f, 360f);
2025-09-22 17:26:19 +05:00
// Deep copy original weights for later restore
_weightsOriginal = new List<WeightedType>(weights.Count);
foreach (var w in weights)
_weightsOriginal.Add(new WeightedType { type = w.type, weight = w.weight });
}
void OnEnable() => StartRound();
[ContextMenu("StartRound")]
public void StartRound()
{
live.RemoveWhere(g => g == null);
StopAllCoroutines();
StartCoroutine(SeedInitial());
}
public void EndRound()
{
StopAllCoroutines();
foreach (var g in live) if (g) Destroy(g);
live.Clear();
}
System.Collections.IEnumerator SeedInitial()
{
if (seedOnEvenRing && initialSeedCount > 1 && spawnPoint)
{
float step = 360f / initialSeedCount;
for (int i = 0; i < initialSeedCount; i++)
{
float ang = i * step + Random.Range(-seedAngleJitterDeg, seedAngleJitterDeg);
SpawnRadial(ang, useRing: true);
lastSpawnTime = Time.time;
yield return new WaitForSeconds(spawnCooldown);
}
}
else
{
for (int i = 0; i < initialSeedCount; i++)
{
TrySpawn(1);
yield return new WaitForSeconds(spawnCooldown);
}
}
}
public void NotifyBubbleGrabbed(ClawBubbleType grabbedType)
{
TrySpawn(spawnPerGrab);
if (grabbedType == ClawBubbleType.Bomb || grabbedType == ClawBubbleType.Shock)
TrySpawn(extraOnTrap);
}
2025-09-22 17:26:19 +05:00
public void TrySpawn(int count, bool ignoreCooldown = false)
{
if (!bubblePrefab || !spawnPoint) return;
live.RemoveWhere(g => g == null);
for (int i = 0; i < count; i++)
{
if (live.Count >= maxConcurrentBubbles) break;
2025-09-22 17:26:19 +05:00
if (!ignoreCooldown && Time.time - lastSpawnTime < spawnCooldown) break;
SpawnOne();
lastSpawnTime = Time.time;
}
}
void SpawnOne()
{
if (useRadialEjection)
{
float chosen = PickClearAngle(nextAngleDeg, minAngleSeparationDeg, maxAngleTries);
SpawnRadial(chosen, useRing: false);
nextAngleDeg = Mathf.Repeat(chosen + goldenAngleDeg, 360f);
}
else
{
if (!TryFindSpawnPos(out var pos))
pos = spawnPoint.position;
2025-09-22 17:26:19 +05:00
SpawnOneAt(pos, lateralDir: Vector3.zero);
}
}
void SpawnRadial(float angleDeg, bool useRing)
{
float rad = angleDeg * Mathf.Deg2Rad;
Vector3 localDir = new Vector3(Mathf.Cos(rad), 0f, Mathf.Sin(rad)).normalized;
float radius = useRing ? seedRingRadius : spawnRadialOffset;
Vector3 pos = spawnPoint.TransformPoint(localDir * radius);
SpawnOneAt(pos, spawnPoint.TransformDirection(localDir));
}
void SpawnOneAt(Vector3 pos, Vector3 lateralDir)
{
var t = PickWeightedType();
2025-09-22 17:26:19 +05:00
if (spawnPointLid)
spawnPointLid.transform.DOLocalMoveY(-0.5f, 0.3f).OnComplete(() =>
{
DoInstantiate(pos, lateralDir, t);
spawnPointLid.transform.DOLocalMoveY(0, 0.3f).SetEase(Ease.OutBack).SetDelay(0.5f);
});
else
2025-09-06 00:08:22 +05:00
{
2025-09-22 17:26:19 +05:00
DoInstantiate(pos, lateralDir, t);
}
}
void DoInstantiate(Vector3 pos, Vector3 lateralDir, ClawBubbleType t)
{
var go = Instantiate(bubblePrefab, pos, Quaternion.identity);
live.Add(go);
2025-09-22 17:26:19 +05:00
go.AddComponent<_SpawnRegistration>().Init(this);
2025-09-22 17:26:19 +05:00
var cb = go.GetComponent<ClawBubble>();
if (cb != null)
{
cb.type = t;
if (cb.bottomMeshRenderer && bubbleTypeMaterials != null)
2025-09-06 00:08:22 +05:00
{
2025-09-22 17:26:19 +05:00
int idx = (int)t;
if (idx >= 0 && idx < bubbleTypeMaterials.Count && bubbleTypeMaterials[idx] != null)
cb.bottomMeshRenderer.material = bubbleTypeMaterials[idx];
2025-09-06 00:08:22 +05:00
}
2025-09-22 17:26:19 +05:00
}
var rb = go.GetComponent<Rigidbody>();
if (rb != null)
{
Vector3 lateral = lateralDir != Vector3.zero
? lateralDir.normalized * radialImpulse
: new Vector3(Random.Range(-0.5f, 0.5f), 0f, Random.Range(-0.5f, 0.5f)).normalized * radialImpulse;
2025-09-22 17:26:19 +05:00
Vector3 impulse = Vector3.up * riseImpulse + lateral;
rb.AddForce(impulse, ForceMode.VelocityChange);
rb.detectCollisions = true;
}
}
// ----- Angle picking / separation helpers -----
float PickClearAngle(float startDeg, float minSepDeg, int tries)
{
if (minSepDeg <= 0f || live.Count == 0) return startDeg;
float a = startDeg;
for (int i = 0; i < tries; i++)
{
if (IsAngleClear(a, minSepDeg)) return a;
a = Mathf.Repeat(a + goldenAngleDeg, 360f);
}
return a; // fallback
}
bool IsAngleClear(float angleDeg, float minSepDeg)
{
float half = minSepDeg * 0.5f;
Vector3 center = spawnPoint.position;
foreach (var g in live)
{
if (!g) continue;
Vector3 v = g.transform.position - center;
v.y = 0f;
if (v.sqrMagnitude < 0.0001f) continue;
2025-09-22 17:26:19 +05:00
float ang = Mathf.Atan2(v.z, v.x) * Mathf.Rad2Deg; // project to XZ
float delta = Mathf.DeltaAngle(angleDeg, ang);
if (Mathf.Abs(delta) < half) return false;
}
return true;
}
// ----- Non-radial fallback placement (distance-based) -----
bool TryFindSpawnPos(out Vector3 pos)
{
for (int i = 0; i < maxPlacementTries; i++)
{
var off = Random.insideUnitCircle * Mathf.Max(spawnRadialOffset, minSpawnSpacing * 0.6f);
var p = spawnPoint.position + new Vector3(off.x, 0f, off.y);
if (IsPositionClear(p, minSpawnSpacing))
{
pos = p;
return true;
}
}
// Fallback push along any direction
var dir = Random.insideUnitCircle.normalized;
pos = spawnPoint.position + new Vector3(dir.x, 0f, dir.y) * Mathf.Max(spawnRadialOffset, minSpawnSpacing);
return true;
}
bool IsPositionClear(Vector3 p, float spacing)
{
float r2 = spacing * spacing;
foreach (var g in live)
{
if (!g) continue;
if ((g.transform.position - p).sqrMagnitude < r2) return false;
}
return !Physics.CheckSphere(p, spacing * 0.5f, ballLayerMask, QueryTriggerInteraction.Ignore);
}
// ----- Odds -----
ClawBubbleType PickWeightedType()
{
2025-09-22 17:26:19 +05:00
if (_forcedTypes.Count > 0) return _forcedTypes.Dequeue();
if (weights == null || weights.Count == 0) return ClawBubbleType.Wolf;
float total = 0f;
foreach (var w in weights) if (w.weight > 0f) total += w.weight;
if (total <= 0f) return ClawBubbleType.Wolf;
float r = Random.value * total, acc = 0f;
foreach (var w in weights)
{
if (w.weight <= 0f) continue;
acc += w.weight;
if (r <= acc) return w.type;
}
return weights[weights.Count - 1].type;
}
private class _SpawnRegistration : MonoBehaviour
{
private ClawBubbleSpawner mgr;
public void Init(ClawBubbleSpawner m) { mgr = m; }
void OnDestroy() { if (mgr) mgr.live.Remove(gameObject); }
}
}
2025-09-22 17:26:19 +05:00
//// ClawBubbleSpawner.cs
//using UnityEngine;
//using System.Collections.Generic;
//using DG.Tweening;
//public class ClawBubbleSpawner : MonoBehaviour
//{
// public static ClawBubbleSpawner Instance;
// public GameObject spawnPointLid;
// public List<Material> bubbleTypeMaterials;
// [Header("Single Prefab")]
// [Tooltip("The ONLY bubble prefab (must contain ClawBubble + Rigidbody).")]
// public GameObject bubblePrefab;
// [Header("Spawn Point & Limits")]
// [Tooltip("Where bubbles emerge (center opening).")]
// public Transform spawnPoint;
// [Tooltip("Seed bubbles at round start.")]
// public int initialSeedCount = 6;
// [Tooltip("Max number of bubbles alive at once.")]
// public int maxConcurrentBubbles = 12;
// [Tooltip("Spawn this many after every grab.")]
// public int spawnPerGrab = 1;
// [Tooltip("Extra spawns when a trap is grabbed (Bomb/Shock).")]
// public int extraOnTrap = 2;
// [Tooltip("Cooldown between spawns to avoid burst spam.")]
// public float spawnCooldown = 0.15f;
// [Header("Motion (Upward + Lateral)")]
// public float riseImpulse = 8f;
// [Header("Radial Ejection")]
// [Tooltip("Use radial angles so balls peel away from the center without overlapping.")]
// public bool useRadialEjection = true;
// [Tooltip("Horizontal speed applied radially on spawn.")]
// public float radialImpulse = 1.8f;
// [Tooltip("Small offset from the exact center so spawns aren't coplanar.")]
// public float spawnRadialOffset = 0.12f;
// [Tooltip("Distribute subsequent spawns using the golden angle (~137.5<EFBFBD>).")]
// public float goldenAngleDeg = 137.50776f;
// [Tooltip("Minimum angular separation from existing balls (0 = off).")]
// public float minAngleSeparationDeg = 18f;
// [Tooltip("Tries to find a clear angle before giving up.")]
// public int maxAngleTries = 16;
// [Header("Initial Seed Layout")]
// [Tooltip("Evenly space the first batch on a ring so they never overlap.")]
// public bool seedOnEvenRing = true;
// public float seedRingRadius = 0.5f;
// public float seedAngleJitterDeg = 6f;
// [Header("Fallback (Non-overlap by distance)")]
// public float minSpawnSpacing = 0.3f;
// public int maxPlacementTries = 10;
// public LayerMask ballLayerMask = ~0;
// [System.Serializable]
// public struct WeightedType { public ClawBubbleType type; public float weight; }
// public List<WeightedType> weights = new List<WeightedType>
// {
// new WeightedType{ type = ClawBubbleType.Wolf, weight = 50f },
// new WeightedType{ type = ClawBubbleType.GoldenWolf, weight = 2f },
// new WeightedType{ type = ClawBubbleType.Star, weight = 15f },
// new WeightedType{ type = ClawBubbleType.Crystal, weight = 10f },
// new WeightedType{ type = ClawBubbleType.Freeze, weight = 4f },
// new WeightedType{ type = ClawBubbleType.Time, weight = 6f },
// new WeightedType{ type = ClawBubbleType.Turbo, weight = 5f },
// new WeightedType{ type = ClawBubbleType.Bomb, weight = 5f },
// new WeightedType{ type = ClawBubbleType.Shock, weight = 3f },
// };
// // Internal
// private readonly HashSet<GameObject> live = new HashSet<GameObject>();
// private float lastSpawnTime;
// private float nextAngleDeg; // used for golden-angle progression
// // Internal
// private readonly Queue<ClawBubbleType> _forcedTypes = new Queue<ClawBubbleType>(); // <<3C> NEW
// public void QueueType(ClawBubbleType t, int count = 1)
// {
// for (int i = 0; i < count; i++) _forcedTypes.Enqueue(t);
// }
// // Guarantees at least one of 't' exists; spawns immediately (ignores cooldown) if needed
// public void EnsureTypePresent(ClawBubbleType t)
// {
// foreach (var g in live)
// {
// if (!g) continue;
// var b = g.GetComponent<ClawBubble>();
// if (b && b.type == t) return; // already present
// }
// // ensure one now
// _forcedTypes.Enqueue(t);
// TrySpawn(1, ignoreCooldown: true);
// }
// void Awake()
// {
// if (Instance == null) Instance = this;
// else Destroy(gameObject);
// nextAngleDeg = Random.Range(0f, 360f);
// }
// void OnEnable() => StartRound();
// [ContextMenu("StartRound")]
// public void StartRound()
// {
// live.RemoveWhere(g => g == null);
// StopAllCoroutines();
// StartCoroutine(SeedInitial());
// }
// public void EndRound()
// {
// StopAllCoroutines();
// foreach (var g in live) if (g) Destroy(g);
// live.Clear();
// }
// System.Collections.IEnumerator SeedInitial()
// {
// if (seedOnEvenRing && initialSeedCount > 1 && spawnPoint)
// {
// float step = 360f / initialSeedCount;
// for (int i = 0; i < initialSeedCount; i++)
// {
// float ang = i * step + Random.Range(-seedAngleJitterDeg, seedAngleJitterDeg);
// SpawnRadial(ang, useRing: true);
// lastSpawnTime = Time.time;
// yield return new WaitForSeconds(spawnCooldown);
// }
// }
// else
// {
// for (int i = 0; i < initialSeedCount; i++)
// {
// TrySpawn(1);
// yield return new WaitForSeconds(spawnCooldown);
// }
// }
// }
// public void NotifyBubbleGrabbed(ClawBubbleType grabbedType)
// {
// TrySpawn(spawnPerGrab);
// if (grabbedType == ClawBubbleType.Bomb || grabbedType == ClawBubbleType.Shock)
// TrySpawn(extraOnTrap);
// }
// public void TrySpawn(int count, bool ignoreCooldown = false)
// {
// if (!bubblePrefab || !spawnPoint) return;
// live.RemoveWhere(g => g == null);
// for (int i = 0; i < count; i++)
// {
// if (live.Count >= maxConcurrentBubbles) break;
// if (!ignoreCooldown && Time.time - lastSpawnTime < spawnCooldown) break;
// SpawnOne();
// lastSpawnTime = Time.time;
// }
// }
// //public void TrySpawn(int count)
// //{
// // if (!bubblePrefab || !spawnPoint) return;
// // live.RemoveWhere(g => g == null);
// // for (int i = 0; i < count; i++)
// // {
// // if (live.Count >= maxConcurrentBubbles) break;
// // if (Time.time - lastSpawnTime < spawnCooldown) break;
// // SpawnOne();
// // lastSpawnTime = Time.time;
// // }
// //}
// void SpawnOne()
// {
// if (useRadialEjection)
// {
// // pick an angle using golden-angle progression, but enforce min separation
// float chosen = PickClearAngle(nextAngleDeg, minAngleSeparationDeg, maxAngleTries);
// SpawnRadial(chosen, useRing: false);
// // advance angle for next time
// nextAngleDeg = Mathf.Repeat(chosen + goldenAngleDeg, 360f);
// }
// else
// {
// // fallback to jittered position around center (with non-overlap)
// if (!TryFindSpawnPos(out var pos))
// pos = spawnPoint.position;
// SpawnOneAt(pos, lateralDir: Vector3.zero); // will randomize inside
// }
// }
// void SpawnRadial(float angleDeg, bool useRing)
// {
// // Build a local XZ direction from the angle, then convert to world via spawnPoint
// float rad = angleDeg * Mathf.Deg2Rad;
// // Local direction (right = +X, forward = +Z)
// Vector3 localDir = new Vector3(Mathf.Cos(rad), 0f, Mathf.Sin(rad)).normalized;
// // Position: small offset from center (or larger ring for initial seed)
// float radius = useRing ? seedRingRadius : spawnRadialOffset;
// Vector3 pos = spawnPoint.TransformPoint(localDir * radius);
// SpawnOneAt(pos, spawnPoint.TransformDirection(localDir));
// }
// void SpawnOneAt(Vector3 pos, Vector3 lateralDir)
// {
// var t = PickWeightedType();
// spawnPointLid.transform.DOLocalMoveY(-0.5f, 0.3f).OnComplete(() =>
// {
// var go = Instantiate(bubblePrefab, pos, Quaternion.identity);
// spawnPointLid.transform.DOLocalMoveY(0, 0.3f).SetEase(Ease.OutBack).SetDelay(0.5f);
// live.Add(go);
// go.AddComponent<_SpawnRegistration>().Init(this);
// var cb = go.GetComponent<ClawBubble>();
// if (cb != null) cb.type = t;
// cb.bottomMeshRenderer.material = bubbleTypeMaterials[(int)t];
// var rb = go.GetComponent<Rigidbody>();
// if (rb != null)
// {
// // If a lateral direction was provided, use it; otherwise random
// Vector3 lateral = lateralDir != Vector3.zero
// ? lateralDir.normalized * radialImpulse
// : new Vector3(Random.Range(-0.5f, 0.5f), 0f, Random.Range(-0.5f, 0.5f)).normalized * radialImpulse;
// Vector3 impulse = Vector3.up * riseImpulse + lateral;
// rb.AddForce(impulse, ForceMode.VelocityChange);
// rb.detectCollisions = true;
// }
// });
// }
// // ----- Angle picking / separation helpers -----
// float PickClearAngle(float startDeg, float minSepDeg, int tries)
// {
// if (minSepDeg <= 0f || live.Count == 0) return startDeg;
// float a = startDeg;
// for (int i = 0; i < tries; i++)
// {
// if (IsAngleClear(a, minSepDeg)) return a;
// a = Mathf.Repeat(a + goldenAngleDeg, 360f);
// }
// return a; // fallback
// }
// bool IsAngleClear(float angleDeg, float minSepDeg)
// {
// float half = minSepDeg * 0.5f;
// Vector3 center = spawnPoint.position;
// foreach (var g in live)
// {
// if (!g) continue;
// Vector3 v = g.transform.position - center;
// v.y = 0f;
// if (v.sqrMagnitude < 0.0001f) continue;
// float ang = Mathf.Atan2(v.z, v.x) * Mathf.Rad2Deg; // project to XZ (note: x/z order)
// float delta = Mathf.DeltaAngle(angleDeg, ang);
// if (Mathf.Abs(delta) < half) return false;
// }
// return true;
// }
// // ----- Non-radial fallback placement (distance-based) -----
// bool TryFindSpawnPos(out Vector3 pos)
// {
// for (int i = 0; i < maxPlacementTries; i++)
// {
// var off = Random.insideUnitCircle * Mathf.Max(spawnRadialOffset, minSpawnSpacing * 0.6f);
// var p = spawnPoint.position + new Vector3(off.x, 0f, off.y);
// if (IsPositionClear(p, minSpawnSpacing))
// {
// pos = p;
// return true;
// }
// }
// // Fallback push along any direction
// var dir = Random.insideUnitCircle.normalized;
// pos = spawnPoint.position + new Vector3(dir.x, 0f, dir.y) * Mathf.Max(spawnRadialOffset, minSpawnSpacing);
// return true;
// }
// bool IsPositionClear(Vector3 p, float spacing)
// {
// float r2 = spacing * spacing;
// foreach (var g in live)
// {
// if (!g) continue;
// if ((g.transform.position - p).sqrMagnitude < r2) return false;
// }
// return !Physics.CheckSphere(p, spacing * 0.5f, ballLayerMask, QueryTriggerInteraction.Ignore);
// }
// // ----- Odds -----
// ClawBubbleType PickWeightedType()
// {
// if (_forcedTypes.Count > 0) return _forcedTypes.Dequeue(); // <<3C> NEW
// if (weights == null || weights.Count == 0) return ClawBubbleType.Wolf;
// float total = 0f;
// foreach (var w in weights) if (w.weight > 0f) total += w.weight;
// if (total <= 0f) return ClawBubbleType.Wolf;
// float r = Random.value * total, acc = 0f;
// foreach (var w in weights)
// {
// if (w.weight <= 0f) continue;
// acc += w.weight;
// if (r <= acc) return w.type;
// }
// return weights[weights.Count - 1].type;
// }
// //ClawBubbleType PickWeightedType()
// //{
// // if (weights == null || weights.Count == 0) return ClawBubbleType.Wolf;
// // float total = 0f;
// // foreach (var w in weights) if (w.weight > 0f) total += w.weight;
// // if (total <= 0f) return ClawBubbleType.Wolf;
// // float r = Random.value * total, acc = 0f;
// // foreach (var w in weights)
// // {
// // if (w.weight <= 0f) continue;
// // acc += w.weight;
// // if (r <= acc) return w.type;
// // }
// // return weights[weights.Count - 1].type;
// //}
// private class _SpawnRegistration : MonoBehaviour
// {
// private ClawBubbleSpawner mgr;
// public void Init(ClawBubbleSpawner m) { mgr = m; }
// void OnDestroy() { if (mgr) mgr.live.Remove(gameObject); }
// }
//}