using UnityEngine; using System.Collections.Generic; /// /// Swimming for an inflated paper fish, using the Fish Alive motion model (Denys Almaral): /// a forward-only body that accelerates and is slowed by liquid drag, re-pings its wander /// target every fraction of a second (with a random offset), picks fast / normal / slow /// acceleration by distance to the target, and turns with an accelerate-then-ease rotation. /// A look-ahead sphere steers it off the glass and away from other fish by trying rotated /// directions (right, left, up, down in 20 degree steps), and hard limits keep it in the tank. /// Distances from Fish Alive are in metres for a ~15 cm fish; they're multiplied by /// worldScale to suit this tank. Presentation only: species rules live elsewhere. /// [DisallowMultipleComponent] public class PaperFishController : MonoBehaviour { static readonly List All = new List(); public enum SwimStyle { Agile, Normal, Clumsy } [Header("Bounds (world)")] public Vector3 boundsMin = new Vector3(-8f, -3.5f, -3f); public Vector3 boundsMax = new Vector3(8f, 3.5f, 3f); [Tooltip("Chance a new wander point is in the front third of the tank (fish come up to the glass).")] [Range(0f, 1f)] public float frontBias = 0.35f; [Header("Fish Alive swim model")] public SwimStyle style = SwimStyle.Normal; [Tooltip("Fish Alive distances are for a ~15 cm fish in metres; this scales them to the tank.")] public float worldScale = 12f; [Tooltip("Multiplier on all accelerations (builder sets this per fish for personality).")] public float cruiseSpeed = 1.6f; public float targetPingFrequency = 0.5f; public float targetPingPrecision = 0.3f; public float targetPositionPrecision = 0.4f; public float targetFar = 2f; public float targetClose = 0.5f; public float fastAcceleration = 3f; public float normalAcceleration = 1f; public float slowAcceleration = 0.2f; public float accelerationFadeInSpeed = 20f; public float baseTurningAcceleration = 100f; public float liquidDrag = 2f; [Tooltip("How far fish may tilt nose up or down (degrees); keeps paper cards from going edge-on.")] public float maxPitch = 35f; [Header("Wander")] [Tooltip("Seconds before the fish picks a new place in the tank to wander around.")] public Vector2 wanderTime = new Vector2(3f, 9f); [Tooltip("Chance a new wander point is a rest near where the fish already is.")] [Range(0f, 1f)] public float restChance = 0.25f; [Header("Life")] public float bankDegrees = 25f; public float bobAmplitude = 0.05f; public float bobFrequency = 1.1f; [Range(0f, 0.1f)] public float breathAmount = 0.02f; public float breathFrequency = 0.8f; [Header("Facing")] [Tooltip("True if the sprite's nose points +X when unrotated.")] public bool spriteFacesRight = true; [Tooltip("World size (height) of this fish; used for body radius and look-ahead.")] public float size = 2f; [Header("Surface Ripples")] public float rippleZoneHeight = 1.5f; public Vector2 rippleCooldownRange = new Vector2(2f, 5f); // Fish Alive state Vector3 pos, forward = Vector3.right, wanderPoint; float speed, acceleration, finalAcceleration, pingTimer, wanderUntil, avoidTimer; bool turning, aborting, avoiding; Quaternion turnStart, turnTarget; float turnAngleTotal, turnAngle, turnVelocity, turnVelocityAccel; Vector3 pendingTurn; bool hasPendingTurn; // Presentation state float bobPhase, lastYaw, bank, nextRippleAt; Vector3 baseScale; FishWiggle wiggle; /// Every swimming fish, for things that react to them (plants swaying in their wake). public static IReadOnlyList Swimmers => All; public Vector3 Position => pos; public Vector3 Velocity => forward * speed; void OnEnable() { All.Add(this); } void OnDisable() { All.Remove(this); } float S(float metres) => metres * worldScale; float BodyRadius => size * 0.6f; void Start() { baseScale = transform.localScale; baseScale.x = Mathf.Abs(baseScale.x); pos = transform.position; bobPhase = Random.value * Mathf.PI * 2f; forward = Random.value < 0.5f ? Vector3.right : Vector3.left; wiggle = GetComponent(); ApplyStyle(); finalAcceleration = normalAcceleration; pingTimer = Random.value * targetPingFrequency; avoidTimer = Random.value * 0.2f; PickWanderPoint(); lastYaw = Mathf.Atan2(forward.z, forward.x) * Mathf.Rad2Deg; } /// Fish Alive presets: SwimAgile (quick, nimble), SwimNormal, SwimClumsy (slow, relaxed). void ApplyStyle() { float mul = cruiseSpeed / 1.6f; switch (style) { case SwimStyle.Agile: fastAcceleration = 4.5f; normalAcceleration = 1.6f; slowAcceleration = 0.3f; baseTurningAcceleration = 220f; liquidDrag = 2.2f; targetPingFrequency = 0.35f; break; case SwimStyle.Clumsy: fastAcceleration = 1.6f; normalAcceleration = 0.5f; slowAcceleration = 0.1f; baseTurningAcceleration = 50f; liquidDrag = 1.6f; targetPingFrequency = 0.8f; break; } fastAcceleration *= mul; normalAcceleration *= mul; slowAcceleration *= mul; } void PickWanderPoint() { Vector3 inset = Vector3.one * BodyRadius; Vector3 lo = boundsMin + inset, hi = boundsMax - inset; if (Random.value < restChance) wanderPoint = pos + Random.insideUnitSphere * S(0.5f); // idle near where it is else { float zHi = Random.value < frontBias ? Mathf.Lerp(lo.z, hi.z, 0.33f) : hi.z; // front of tank = low Z wanderPoint = new Vector3(Random.Range(lo.x, hi.x), Random.Range(lo.y, hi.y), Random.Range(lo.z, zHi)); } wanderPoint = Vector3.Min(Vector3.Max(wanderPoint, lo), hi); // Don't aim inside a rock or coral: nudge the point up above it, or try again for (int tries = 0; tries < 6 && FishObstacle.Contains(wanderPoint, BodyRadius); tries++) wanderPoint = new Vector3(Random.Range(lo.x, hi.x), Random.Range(lo.y, hi.y), Random.Range(lo.z, hi.z)); wanderUntil = Time.time + Random.Range(wanderTime.x, wanderTime.y); } void Update() { float dt = Time.deltaTime; if (dt <= 0f) return; float t = Time.time; if (t >= wanderUntil) PickWanderPoint(); // Timed calls: target ping and avoidance scan if (!avoiding) { pingTimer -= dt; if (pingTimer < 0f) { PingTarget(); pingTimer = targetPingFrequency + Random.value * targetPingPrecision; } } avoidTimer -= dt; if (avoidTimer < 0f) { AvoidStuff(); avoidTimer = 0.2f; } // Directional motion: soft acceleration, drag, always swim forward float step = accelerationFadeInSpeed * dt; acceleration = acceleration < finalAcceleration - step ? acceleration + step : acceleration > finalAcceleration + step ? acceleration - step : finalAcceleration; speed += S(acceleration) * dt; speed -= liquidDrag * speed * dt; pos += forward * speed * dt; if (turning) MotionTurning(dt); // Hard limits (the glass) Vector3 r = Vector3.one * BodyRadius * 0.5f; pos = Vector3.Min(Vector3.Max(pos, boundsMin + r), boundsMax - r); // Near the top, occasionally disturb the surface if (WaterRippleManager.Instance != null && pos.y > boundsMax.y - rippleZoneHeight && t >= nextRippleAt) { WaterRippleManager.Instance.Emit(pos, Mathf.Clamp01(speed / S(0.6f))); nextRippleAt = t + Random.Range(rippleCooldownRange.x, rippleCooldownRange.y); } Present(dt, t); } void PingTarget() { var targetPos = wanderPoint; Vector3 rng = Random.insideUnitSphere * S(targetPositionPrecision); rng.y *= 0.2f; // Fish Alive VerticalPrecisionMul targetPos += rng; StartTurnTowards(targetPos - pos); var toTarget = targetPos - pos; float d = toTarget.magnitude; bool facing = Vector3.Dot(toTarget.normalized, forward) > 0.5f; if (d > S(targetFar)) { if (facing) finalAcceleration = fastAcceleration; } else finalAcceleration = normalAcceleration; if (d < S(targetClose)) finalAcceleration = slowAcceleration; } Vector3 ClampPitch(Vector3 dir) { var flat = new Vector3(dir.x, 0f, dir.z); if (flat.sqrMagnitude < 0.0001f) flat = new Vector3(forward.x, 0f, forward.z); if (flat.sqrMagnitude < 0.0001f) flat = Vector3.right; float pitch = Mathf.Clamp(Mathf.Atan2(dir.y, flat.magnitude) * Mathf.Rad2Deg, -maxPitch, maxPitch); return (flat.normalized * Mathf.Cos(pitch * Mathf.Deg2Rad) + Vector3.up * Mathf.Sin(pitch * Mathf.Deg2Rad)).normalized; } void StartTurnTowards(Vector3 dir, bool abortExisting = false, float multiplier = 1f) { if (dir.sqrMagnitude <= 0f) return; dir = ClampPitch(dir.normalized); if (!turning) { turning = true; aborting = false; turnStart = Quaternion.LookRotation(forward); turnTarget = Quaternion.LookRotation(dir); turnAngleTotal = Quaternion.Angle(turnStart, turnTarget); turnAngle = 0f; turnVelocity = 0f; turnVelocityAccel = (baseTurningAcceleration + speed / worldScale * 800f) * multiplier; } else if (abortExisting) { // Ease out of the current turn, then take the new one pendingTurn = dir; hasPendingTurn = true; if (!aborting) { aborting = true; turnVelocityAccel = -Mathf.Abs(turnVelocityAccel * 3f); } } } void MotionTurning(float dt) { turnAngle += turnVelocity * dt; turnVelocity += turnVelocityAccel * dt; forward = Quaternion.RotateTowards(turnStart, turnTarget, turnAngle) * Vector3.forward; if (turnAngle > turnAngleTotal * 0.5f && turnVelocityAccel > 0f) turnVelocityAccel = -turnVelocityAccel; if (turnVelocity <= 0f) { bool wasAvoiding = avoiding && !aborting; turning = false; aborting = false; if (wasAvoiding) avoiding = false; // Fish Alive KeepAvoiding: go back to pinging the target if (hasPendingTurn) { hasPendingTurn = false; var d = pendingTurn; StartTurnTowards(d, false, 3f); avoiding = true; } } } // ---- Soft avoidance: look-ahead sphere against the glass, the water surface and other fish ---- Vector3 LookAheadOrigin(Vector3 dir) => pos + dir * BodyRadius; float CastDistance => 0.6f * BodyRadius + S(0.46f) * Mathf.Clamp(speed / S(1f), 0.3f, 2f); float CheckRadius => BodyRadius * 0.5f + speed * 0.1f; bool Blocked(Vector3 dir) { Vector3 p = LookAheadOrigin(dir) + dir * CastDistance; float rad = CheckRadius; if (p.x - rad < boundsMin.x || p.x + rad > boundsMax.x || p.y - rad < boundsMin.y || p.y + rad > boundsMax.y || p.z - rad < boundsMin.z || p.z + rad > boundsMax.z) return true; foreach (var o in All) { if (o == this) continue; if ((o.pos - p).sqrMagnitude < (rad + o.BodyRadius * 0.6f) * (rad + o.BodyRadius * 0.6f)) return true; } return FishObstacle.Contains(p, rad); } void AvoidStuff() { if (avoiding || !Blocked(forward)) return; var right = Vector3.Cross(Vector3.up, forward).normalized; var dirs = new[] { forward, forward, forward, forward }; var rots = new[] { Quaternion.AngleAxis(20f, Vector3.up), Quaternion.AngleAxis(-20f, Vector3.up), Quaternion.AngleAxis(-20f, right), Quaternion.AngleAxis(20f, right) }; for (int tries = 1; tries <= 5; tries++) for (int i = 0; i < 4; i++) { dirs[i] = rots[i] * dirs[i]; if (Blocked(dirs[i])) continue; avoiding = true; if (turning) StartTurnTowards(dirs[i], true, tries); else StartTurnTowards(dirs[i], false, tries); return; } // Boxed in: turn right round avoiding = true; StartTurnTowards(-forward + Vector3.up * 0.1f, true, 5f); } // ---- Presentation: paper card along the heading, banking into turns, bob and breath ---- void Present(float dt, float t) { Vector3 heading = forward; Vector3 side = Vector3.Cross(heading, Vector3.up); if (side.sqrMagnitude < 0.0001f) side = Vector3.forward; Vector3 up = Vector3.Cross(side.normalized, heading).normalized; var rot = Quaternion.LookRotation(side.normalized, up); float yaw = Mathf.Atan2(heading.z, heading.x) * Mathf.Rad2Deg; float yawRate = Mathf.DeltaAngle(lastYaw, yaw) / dt; lastYaw = yaw; float wantBank = Mathf.Clamp(yawRate / 120f, -1f, 1f) * bankDegrees; bank = Mathf.Lerp(bank, wantBank, 1f - Mathf.Exp(-4f * dt)); rot *= Quaternion.AngleAxis(bank, Vector3.right); if (wiggle != null) wiggle.effort = Mathf.Clamp(speed / S(0.35f), 0.15f, 2.5f); float bob = Mathf.Sin(t * bobFrequency + bobPhase) * bobAmplitude; float breath = 1f + Mathf.Sin(t * breathFrequency * Mathf.PI * 2f + bobPhase) * breathAmount; transform.position = pos + Vector3.up * bob; transform.rotation = rot; float mirror = spriteFacesRight ? 1f : -1f; transform.localScale = new Vector3(baseScale.x * mirror * breath, baseScale.y * (2f - breath), baseScale.z); } void OnDrawGizmosSelected() { Gizmos.color = Color.cyan; Gizmos.DrawWireCube((boundsMin + boundsMax) * 0.5f, boundsMax - boundsMin); if (!Application.isPlaying) return; Gizmos.color = Color.yellow; Gizmos.DrawLine(pos, wanderPoint); Gizmos.DrawWireSphere(wanderPoint, 0.2f); Gizmos.color = Blocked(forward) ? Color.red : Color.green; Gizmos.DrawWireSphere(LookAheadOrigin(forward) + forward * CastDistance, CheckRadius); } }