using System; using System.Collections.Generic; using UnityEngine; using Random = UnityEngine.Random; [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(Collider2D))] public class TrickController : MonoBehaviour { private static readonly List ActiveTricks = new List(); private static PlayerPlatformerController[] cachedPlayers = Array.Empty(); private static float nextPlayerCacheTime; private static bool hasCanonicalScale; private static Vector3 canonicalScale; public static int ActiveCount => ActiveTricks.Count; [SerializeField] private string treatTag = "Interactable"; [SerializeField] private float moveSpeed = 3.5f; [SerializeField] private float targetRefreshInterval = 0.4f; [SerializeField] private float consumeDistance = 0.35f; [SerializeField] private float wanderInterval = 1.2f; [SerializeField] private float consumeCooldown = 1.5f; [SerializeField] private TrickController trickPrefab; [SerializeField] private float avoidPlayerRadius = 1.2f; [SerializeField] private float treatSeekWeight = 1f; [SerializeField] private float wanderWeight = 0.4f; [SerializeField] private int maxTricks = 50; [SerializeField] private float jumpImpulse = 6f; [SerializeField] private float jumpImpulseVariance = 0.25f; [SerializeField] private float jumpCooldown = 1f; [SerializeField] private float jumpChancePerSecond = 0.5f; [SerializeField] private float jumpArcChance = 0.5f; [SerializeField] private float jumpArcHorizontalImpulse = 2f; [SerializeField] private float basketSeekRadius = 8f; [SerializeField] private float basketSeekWeight = 0.9f; [SerializeField] private float basketMinHeight = 0.5f; [SerializeField] private float basketRefreshInterval = 0.6f; [SerializeField] private float basketJumpChance = 0.35f; [SerializeField] private float basketJumpHorizontalRange = 0.8f; [SerializeField] private LayerMask groundMask = ~0; [SerializeField] private float groundCheckDistance = 0.05f; [SerializeField] private float despawnMinX = -12f; [SerializeField] private float despawnMaxX = 12f; [SerializeField] private float despawnMinY = -10f; [SerializeField] private float despawnMaxY = 20f; [SerializeField] private float throwControlLockTime = 0.35f; [SerializeField] private float wallCheckDistance = 0.1f; [SerializeField] private float wallAvoidDuration = 0.6f; [SerializeField] private float wallAvoidCooldown = 0.2f; private Rigidbody2D body; private Transform target; private float nextTargetTime; private Transform basketTarget; private float nextBasketTime; private Vector2 wanderDirection; private float nextWanderTime; private bool isCarried; private bool isBasketed; private float nextConsumeTime; private float desiredXVelocity; private float nextJumpTime; private float movementLockUntil; private readonly RaycastHit2D[] groundHits = new RaycastHit2D[4]; private readonly RaycastHit2D[] wallHits = new RaycastHit2D[4]; private float wallAvoidUntil; private float wallAvoidDirection; private float nextWallAvoidTime; private RigidbodyType2D basketBodyType; private float basketGravityScale; private bool basketSimulated; private bool hasBasketAnchor; private Vector2 basketAnchorPosition; [SerializeField] private string basketSortingLayer = ""; [SerializeField] private int basketSortingOrder = -10; private SpriteRenderer cachedRenderer; private string originalSortingLayer; private int originalSortingOrder; private Vector3 originalLocalScale; public bool IsCarried => isCarried; public bool IsBasketed => isBasketed; private void Awake() { body = GetComponent(); body.bodyType = RigidbodyType2D.Dynamic; body.gravityScale = 1f; body.freezeRotation = true; body.collisionDetectionMode = CollisionDetectionMode2D.Continuous; body.interpolation = RigidbodyInterpolation2D.Interpolate; cachedRenderer = GetComponent(); originalLocalScale = transform.localScale; if (!hasCanonicalScale) { canonicalScale = transform.localScale; hasCanonicalScale = true; } } private void OnEnable() { if (!ActiveTricks.Contains(this)) { ActiveTricks.Add(this); } nextConsumeTime = Time.time + consumeCooldown; } private void OnDisable() { ActiveTricks.Remove(this); } private void Update() { if (isCarried) { return; } if (isBasketed) { return; } var position = transform.position; if (position.x < despawnMinX || position.x > despawnMaxX || position.y < despawnMinY || position.y > despawnMaxY) { Destroy(gameObject); return; } if (Time.time >= nextTargetTime) { target = FindNearestTreat(); if (target == null) { // If we can't find treats while we're tagged Interactable, keep moving. wanderDirection = Random.insideUnitCircle.normalized; if (wanderDirection.sqrMagnitude < 0.01f) { wanderDirection = Vector2.right; } nextWanderTime = Time.time + wanderInterval; } nextTargetTime = Time.time + targetRefreshInterval; } if (Time.time >= nextBasketTime) { basketTarget = FindNearestBasketAbove(); nextBasketTime = Time.time + basketRefreshInterval; } if (Time.time >= nextWanderTime) { wanderDirection = Random.insideUnitCircle.normalized; if (wanderDirection.sqrMagnitude < 0.01f) { wanderDirection = Vector2.right; } nextWanderTime = Time.time + wanderInterval; } TryConsumeTreat(); } private void FixedUpdate() { if (isCarried) { return; } if (isBasketed) { if (hasBasketAnchor) { transform.position = basketAnchorPosition; } var held = body.linearVelocity; held.x = 0f; held.y = 0f; body.linearVelocity = held; return; } if (Time.time < movementLockUntil) { return; } if (!IsGrounded()) { return; } Vector2 desiredVelocity; var hasTarget = target != null; var canConsume = Time.time >= nextConsumeTime; var desiredX = 0f; if (hasTarget && canConsume) { var direction = ((Vector2)target.position - (Vector2)transform.position).normalized; desiredX += direction.x * treatSeekWeight; } else { desiredX += wanderDirection.x * wanderWeight; } if (!hasTarget && basketTarget != null) { var basketDelta = (Vector2)basketTarget.position - (Vector2)transform.position; if (basketDelta.y >= basketMinHeight && basketDelta.magnitude <= basketSeekRadius) { desiredX += Mathf.Sign(basketDelta.x) * basketSeekWeight; } } // No trick-avoidance bias. if (Mathf.Abs(desiredX) < 0.01f) { desiredX = wanderDirection.x * 0.2f; } if (Time.time >= nextWallAvoidTime) { var wallDirection = GetWallAvoidDirection(desiredX); if (wallDirection != 0f) { wallAvoidDirection = wallDirection; wallAvoidUntil = Time.time + wallAvoidDuration; nextWallAvoidTime = Time.time + wallAvoidDuration + wallAvoidCooldown; } } if (Time.time < wallAvoidUntil && Mathf.Abs(wallAvoidDirection) > 0.01f) { desiredX = wallAvoidDirection; } desiredVelocity = new Vector2(Mathf.Clamp(desiredX, -1f, 1f) * moveSpeed, 0f); var velocity = body.linearVelocity; desiredXVelocity = desiredVelocity.x; velocity.x = Mathf.Lerp(velocity.x, desiredXVelocity, 0.25f); body.linearVelocity = velocity; var isMoving = Mathf.Abs(desiredXVelocity) > 0.1f; TryJump(isMoving, desiredXVelocity); } private Transform FindNearestTreat() { var treats = GameObject.FindGameObjectsWithTag(treatTag); if (treats == null || treats.Length == 0) { return null; } Transform best = null; var bestDistance = float.MaxValue; var origin = transform.position; for (var i = 0; i < treats.Length; i++) { var candidate = treats[i]; if (candidate == gameObject) { continue; } if (!candidate.activeInHierarchy) { continue; } if (candidate.GetComponent() != null) { continue; } if (candidate.GetComponent() != null) { continue; } if (!candidate.TryGetComponent(out var collider) || !collider.enabled) { continue; } var distance = Vector2.Distance(origin, candidate.transform.position); if (distance < bestDistance) { bestDistance = distance; best = candidate.transform; } } return best; } private Transform FindNearestBasketAbove() { var baskets = FindObjectsByType(FindObjectsSortMode.None); if (baskets == null || baskets.Length == 0) { return null; } Transform best = null; var bestDistance = float.MaxValue; var origin = transform.position; for (var i = 0; i < baskets.Length; i++) { var basket = baskets[i]; if (basket == null || !basket.gameObject.activeInHierarchy) { continue; } var basketPos = basket.transform.position; var delta = (Vector2)basketPos - (Vector2)origin; if (delta.y < basketMinHeight) { continue; } var distance = delta.magnitude; if (distance > basketSeekRadius) { continue; } if (distance < bestDistance) { bestDistance = distance; best = basket.transform; } } return best; } private void TryConsumeTreat() { if (isCarried) { return; } if (Time.time < nextConsumeTime) { return; } var hits = Physics2D.OverlapCircleAll(transform.position, consumeDistance); for (var i = 0; i < hits.Length; i++) { var hit = hits[i]; if (hit == null || hit.gameObject == gameObject || !hit.CompareTag(treatTag)) { continue; } var spawnPosition = hit.transform.position; hit.enabled = false; hit.gameObject.tag = "Untagged"; Destroy(hit.gameObject); if (target == hit.transform) { target = null; } SpawnTrick(spawnPosition); nextConsumeTime = Time.time + consumeCooldown; break; } } private bool SpawnTrick(Vector3 position) { if (maxTricks > 0 && ActiveTricks.Count >= maxTricks) { return false; } if (trickPrefab != null) { var instance = Instantiate(trickPrefab, position, Quaternion.identity); if (instance != null) { instance.ResetRuntimeState(canonicalScale); } return true; } var clone = Instantiate(gameObject, position, Quaternion.identity); if (clone.TryGetComponent(out var spawned)) { spawned.ResetRuntimeState(canonicalScale); } return true; } private void ResetRuntimeState(Vector3? forcedScale = null) { isCarried = false; isBasketed = false; hasBasketAnchor = false; movementLockUntil = 0f; wallAvoidUntil = 0f; wallAvoidDirection = 0f; if (forcedScale.HasValue) { transform.localScale = forcedScale.Value; } if (cachedRenderer != null) { if (!string.IsNullOrWhiteSpace(basketSortingLayer)) { cachedRenderer.sortingLayerName = originalSortingLayer; } cachedRenderer.sortingOrder = originalSortingOrder; } body.simulated = true; body.bodyType = RigidbodyType2D.Dynamic; body.gravityScale = 1f; body.linearVelocity = Vector2.zero; body.angularVelocity = 0f; if (TryGetComponent(out var collider)) { collider.enabled = true; } } public static bool TrySpawnAt(Vector3 position) { if (ActiveTricks.Count == 0) { return false; } TrickController candidate = null; TrickController fallback = null; for (var i = 0; i < ActiveTricks.Count; i++) { var trick = ActiveTricks[i]; if (trick == null) { continue; } if (fallback == null) { fallback = trick; } if (!trick.isCarried && !trick.isBasketed) { if (trick.TryGetComponent(out var collider) && collider.enabled) { candidate = trick; break; } } } if (candidate == null) { candidate = fallback; } return candidate != null && candidate.SpawnTrick(position); } public static bool TrySpawnFromPrefabAt(Vector3 position) { if (ActiveTricks.Count == 0) { return false; } for (var i = 0; i < ActiveTricks.Count; i++) { var trick = ActiveTricks[i]; if (trick == null) { continue; } if (trick.trickPrefab == null) { continue; } return trick.SpawnTrick(position); } return false; } private float GetAvoidPlayerDirection() { if (Time.time >= nextPlayerCacheTime) { cachedPlayers = FindObjectsByType(FindObjectsSortMode.None); nextPlayerCacheTime = Time.time + 0.5f; } var players = cachedPlayers; if (players == null || players.Length == 0) { return 0f; } Transform closest = null; var closestDist = float.MaxValue; var origin = transform.position; for (var i = 0; i < players.Length; i++) { var candidate = players[i]; if (candidate == null || !candidate.gameObject.activeInHierarchy) { continue; } var dist = Vector2.Distance(origin, candidate.transform.position); if (dist < closestDist) { closestDist = dist; closest = candidate.transform; } } if (closest == null || closestDist > avoidPlayerRadius) { return 0f; } return Mathf.Sign(origin.x - closest.position.x); } private void TryJump(bool isMoving, float jumpDirection) { if (Time.time < nextJumpTime) { return; } if (!IsGrounded()) { return; } var wantsBasketJump = false; var basketDir = 0f; if (basketTarget != null) { var delta = (Vector2)basketTarget.position - (Vector2)transform.position; if (delta.y >= basketMinHeight && Mathf.Abs(delta.x) <= basketJumpHorizontalRange) { if (Random.value < basketJumpChance) { wantsBasketJump = true; basketDir = Mathf.Sign(delta.x); } } } if (!wantsBasketJump) { if (!isMoving) { return; } var chance = jumpChancePerSecond * Time.fixedDeltaTime; if (Random.value > chance) { return; } } if (body.linearVelocity.y > 0.01f) { return; } var velocity = body.linearVelocity; velocity.y = 0f; var jumpDir = wantsBasketJump ? basketDir : Mathf.Sign(jumpDirection); if (jumpDir == 0f) { jumpDir = Random.value < 0.5f ? -1f : 1f; } velocity.x = jumpDir * moveSpeed; body.linearVelocity = velocity; var minImpulse = Mathf.Max(0f, jumpImpulse * (1f - jumpImpulseVariance)); var maxImpulse = Mathf.Max(minImpulse, jumpImpulse * (1f + jumpImpulseVariance)); var jumpImpulseActual = Random.Range(minImpulse, maxImpulse); body.AddForce(Vector2.up * jumpImpulseActual, ForceMode2D.Impulse); if (Random.value < jumpArcChance && jumpArcHorizontalImpulse > 0f) { var arcDir = Random.value < 0.5f ? -1f : 1f; body.AddForce(Vector2.right * arcDir * jumpArcHorizontalImpulse, ForceMode2D.Impulse); } nextJumpTime = Time.time + jumpCooldown; } private bool IsGrounded() { var collider = GetComponent(); if (collider == null) { return false; } var filter = new ContactFilter2D(); filter.SetLayerMask(groundMask); filter.useTriggers = false; var hitCount = collider.Cast(Vector2.down, filter, groundHits, groundCheckDistance); for (var i = 0; i < hitCount; i++) { var hitCollider = groundHits[i].collider; if (hitCollider == null || hitCollider == collider) { continue; } if (hitCollider.GetComponent() != null) { continue; } if (hitCollider.GetComponent() != null) { continue; } return true; } return false; } private float GetWallAvoidDirection(float desiredX) { if (Mathf.Abs(desiredX) < 0.01f) { return 0f; } var collider = GetComponent(); if (collider == null) { return 0f; } var filter = new ContactFilter2D(); filter.SetLayerMask(groundMask); filter.useTriggers = false; var dir = Mathf.Sign(desiredX); var hitCount = collider.Cast(Vector2.right * dir, filter, wallHits, wallCheckDistance); for (var i = 0; i < hitCount; i++) { var hitCollider = wallHits[i].collider; if (hitCollider == null || hitCollider == collider) { continue; } if (hitCollider.GetComponent() != null) { continue; } if (hitCollider.GetComponent() != null) { continue; } return -dir; } return 0f; } public void SetCarried(bool carried) { isCarried = carried; if (carried && isBasketed) { SetBasketed(false); } if (carried) { target = null; desiredXVelocity = 0f; var velocity = body.linearVelocity; velocity.x = 0f; velocity.y = 0f; body.linearVelocity = velocity; nextJumpTime = Time.time + 0.1f; if (TryGetComponent(out var collider)) { collider.enabled = false; } } else { var velocity = body.linearVelocity; velocity.x = 0f; velocity.y = 0f; body.linearVelocity = velocity; if (TryGetComponent(out var collider)) { collider.enabled = true; } } } public void SetBasketed(bool basketed, Vector2? anchorPosition = null) { if (isBasketed == basketed) { return; } isBasketed = basketed; if (basketed) { if (cachedRenderer != null) { originalSortingLayer = cachedRenderer.sortingLayerName; originalSortingOrder = cachedRenderer.sortingOrder; if (!string.IsNullOrWhiteSpace(basketSortingLayer)) { cachedRenderer.sortingLayerName = basketSortingLayer; } cachedRenderer.sortingOrder = basketSortingOrder; } if (anchorPosition.HasValue) { hasBasketAnchor = true; basketAnchorPosition = anchorPosition.Value; } else { hasBasketAnchor = false; } basketBodyType = body.bodyType; basketGravityScale = body.gravityScale; basketSimulated = body.simulated; body.simulated = true; body.bodyType = RigidbodyType2D.Kinematic; body.gravityScale = 0f; body.linearVelocity = Vector2.zero; body.angularVelocity = 0f; nextJumpTime = Time.time + 0.1f; } else { if (cachedRenderer != null) { if (!string.IsNullOrWhiteSpace(basketSortingLayer)) { cachedRenderer.sortingLayerName = originalSortingLayer; } cachedRenderer.sortingOrder = originalSortingOrder; } hasBasketAnchor = false; body.simulated = basketSimulated; body.bodyType = basketBodyType; body.gravityScale = basketGravityScale; } } public void OnThrown() { movementLockUntil = Time.time + throwControlLockTime; } }