All files / renderer/village fish.ts

100% Statements 62/62
87.5% Branches 7/8
100% Functions 3/3
100% Lines 62/62

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104                4x 4x 4x 730x 730x 730x 730x 730x 730x 4x                                           1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x     1x 1x   1x 1x   1x 4x 4x 4x 4x 4x 4x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 4x 4x           1x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x   1x  
/**
 * Deterministic parameters for the underwater fish school.
 *
 * Each fish follows a slow horizontal circular path with a sinusoidal
 * vertical bob. Rendering is gated by camera y elsewhere; this module
 * only exposes pure helpers.
 */
 
function mulberry32(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
 
export interface FishPath {
  /** Centre of the circular path in XZ. */
  centerX: number;
  centerZ: number;
  /** Radius of the circle. */
  radius: number;
  /** Mean depth (y) the fish swims around. */
  baseY: number;
  /** Vertical bob amplitude. */
  bobAmplitude: number;
  /** Angular speed in rad/s. Positive is CCW. */
  angularSpeed: number;
  /** Starting phase in radians. */
  phase: number;
  /** Palette index for the fish body colour. */
  colorIndex: number;
  /** Body length multiplier (per-fish size variation). */
  scale: number;
}
 
export const FISH_COLORS: readonly string[] = Object.freeze([
  "#ffb85c",
  "#5fb0ff",
  "#ff6b6b",
  "#f5d65c",
  "#b48cff",
  "#5fd9a3",
  "#ff93c6"
]);
 
/** Where fish are allowed to swim vertically (world y). */
export const FISH_MIN_Y = -14;
export const FISH_MAX_Y = -2;
 
/** Horizontal extents for the centre of each fish's orbit. */
export const FISH_ORBIT_MIN_RADIUS = 5;
export const FISH_ORBIT_MAX_RADIUS = 35;
 
export const FISH_COUNT = 22;
export const FISH_SEED = 0xf15c0111;
 
export function generateFishPaths(
  count: number = FISH_COUNT,
  seed: number = FISH_SEED
): FishPath[] {
  const rand = mulberry32(seed);
  const paths: FishPath[] = [];
  for (let i = 0; i < count; i++) {
    const centerR = rand() * 25;
    const centerTheta = rand() * Math.PI * 2;
    paths.push({
      centerX: Math.cos(centerTheta) * centerR,
      centerZ: Math.sin(centerTheta) * centerR,
      radius: FISH_ORBIT_MIN_RADIUS + rand() * (FISH_ORBIT_MAX_RADIUS - FISH_ORBIT_MIN_RADIUS),
      baseY: FISH_MIN_Y + rand() * (FISH_MAX_Y - FISH_MIN_Y),
      bobAmplitude: 0.3 + rand() * 0.9,
      angularSpeed: (rand() < 0.5 ? -1 : 1) * (0.15 + rand() * 0.25),
      phase: rand() * Math.PI * 2,
      colorIndex: Math.floor(rand() * FISH_COLORS.length),
      scale: 0.8 + rand() * 0.8
    });
  }
  return paths;
}
 
/**
 * Pure helper - where is this fish at time `t`, and which direction is
 * it heading? Exported so the path math can be unit tested.
 */
export function fishPositionAt(
  path: FishPath,
  t: number
): { position: [number, number, number]; tangent: [number, number, number] } {
  const angle = path.phase + path.angularSpeed * t;
  const x = path.centerX + Math.cos(angle) * path.radius;
  const z = path.centerZ + Math.sin(angle) * path.radius;
  const y = path.baseY + Math.sin(t * 0.7 + path.phase) * path.bobAmplitude;
  const sign = Math.sign(path.angularSpeed) || 1;
  const tx = -Math.sin(angle) * sign;
  const tz = Math.cos(angle) * sign;
  return { position: [x, y, z], tangent: [tx, 0, tz] };
}
 
export const FISH_PATHS: readonly FishPath[] = Object.freeze(generateFishPaths());