JavaScript · 8967 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 < 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 }