2025-08-30 22:59:11 +05:00
|
|
|
|
// ClawBubbleSpawner.cs
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
using System.Collections.Generic;
|
2025-09-06 00:08:22 +05:00
|
|
|
|
using DG.Tweening;
|
2025-08-30 22:59:11 +05:00
|
|
|
|
|
|
|
|
|
public class ClawBubbleSpawner : MonoBehaviour
|
|
|
|
|
{
|
|
|
|
|
public static ClawBubbleSpawner Instance;
|
2025-09-06 00:08:22 +05:00
|
|
|
|
public GameObject spawnPointLid;
|
|
|
|
|
public List<Material> bubbleTypeMaterials;
|
2025-08-30 22:59:11 +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 },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Internal
|
|
|
|
|
private readonly HashSet<GameObject> live = new HashSet<GameObject>();
|
|
|
|
|
private float lastSpawnTime;
|
|
|
|
|
private float nextAngleDeg; // used for golden-angle progression
|
2025-09-06 00:08:22 +05:00
|
|
|
|
public
|
2025-08-30 22:59:11 +05:00
|
|
|
|
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();
|
2025-09-06 00:08:22 +05:00
|
|
|
|
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);
|
2025-08-30 22:59:11 +05:00
|
|
|
|
|
2025-09-06 00:08:22 +05:00
|
|
|
|
go.AddComponent<_SpawnRegistration>().Init(this);
|
2025-08-30 22:59:11 +05:00
|
|
|
|
|
2025-09-06 00:08:22 +05:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-08-30 22:59:11 +05:00
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ----- 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); }
|
|
|
|
|
}
|
|
|
|
|
}
|