| 1 | // Koi fish - rapid flickering swimmers that react to bread |
| 2 | import * as THREE from 'three' |
| 3 | |
| 4 | export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 5 | const group = new THREE.Group() |
| 6 | const kois = [] |
| 7 | const koiCount = 5 |
| 8 | |
| 9 | // Koi color variations |
| 10 | const koiColors = [ |
| 11 | { body: 0xff6b35, spots: 0xffffff }, // Orange with white |
| 12 | { body: 0xffffff, spots: 0xff4444 }, // White with red |
| 13 | { body: 0xffaa00, spots: 0x000000 }, // Gold with black |
| 14 | { body: 0xff3333, spots: 0xffffff }, // Red with white |
| 15 | { body: 0xffd700, spots: 0xff6600 }, // Golden |
| 16 | ] |
| 17 | |
| 18 | for (let i = 0; i < koiCount; i++) { |
| 19 | const koi = createKoi(gradientMap, koiColors[i % koiColors.length]) |
| 20 | |
| 21 | // Random starting position |
| 22 | const angle = Math.random() * Math.PI * 2 |
| 23 | const dist = Math.random() * pondRadius * 0.7 + pondRadius * 0.1 |
| 24 | koi.group.position.set( |
| 25 | Math.cos(angle) * dist, |
| 26 | -0.08, // Just below water surface |
| 27 | Math.sin(angle) * dist |
| 28 | ) |
| 29 | koi.group.rotation.y = Math.random() * Math.PI * 2 |
| 30 | |
| 31 | koi.state = { |
| 32 | targetX: koi.group.position.x, |
| 33 | targetZ: koi.group.position.z, |
| 34 | baseSpeed: 0.8 + Math.random() * 1.2, |
| 35 | speed: 0.8 + Math.random() * 1.2, |
| 36 | turnSpeed: 2 + Math.random() * 3, |
| 37 | flickerPhase: Math.random() * Math.PI * 2, |
| 38 | panicTimer: 0, |
| 39 | panicMode: false, |
| 40 | wanderTimer: Math.random() * 3, |
| 41 | // Independent personality |
| 42 | restlessness: 0.3 + Math.random() * 0.7, // How often they change direction |
| 43 | curiosity: Math.random(), // Likelihood to investigate bread vs flee |
| 44 | sociability: Math.random() * 0.5, // How much they follow others |
| 45 | idleTimer: 0, |
| 46 | isIdle: false, |
| 47 | idleDuration: 0 |
| 48 | } |
| 49 | |
| 50 | group.add(koi.group) |
| 51 | kois.push(koi) |
| 52 | } |
| 53 | |
| 54 | scene.add(group) |
| 55 | |
| 56 | function createKoi(gradientMap, colors) { |
| 57 | const koiGroup = new THREE.Group() |
| 58 | |
| 59 | const bodyMaterial = new THREE.MeshToonMaterial({ |
| 60 | color: colors.body, |
| 61 | gradientMap: gradientMap |
| 62 | }) |
| 63 | |
| 64 | const spotMaterial = new THREE.MeshToonMaterial({ |
| 65 | color: colors.spots, |
| 66 | gradientMap: gradientMap |
| 67 | }) |
| 68 | |
| 69 | // Body - elongated oval |
| 70 | const bodyGeom = new THREE.SphereGeometry(0.12, 6, 4) |
| 71 | bodyGeom.scale(2, 0.6, 0.8) |
| 72 | const body = new THREE.Mesh(bodyGeom, bodyMaterial) |
| 73 | koiGroup.add(body) |
| 74 | |
| 75 | // Head |
| 76 | const headGeom = new THREE.SphereGeometry(0.08, 5, 4) |
| 77 | headGeom.scale(1.2, 0.8, 1) |
| 78 | const head = new THREE.Mesh(headGeom, bodyMaterial) |
| 79 | head.position.set(0.2, 0, 0) |
| 80 | koiGroup.add(head) |
| 81 | |
| 82 | // Tail fin |
| 83 | const tailGeom = new THREE.ConeGeometry(0.08, 0.18, 4) |
| 84 | const tail = new THREE.Mesh(tailGeom, bodyMaterial) |
| 85 | tail.position.set(-0.28, 0, 0) |
| 86 | tail.rotation.z = Math.PI / 2 |
| 87 | koiGroup.add(tail) |
| 88 | |
| 89 | // Spots (2-3 random spots) |
| 90 | const spotCount = 2 + Math.floor(Math.random() * 2) |
| 91 | for (let i = 0; i < spotCount; i++) { |
| 92 | const spotGeom = new THREE.SphereGeometry(0.04 + Math.random() * 0.03, 4, 3) |
| 93 | const spot = new THREE.Mesh(spotGeom, spotMaterial) |
| 94 | spot.position.set( |
| 95 | (Math.random() - 0.5) * 0.2, |
| 96 | 0.04, |
| 97 | (Math.random() - 0.5) * 0.08 |
| 98 | ) |
| 99 | spot.scale.y = 0.5 |
| 100 | koiGroup.add(spot) |
| 101 | } |
| 102 | |
| 103 | // Dorsal fin |
| 104 | const dorsalGeom = new THREE.ConeGeometry(0.03, 0.08, 3) |
| 105 | const dorsal = new THREE.Mesh(dorsalGeom, bodyMaterial) |
| 106 | dorsal.position.set(-0.05, 0.06, 0) |
| 107 | dorsal.rotation.z = -0.3 |
| 108 | koiGroup.add(dorsal) |
| 109 | |
| 110 | // Scale down the whole koi |
| 111 | koiGroup.scale.setScalar(0.8) |
| 112 | |
| 113 | return { group: koiGroup, body, tail } |
| 114 | } |
| 115 | |
| 116 | function pickNewTarget(koi, pondRadius, avoidX, avoidZ) { |
| 117 | let attempts = 0 |
| 118 | let x, z |
| 119 | |
| 120 | do { |
| 121 | const angle = Math.random() * Math.PI * 2 |
| 122 | const dist = Math.random() * pondRadius * 0.7 + pondRadius * 0.1 |
| 123 | x = Math.cos(angle) * dist |
| 124 | z = Math.sin(angle) * dist |
| 125 | attempts++ |
| 126 | } while ( |
| 127 | avoidX !== undefined && |
| 128 | Math.hypot(x - avoidX, z - avoidZ) < 1.5 && |
| 129 | attempts < 10 |
| 130 | ) |
| 131 | |
| 132 | koi.state.targetX = x |
| 133 | koi.state.targetZ = z |
| 134 | } |
| 135 | |
| 136 | function triggerPanic(x, z) { |
| 137 | for (const koi of kois) { |
| 138 | const dist = Math.hypot( |
| 139 | koi.group.position.x - x, |
| 140 | koi.group.position.z - z |
| 141 | ) |
| 142 | |
| 143 | if (dist < 1.5) { |
| 144 | koi.state.panicMode = true |
| 145 | koi.state.panicTimer = 0.8 + Math.random() * 0.6 // Shorter panic |
| 146 | koi.state.speed = koi.state.baseSpeed * 2 // Just double speed, not crazy fast |
| 147 | |
| 148 | // Flee away from the bread - but keep it smooth |
| 149 | const fleeAngle = Math.atan2( |
| 150 | koi.group.position.z - z, |
| 151 | koi.group.position.x - x |
| 152 | ) |
| 153 | const fleeDist = pondRadius * 0.5 + Math.random() * pondRadius * 0.3 |
| 154 | koi.state.targetX = Math.cos(fleeAngle) * fleeDist |
| 155 | koi.state.targetZ = Math.sin(fleeAngle) * fleeDist |
| 156 | |
| 157 | // Clamp to pond |
| 158 | const targetDist = Math.hypot(koi.state.targetX, koi.state.targetZ) |
| 159 | if (targetDist > pondRadius * 0.85) { |
| 160 | koi.state.targetX *= (pondRadius * 0.85) / targetDist |
| 161 | koi.state.targetZ *= (pondRadius * 0.85) / targetDist |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | function update(delta, elapsed) { |
| 168 | for (const koi of kois) { |
| 169 | const s = koi.state |
| 170 | |
| 171 | // Update panic timer |
| 172 | if (s.panicMode) { |
| 173 | s.panicTimer -= delta |
| 174 | if (s.panicTimer <= 0) { |
| 175 | s.panicMode = false |
| 176 | s.speed = s.baseSpeed |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // Idle behavior - sometimes koi just stop and chill |
| 181 | if (s.isIdle) { |
| 182 | s.idleTimer -= delta |
| 183 | if (s.idleTimer <= 0) { |
| 184 | s.isIdle = false |
| 185 | pickNewTarget(koi, pondRadius) |
| 186 | } |
| 187 | // Gentle drifting while idle - still wiggle tail slowly |
| 188 | koi.tail.rotation.y = Math.sin(elapsed * 3 + s.flickerPhase) * 0.15 |
| 189 | koi.group.position.y = -0.1 + Math.sin(elapsed * 2 + s.flickerPhase) * 0.008 |
| 190 | continue |
| 191 | } |
| 192 | |
| 193 | // Wander behavior - each koi has its own rhythm |
| 194 | s.wanderTimer -= delta |
| 195 | if (s.wanderTimer <= 0 && !s.panicMode) { |
| 196 | // Random chance to go idle |
| 197 | if (Math.random() < 0.2) { |
| 198 | s.isIdle = true |
| 199 | s.idleTimer = 2 + Math.random() * 4 |
| 200 | s.wanderTimer = 0.5 |
| 201 | continue |
| 202 | } |
| 203 | |
| 204 | // Sometimes follow another koi loosely (if sociable) |
| 205 | if (Math.random() < s.sociability && kois.length > 1) { |
| 206 | const otherKoi = kois[Math.floor(Math.random() * kois.length)] |
| 207 | if (otherKoi !== koi) { |
| 208 | // Head toward where they are, with some offset |
| 209 | s.targetX = otherKoi.group.position.x + (Math.random() - 0.5) * 2 |
| 210 | s.targetZ = otherKoi.group.position.z + (Math.random() - 0.5) * 2 |
| 211 | } |
| 212 | } else { |
| 213 | pickNewTarget(koi, pondRadius) |
| 214 | } |
| 215 | |
| 216 | // Longer wander intervals for more natural movement |
| 217 | s.wanderTimer = 2 + Math.random() * 4 |
| 218 | } |
| 219 | |
| 220 | // Move toward target - smooth, natural swimming |
| 221 | const dx = s.targetX - koi.group.position.x |
| 222 | const dz = s.targetZ - koi.group.position.z |
| 223 | const dist = Math.hypot(dx, dz) |
| 224 | |
| 225 | // Calculate target rotation - koi model faces +X, so use atan2(dz, dx) |
| 226 | const targetRot = Math.atan2(dz, dx) |
| 227 | |
| 228 | // Very smooth rotation - fish don't turn sharply |
| 229 | let rotDiff = targetRot - koi.group.rotation.y |
| 230 | while (rotDiff > Math.PI) rotDiff -= Math.PI * 2 |
| 231 | while (rotDiff < -Math.PI) rotDiff += Math.PI * 2 |
| 232 | |
| 233 | // Slower turn rate for natural movement |
| 234 | const turnRate = s.panicMode ? 2.5 : 1.2 |
| 235 | koi.group.rotation.y += rotDiff * turnRate * delta |
| 236 | |
| 237 | // Move in the direction koi is facing (model faces +X, so use cos/sin) |
| 238 | const moveSpeed = s.panicMode ? s.speed * 1.8 : s.speed * 0.5 |
| 239 | const moveX = Math.cos(koi.group.rotation.y) * moveSpeed * delta |
| 240 | const moveZ = Math.sin(koi.group.rotation.y) * moveSpeed * delta |
| 241 | koi.group.position.x += moveX |
| 242 | koi.group.position.z += moveZ |
| 243 | |
| 244 | // Tail wiggle - proportional to speed |
| 245 | const wiggleSpeed = s.panicMode ? 15 : 8 |
| 246 | const wiggleAmount = s.panicMode ? 0.4 : 0.3 |
| 247 | koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount |
| 248 | |
| 249 | // Reached close to target - pick new one |
| 250 | if (dist < 0.3) { |
| 251 | if (Math.random() < 0.25) { |
| 252 | s.isIdle = true |
| 253 | s.idleTimer = 1 + Math.random() * 3 |
| 254 | } else { |
| 255 | pickNewTarget(koi, pondRadius) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // Gentle depth variation - natural swimming motion |
| 260 | koi.group.position.y = -0.08 + Math.sin(elapsed * 3 + s.flickerPhase) * 0.01 |
| 261 | |
| 262 | // Keep in pond bounds - smooth turnaround |
| 263 | const currentDist = Math.hypot(koi.group.position.x, koi.group.position.z) |
| 264 | if (currentDist > pondRadius * 0.85) { |
| 265 | // Steer back toward center |
| 266 | s.targetX = (Math.random() - 0.5) * pondRadius * 0.5 |
| 267 | s.targetZ = (Math.random() - 0.5) * pondRadius * 0.5 |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | return { |
| 273 | group, |
| 274 | update, |
| 275 | triggerPanic, |
| 276 | getKois: () => kois.map(k => k.group) |
| 277 | } |
| 278 | } |