@@ -1,4 +1,4 @@ |
| 1 | | -// Koi fish - rapid flickering swimmers that react to bread |
| 1 | +// Koi fish - natural swimmers that react to bread |
| 2 | 2 | import * as THREE from 'three' |
| 3 | 3 | |
| 4 | 4 | export function createKoiSchool(scene, gradientMap, pondRadius) { |
@@ -10,7 +10,7 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 10 | 10 | const koiColors = [ |
| 11 | 11 | { body: 0xff6b35, spots: 0xffffff }, // Orange with white |
| 12 | 12 | { body: 0xffffff, spots: 0xff4444 }, // White with red |
| 13 | | - { body: 0xffaa00, spots: 0x000000 }, // Gold with black |
| 13 | + { body: 0xffaa00, spots: 0x222222 }, // Gold with black |
| 14 | 14 | { body: 0xff3333, spots: 0xffffff }, // Red with white |
| 15 | 15 | { body: 0xffd700, spots: 0xff6600 }, // Golden |
| 16 | 16 | ] |
@@ -18,33 +18,26 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 18 | 18 | for (let i = 0; i < koiCount; i++) { |
| 19 | 19 | const koi = createKoi(gradientMap, koiColors[i % koiColors.length]) |
| 20 | 20 | |
| 21 | | - // Random starting position |
| 21 | + // Random starting position and direction |
| 22 | 22 | const angle = Math.random() * Math.PI * 2 |
| 23 | | - const dist = Math.random() * pondRadius * 0.7 + pondRadius * 0.1 |
| 23 | + const dist = Math.random() * pondRadius * 0.6 + pondRadius * 0.1 |
| 24 | + |
| 24 | 25 | koi.group.position.set( |
| 25 | 26 | Math.cos(angle) * dist, |
| 26 | | - -0.08, // Just below water surface |
| 27 | + -0.08, |
| 27 | 28 | Math.sin(angle) * dist |
| 28 | 29 | ) |
| 30 | + |
| 31 | + // Face a random direction to start |
| 29 | 32 | koi.group.rotation.y = Math.random() * Math.PI * 2 |
| 30 | 33 | |
| 31 | 34 | 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, |
| 35 | + speed: 0.3 + Math.random() * 0.3, |
| 36 | + turnRate: 0, // Current turning rate |
| 37 | + targetTurnRate: 0, |
| 38 | + turnTimer: Math.random() * 3, |
| 38 | 39 | 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 |
| 40 | + flickerPhase: Math.random() * Math.PI * 2 |
| 48 | 41 | } |
| 49 | 42 | |
| 50 | 43 | group.add(koi.group) |
@@ -66,71 +59,50 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 66 | 59 | gradientMap: gradientMap |
| 67 | 60 | }) |
| 68 | 61 | |
| 69 | | - // Body - elongated oval |
| 70 | | - const bodyGeom = new THREE.SphereGeometry(0.12, 6, 4) |
| 71 | | - bodyGeom.scale(2, 0.6, 0.8) |
| 62 | + // Body - elongated oval, facing +Z |
| 63 | + const bodyGeom = new THREE.SphereGeometry(0.1, 6, 4) |
| 64 | + bodyGeom.scale(0.7, 0.5, 1.8) // Long along Z |
| 72 | 65 | const body = new THREE.Mesh(bodyGeom, bodyMaterial) |
| 73 | 66 | koiGroup.add(body) |
| 74 | 67 | |
| 75 | | - // Head |
| 76 | | - const headGeom = new THREE.SphereGeometry(0.08, 5, 4) |
| 77 | | - headGeom.scale(1.2, 0.8, 1) |
| 68 | + // Head - at +Z end |
| 69 | + const headGeom = new THREE.SphereGeometry(0.07, 5, 4) |
| 70 | + headGeom.scale(0.9, 0.7, 1) |
| 78 | 71 | const head = new THREE.Mesh(headGeom, bodyMaterial) |
| 79 | | - head.position.set(0.2, 0, 0) |
| 72 | + head.position.set(0, 0.01, 0.18) |
| 80 | 73 | koiGroup.add(head) |
| 81 | 74 | |
| 82 | | - // Tail fin |
| 83 | | - const tailGeom = new THREE.ConeGeometry(0.08, 0.18, 4) |
| 75 | + // Tail fin - at -Z end |
| 76 | + const tailGeom = new THREE.ConeGeometry(0.06, 0.15, 4) |
| 77 | + tailGeom.rotateX(Math.PI / 2) // Point along Z |
| 84 | 78 | const tail = new THREE.Mesh(tailGeom, bodyMaterial) |
| 85 | | - tail.position.set(-0.28, 0, 0) |
| 86 | | - tail.rotation.z = Math.PI / 2 |
| 79 | + tail.position.set(0, 0, -0.22) |
| 87 | 80 | koiGroup.add(tail) |
| 88 | 81 | |
| 89 | | - // Spots (2-3 random spots) |
| 82 | + // Spots on top |
| 90 | 83 | const spotCount = 2 + Math.floor(Math.random() * 2) |
| 91 | 84 | for (let i = 0; i < spotCount; i++) { |
| 92 | | - const spotGeom = new THREE.SphereGeometry(0.04 + Math.random() * 0.03, 4, 3) |
| 85 | + const spotGeom = new THREE.SphereGeometry(0.03 + Math.random() * 0.02, 4, 3) |
| 93 | 86 | const spot = new THREE.Mesh(spotGeom, spotMaterial) |
| 94 | 87 | spot.position.set( |
| 95 | | - (Math.random() - 0.5) * 0.2, |
| 88 | + (Math.random() - 0.5) * 0.06, |
| 96 | 89 | 0.04, |
| 97 | | - (Math.random() - 0.5) * 0.08 |
| 90 | + (Math.random() - 0.5) * 0.12 |
| 98 | 91 | ) |
| 99 | | - spot.scale.y = 0.5 |
| 92 | + spot.scale.y = 0.4 |
| 100 | 93 | koiGroup.add(spot) |
| 101 | 94 | } |
| 102 | 95 | |
| 103 | 96 | // Dorsal fin |
| 104 | | - const dorsalGeom = new THREE.ConeGeometry(0.03, 0.08, 3) |
| 97 | + const dorsalGeom = new THREE.ConeGeometry(0.02, 0.06, 3) |
| 105 | 98 | const dorsal = new THREE.Mesh(dorsalGeom, bodyMaterial) |
| 106 | | - dorsal.position.set(-0.05, 0.06, 0) |
| 107 | | - dorsal.rotation.z = -0.3 |
| 99 | + dorsal.position.set(0, 0.055, -0.03) |
| 108 | 100 | koiGroup.add(dorsal) |
| 109 | 101 | |
| 110 | | - // Scale down the whole koi |
| 111 | | - koiGroup.scale.setScalar(0.8) |
| 102 | + // Scale the whole koi |
| 103 | + koiGroup.scale.setScalar(0.9) |
| 112 | 104 | |
| 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 |
| 105 | + return { group: koiGroup, tail } |
| 134 | 106 | } |
| 135 | 107 | |
| 136 | 108 | function triggerPanic(x, z) { |
@@ -141,25 +113,18 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 141 | 113 | ) |
| 142 | 114 | |
| 143 | 115 | 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 |
| 116 | + koi.state.panicTimer = 1 + Math.random() * 0.5 |
| 117 | + |
| 118 | + // Turn away from the disturbance |
| 119 | + const awayAngle = Math.atan2( |
| 120 | + koi.group.position.x - x, |
| 121 | + koi.group.position.z - z |
| 152 | 122 | ) |
| 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 | | - } |
| 123 | + // Set a strong turn toward the away direction |
| 124 | + let turnNeeded = awayAngle - koi.group.rotation.y |
| 125 | + while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2 |
| 126 | + while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2 |
| 127 | + koi.state.targetTurnRate = Math.sign(turnNeeded) * 3 |
| 163 | 128 | } |
| 164 | 129 | } |
| 165 | 130 | } |
@@ -167,104 +132,65 @@ export function createKoiSchool(scene, gradientMap, pondRadius) { |
| 167 | 132 | function update(delta, elapsed) { |
| 168 | 133 | for (const koi of kois) { |
| 169 | 134 | const s = koi.state |
| 135 | + const pos = koi.group.position |
| 170 | 136 | |
| 171 | | - // Update panic timer |
| 172 | | - if (s.panicMode) { |
| 137 | + // Update panic |
| 138 | + const isPanicked = s.panicTimer > 0 |
| 139 | + if (isPanicked) { |
| 173 | 140 | s.panicTimer -= delta |
| 174 | | - if (s.panicTimer <= 0) { |
| 175 | | - s.panicMode = false |
| 176 | | - s.speed = s.baseSpeed |
| 177 | | - } |
| 178 | 141 | } |
| 179 | 142 | |
| 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 |
| 143 | + // Decide turning behavior |
| 144 | + s.turnTimer -= delta |
| 145 | + if (s.turnTimer <= 0 && !isPanicked) { |
| 146 | + // Occasionally change turn rate for natural wandering |
| 147 | + s.targetTurnRate = (Math.random() - 0.5) * 1.5 |
| 148 | + s.turnTimer = 1 + Math.random() * 3 |
| 191 | 149 | } |
| 192 | 150 | |
| 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 |
| 151 | + // Check if heading toward pond edge |
| 152 | + const distFromCenter = Math.hypot(pos.x, pos.z) |
| 153 | + if (distFromCenter > pondRadius * 0.75) { |
| 154 | + // Calculate angle to center |
| 155 | + const toCenter = Math.atan2(-pos.x, -pos.z) |
| 156 | + let turnNeeded = toCenter - koi.group.rotation.y |
| 157 | + while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2 |
| 158 | + while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2 |
| 159 | + |
| 160 | + // Steer back toward center |
| 161 | + s.targetTurnRate = Math.sign(turnNeeded) * 1.5 |
| 218 | 162 | } |
| 219 | 163 | |
| 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 |
| 164 | + // Smooth turn rate changes |
| 165 | + s.turnRate += (s.targetTurnRate - s.turnRate) * delta * 2 |
| 248 | 166 | |
| 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 | | - } |
| 167 | + // Apply rotation |
| 168 | + koi.group.rotation.y += s.turnRate * delta |
| 258 | 169 | |
| 259 | | - // Gentle depth variation - natural swimming motion |
| 260 | | - koi.group.position.y = -0.08 + Math.sin(elapsed * 3 + s.flickerPhase) * 0.01 |
| 170 | + // Move forward (in the direction the fish is facing, which is +Z in local space) |
| 171 | + const speed = isPanicked ? s.speed * 2.5 : s.speed |
| 261 | 172 | |
| 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 |
| 173 | + // Get forward direction from rotation |
| 174 | + const forwardX = Math.sin(koi.group.rotation.y) |
| 175 | + const forwardZ = Math.cos(koi.group.rotation.y) |
| 176 | + |
| 177 | + pos.x += forwardX * speed * delta |
| 178 | + pos.z += forwardZ * speed * delta |
| 179 | + |
| 180 | + // Tail wiggle - faster when moving fast |
| 181 | + const wiggleSpeed = isPanicked ? 18 : 10 |
| 182 | + const wiggleAmount = isPanicked ? 0.4 : 0.25 |
| 183 | + koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount |
| 184 | + |
| 185 | + // Gentle vertical bob |
| 186 | + pos.y = -0.08 + Math.sin(elapsed * 2.5 + s.flickerPhase) * 0.008 |
| 187 | + |
| 188 | + // Hard clamp to pond bounds |
| 189 | + const currentDist = Math.hypot(pos.x, pos.z) |
| 190 | + if (currentDist > pondRadius * 0.88) { |
| 191 | + const scale = (pondRadius * 0.85) / currentDist |
| 192 | + pos.x *= scale |
| 193 | + pos.z *= scale |
| 268 | 194 | } |
| 269 | 195 | } |
| 270 | 196 | } |