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