// ClawBubbleSpawner.cs using UnityEngine; using System.Collections.Generic; using DG.Tweening; public class ClawBubbleSpawner : MonoBehaviour { public static ClawBubbleSpawner Instance; public GameObject spawnPointLid; public List 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°).")] 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 weights = new List { 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 live = new HashSet(); private float lastSpawnTime; private float nextAngleDeg; // used for golden-angle progression public 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) { 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(); if (cb != null) cb.type = t; cb.bottomMeshRenderer.material = bubbleTypeMaterials[(int)t]; var rb = go.GetComponent(); 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 (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); } } }