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);
}
}