All files / renderer/village Boat.tsx

48.61% Statements 70/144
88.88% Branches 8/9
50% Functions 2/4
48.61% Lines 70/144

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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 2721x                         1x                                         1x 6x 6x 6x 6x 6x 6x 6x 7x 7x 7x 7x 7x 5x 7x 1x 1x 1x 1x 4x 4x 4x 4x 6x 6x           1x 16x 16x 1x                                   1x                   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                     1x 13x 13x 13x 13x 13x 13x     13x 13x 13x 13x 13x 13x 13x 13x 13x                                                                                                                     1x                                                                                                                                          
/* eslint-disable react/no-unknown-property -- react-three-fiber extends JSX with three.js props */
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { MAIN_ISLAND_RADIUS } from "./sceneConstants";
import { MINOR_ISLANDS } from "./minorIslands";
 
/**
 * How far (in world units) from a minor island's edge the boats must
 * keep. Wide enough that the boat's hull (half-length ~1 unit) never
 * overlaps the disc even when it is tracking along the orbit at a
 * shallow angle.
 */
export const BOAT_ISLAND_CLEARANCE = 1.5;
 
/** Minimal obstacle shape used by the avoidance helper. */
export interface BoatObstacle {
  center: { x: number; z: number };
  radius: number;
}
 
/**
 * Push a proposed boat position out of any obstacle disc by the
 * shortest radial distance. Pure and easily unit-testable.
 *
 * The orbit parametrisation naturally guides boats around the main
 * island, but minor islands sit inside the orbit annulus - a straight
 * orbit puts some boats directly through them. For every obstacle
 * whose safety disc (radius + clearance) contains the proposed
 * position, we move the boat outward along the obstacle-to-boat
 * direction until it sits exactly on the safety disc edge. If the
 * proposed position coincides with the island centre, we nudge along
 * +x as a deterministic fallback so the result is never NaN.
 */
export function deflectAroundIslands(
  position: { x: number; z: number },
  obstacles: readonly BoatObstacle[],
  clearance: number = BOAT_ISLAND_CLEARANCE
): { x: number; z: number } {
  let x = position.x;
  let z = position.z;
  for (const o of obstacles) {
    const dx = x - o.center.x;
    const dz = z - o.center.z;
    const safe = o.radius + clearance;
    const distSq = dx * dx + dz * dz;
    if (distSq >= safe * safe) continue;
    const dist = Math.sqrt(distSq);
    if (dist < 1e-6) {
      x = o.center.x + safe;
      z = o.center.z;
      continue;
    }
    const push = safe / dist;
    x = o.center.x + dx * push;
    z = o.center.z + dz * push;
  }
  return { x, z };
}
 
/**
 * The obstacle list used at runtime. Derived from the minor-island
 * archipelago so any future regeneration automatically flows through.
 */
const BOAT_OBSTACLES: readonly BoatObstacle[] = MINOR_ISLANDS.map((i) => ({
  center: { x: i.center[0], z: i.center[2] },
  radius: i.radius
}));
 
/**
 * Parameters for a single boat's orbit around the main island. Kept
 * plain data so the fleet configuration (see `BOAT_FLEET_CONFIG`)
 * stays readable and testable.
 */
export interface BoatOrbit {
  /** Orbit radius in world units. */
  radius: number;
  /** Angular speed in radians / second. Positive is counter-clockwise. */
  angularSpeed: number;
  /** Starting phase in radians. */
  phase: number;
  /** Sea-level y (roughly the water surface). Boat bobs around this. */
  baseY: number;
}
 
export const BOAT_COUNT = 4;
 
/**
 * Four boats spaced around four different orbit radii. Values chosen to
 * sit outside the main island ring (`MAIN_ISLAND_RADIUS + 6` or more)
 * and inside the scatter radius of the minor archipelago so they never
 * clip into land. Angular speeds are small enough that an on-screen
 * boat takes at least a minute to complete a lap, which reads as a
 * slow cruise rather than a pond-spin.
 */
export const BOAT_FLEET_CONFIG: readonly BoatOrbit[] = Object.freeze([
  { radius: MAIN_ISLAND_RADIUS + 7, angularSpeed: 0.09, phase: 0, baseY: -0.15 },
  {
    radius: MAIN_ISLAND_RADIUS + 12,
    angularSpeed: -0.06,
    phase: Math.PI * 0.5,
    baseY: -0.15
  },
  {
    radius: MAIN_ISLAND_RADIUS + 16,
    angularSpeed: 0.05,
    phase: Math.PI,
    baseY: -0.15
  },
  {
    radius: MAIN_ISLAND_RADIUS + 20,
    angularSpeed: -0.04,
    phase: Math.PI * 1.5,
    baseY: -0.15
  }
]);
 
/**
 * Pure helper - where does this boat sit at time `t`? Exported so the
 * orbit geometry can be unit tested without spinning up a renderer.
 *
 * Returns `{ position, tangent }` where `tangent` is the unit-length
 * world-space direction the boat is heading. The tangent is the
 * derivative of the orbit parametrisation, so it already includes the
 * sign of the angular speed.
 */
export function boatOrbitAt(
  orbit: BoatOrbit,
  t: number
): { position: [number, number, number]; tangent: [number, number, number] } {
  const angle = orbit.phase + orbit.angularSpeed * t;
  const x = Math.cos(angle) * orbit.radius;
  const z = Math.sin(angle) * orbit.radius;
  // Derivative of (cos,sin) wrt angle is (-sin, cos); multiply by
  // sign(angularSpeed) so the tangent points the direction of travel.
  const sign = Math.sign(orbit.angularSpeed) || 1;
  const tx = -Math.sin(angle) * sign;
  const tz = Math.cos(angle) * sign;
  const y = orbit.baseY + Math.sin(t * 0.9 + orbit.phase) * 0.05;
  return {
    position: [x, y, z],
    tangent: [tx, 0, tz]
  };
}
 
/**
 * A single boat mesh - hull, mast, triangular sail. Colours chosen to
 * match the existing warm-wood palette (signposts) and cloud-white.
 * Geometry dimensions are intentionally chunky so the silhouette reads
 * clearly from the default camera distance.
 */
function BoatMesh() {
  // Build the sail geometry once. Using BufferGeometry for the tri
  // avoids shipping yet another PlaneGeometry + tweaks and keeps the
  // triangle mathematically exact (no spurious subdivisions).
  const sailGeometry = useMemo(() => {
    const geo = new THREE.BufferGeometry();
    // Triangle with base at the bottom (y=0), apex at the top (y=1.6).
    // Extrusion in x is handled by scale on the mesh itself.
    const positions = new Float32Array([
      // front face
      -0.9, 0, 0, 0.9, 0, 0, 0, 1.6, 0,
      // back face (reverse winding so it shows from the other side)
      -0.9, 0, 0, 0, 1.6, 0, 0.9, 0, 0
    ]);
    geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    geo.computeVertexNormals();
    return geo;
  }, []);
 
  return (
    <group>
      {/* Hull - dark stained wood. */}
      <mesh position={[0, 0, 0]}>
        <boxGeometry args={[2, 0.4, 1]} />
        <meshStandardMaterial color="#6b4423" roughness={0.7} />
      </mesh>
      {/* Deck plank accent. */}
      <mesh position={[0, 0.22, 0]}>
        <boxGeometry args={[1.8, 0.05, 0.85]} />
        <meshStandardMaterial color="#8a5a2b" roughness={0.8} />
      </mesh>
      {/* Mast. */}
      <mesh position={[0, 0.8, 0]}>
        <boxGeometry args={[0.08, 1.7, 0.08]} />
        <meshStandardMaterial color="#4a2f15" roughness={0.85} />
      </mesh>
      {/* Sail - off-white triangle. The sail sits just in front of the
          mast and is double-sided so it is always visible regardless of
          orbit direction. */}
      <mesh position={[0, 0.2, 0.02]} geometry={sailGeometry}>
        <meshStandardMaterial color="#f4f1e8" side={THREE.DoubleSide} roughness={0.9} />
      </mesh>
    </group>
  );
}
 
/**
 * Fleet container - renders one boat per entry in
 * `BOAT_FLEET_CONFIG` and drives their motion in a single shared
 * `useFrame` so we never force a React re-render just to move a mesh.
 */
export function BoatFleet() {
  const groupsRef = useRef<Array<THREE.Group | null>>([]);
  // Scratch vectors reused every frame so we never allocate in the hot
  // path. `look` stores the computed `lookAt` target - the boat's own
  // position plus its tangent vector.
  const lookTarget = useMemo(() => new THREE.Vector3(), []);
 
  // Remember each boat's previous xz position so we can compute the
  // true heading after island avoidance, not the pure orbit tangent.
  // When the deflection changes the path mid-frame, the boat should
  // visually bank toward where it actually just moved.
  const prevXzRef = useRef<Array<{ x: number; z: number } | null>>([]);
 
  useFrame((state) => {
    const t = state.clock.elapsedTime;
    for (let i = 0; i < BOAT_FLEET_CONFIG.length; i++) {
      const orbit = BOAT_FLEET_CONFIG[i]!;
      const group = groupsRef.current[i];
      if (!group) continue;
      const { position, tangent } = boatOrbitAt(orbit, t);
      // Avoid minor islands by pushing the proposed orbit position out of
      // any obstacle safety disc. The y (bob) stays as computed by the
      // orbit - only the xz plane needs deflection.
      const safe = deflectAroundIslands({ x: position[0], z: position[2] }, BOAT_OBSTACLES);
      group.position.set(safe.x, position[1], safe.z);
 
      // Heading: prefer the actual frame-over-frame motion so the boat
      // banks with the deflection. On the first frame (no previous xz
      // yet) or if motion is vanishingly small, fall back to the orbit
      // tangent so the boat never faces a random direction.
      const prev = prevXzRef.current[i];
      let hx = tangent[0];
      let hz = tangent[2];
      if (prev) {
        const mx = safe.x - prev.x;
        const mz = safe.z - prev.z;
        if (mx * mx + mz * mz > 1e-6) {
          const len = Math.hypot(mx, mz);
          hx = mx / len;
          hz = mz / len;
        }
      }
      prevXzRef.current[i] = { x: safe.x, z: safe.z };
 
      lookTarget.set(safe.x + hx, position[1], safe.z + hz);
      group.lookAt(lookTarget);
      // Gentle pitch/roll - small sinusoidal tilt around the local
      // x-axis (pitch) and z-axis (roll). The existing `lookAt` sets
      // the Y rotation; we layer the extra axes on top.
      group.rotation.x = Math.sin(t * 1.1 + orbit.phase) * 0.05;
      group.rotation.z = Math.cos(t * 0.9 + orbit.phase * 0.5) * 0.04;
    }
  });
 
  return (
    <group>
      {BOAT_FLEET_CONFIG.map((orbit, i) => (
        <group
          key={i}
          ref={(el) => {
            groupsRef.current[i] = el;
          }}
        >
          <BoatMesh />
        </group>
      ))}
    </group>
  );
}