JavaScript · 9081 bytes Raw Blame History
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 < 2) {
144 koi.state.panicMode = true
145 koi.state.panicTimer = 1.5 + Math.random() * 1
146 koi.state.speed = 4 + Math.random() * 2
147
148 // Flee away from the bread
149 const fleeAngle = Math.atan2(
150 koi.group.position.z - z,
151 koi.group.position.x - x
152 )
153 const fleeDist = pondRadius * 0.6 + Math.random() * pondRadius * 0.2
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
188 koi.tail.rotation.y = Math.sin(elapsed * 4 + s.flickerPhase) * 0.2
189 koi.group.position.y = -0.1 + Math.sin(elapsed * 3 + s.flickerPhase) * 0.01
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.15) {
198 s.isIdle = true
199 s.idleTimer = 1 + Math.random() * 3
200 s.wanderTimer = 0.5
201 continue
202 }
203
204 // Sometimes follow another koi (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're going, with some offset
209 s.targetX = otherKoi.state.targetX + (Math.random() - 0.5) * 1.5
210 s.targetZ = otherKoi.state.targetZ + (Math.random() - 0.5) * 1.5
211 }
212 } else {
213 pickNewTarget(koi, pondRadius)
214 }
215
216 // Restless koi change direction more often
217 s.wanderTimer = (1 + Math.random() * 3) / s.restlessness
218 }
219
220 // Move toward target
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 if (dist > 0.15) {
226 // Calculate target rotation
227 const targetRot = Math.atan2(dx, dz)
228
229 // Smooth rotation - varies by individual
230 let rotDiff = targetRot - koi.group.rotation.y
231 while (rotDiff > Math.PI) rotDiff -= Math.PI * 2
232 while (rotDiff < -Math.PI) rotDiff += Math.PI * 2
233 koi.group.rotation.y += rotDiff * s.turnSpeed * delta
234
235 // Move forward - speed varies
236 const moveSpeed = s.panicMode ? s.speed * 2.5 : s.speed * (0.4 + s.restlessness * 0.4)
237 const moveX = Math.sin(koi.group.rotation.y) * moveSpeed * delta
238 const moveZ = Math.cos(koi.group.rotation.y) * moveSpeed * delta
239 koi.group.position.x += moveX
240 koi.group.position.z += moveZ
241
242 // Tail wiggle - faster when moving fast
243 const wiggleSpeed = s.panicMode ? 25 : 8 + s.restlessness * 8
244 koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * 0.5
245 } else {
246 // Reached target - maybe idle, maybe pick new target
247 if (Math.random() < 0.3) {
248 s.isIdle = true
249 s.idleTimer = 0.5 + Math.random() * 2
250 } else {
251 pickNewTarget(koi, pondRadius)
252 }
253 }
254
255 // Flicker/shimmer effect - slight Y oscillation
256 koi.group.position.y = -0.08 + Math.sin(elapsed * 8 + s.flickerPhase) * 0.015
257
258 // Erratic depth changes when panicked
259 if (s.panicMode) {
260 koi.group.position.y += Math.sin(elapsed * 20 + s.flickerPhase) * 0.03
261 // Random direction jitters
262 if (Math.random() < delta * 5) {
263 koi.group.rotation.y += (Math.random() - 0.5) * 0.8
264 pickNewTarget(koi, pondRadius)
265 }
266 }
267
268 // Keep in pond bounds
269 const currentDist = Math.hypot(koi.group.position.x, koi.group.position.z)
270 if (currentDist > pondRadius * 0.9) {
271 const scale = (pondRadius * 0.85) / currentDist
272 koi.group.position.x *= scale
273 koi.group.position.z *= scale
274 // Turn back toward center
275 pickNewTarget(koi, pondRadius * 0.5)
276 }
277 }
278 }
279
280 return {
281 group,
282 update,
283 triggerPanic,
284 getKois: () => kois.map(k => k.group)
285 }
286 }