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; 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 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; private Rigidbody2D body; private Transform target; private float nextTargetTime; 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 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; 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(); } 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 >= 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; } // No trick-avoidance bias. if (Mathf.Abs(desiredX) < 0.01f) { desiredX = wanderDirection.x * 0.2f; } var wallDirection = GetWallAvoidDirection(desiredX); if (wallDirection != 0f) { wallAvoidDirection = wallDirection; wallAvoidUntil = Time.time + wallAvoidDuration; } 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.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 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 void SpawnTrick(Vector3 position) { if (maxTricks > 0 && ActiveTricks.Count >= maxTricks) { return; } if (trickPrefab != null) { Instantiate(trickPrefab, position, Quaternion.identity); return; } Instantiate(gameObject, position, Quaternion.identity); } 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 (!isMoving || Time.time < nextJumpTime) { return; } if (!IsGrounded()) { return; } if (body.linearVelocity.y > 0.01f) { return; } var chance = jumpChancePerSecond * Time.fixedDeltaTime; if (Random.value > chance) { return; } var velocity = body.linearVelocity; velocity.y = 0f; var jumpDir = 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; } }