JavaScript · 12787 bytes Raw Blame History
1 // Koi fish - natural swimmers that are attracted to bread and can be captured
2 import * as THREE from 'three'
3 import gameState from './gameState.js'
4 import { playCapture } from './sounds.js'
5
6 export function createKoiSchool(scene, gradientMap, pondRadius) {
7 const group = new THREE.Group()
8 const kois = []
9 const koiCount = 5
10
11 // Sparkle particles for capture effect
12 const sparkles = []
13 const sparkleMaterial = new THREE.MeshBasicMaterial({
14 color: 0xffd700,
15 transparent: true
16 })
17
18 function createCaptureSparkles(x, y, z) {
19 const particleCount = 12
20 for (let i = 0; i < particleCount; i++) {
21 const angle = (i / particleCount) * Math.PI * 2
22 const upAngle = Math.random() * Math.PI * 0.5
23
24 const sparkleGeom = new THREE.OctahedronGeometry(0.06)
25 const sparkle = new THREE.Mesh(sparkleGeom, sparkleMaterial.clone())
26 sparkle.position.set(x, y + 0.1, z)
27
28 // Random velocity outward and upward
29 const speed = 1.5 + Math.random() * 1
30 sparkle.userData = {
31 vx: Math.cos(angle) * Math.cos(upAngle) * speed,
32 vy: Math.sin(upAngle) * speed + 1,
33 vz: Math.sin(angle) * Math.cos(upAngle) * speed,
34 age: 0,
35 maxAge: 0.6 + Math.random() * 0.3,
36 rotSpeed: (Math.random() - 0.5) * 10
37 }
38
39 group.add(sparkle)
40 sparkles.push(sparkle)
41 }
42 }
43
44 function updateSparkles(delta) {
45 for (let i = sparkles.length - 1; i >= 0; i--) {
46 const sparkle = sparkles[i]
47 const d = sparkle.userData
48
49 d.age += delta
50 const progress = d.age / d.maxAge
51
52 // Move
53 sparkle.position.x += d.vx * delta
54 sparkle.position.y += d.vy * delta
55 sparkle.position.z += d.vz * delta
56
57 // Gravity
58 d.vy -= 4 * delta
59
60 // Spin
61 sparkle.rotation.x += d.rotSpeed * delta
62 sparkle.rotation.y += d.rotSpeed * delta
63
64 // Fade and shrink
65 sparkle.material.opacity = 1 - progress
66 sparkle.scale.setScalar(1 - progress * 0.5)
67
68 // Remove when done
69 if (d.age >= d.maxAge) {
70 group.remove(sparkle)
71 sparkle.geometry.dispose()
72 sparkle.material.dispose()
73 sparkles.splice(i, 1)
74 }
75 }
76 }
77
78 // Koi color variations
79 const koiColors = [
80 { body: 0xff6b35, spots: 0xffffff }, // Orange with white
81 { body: 0xffffff, spots: 0xff4444 }, // White with red
82 { body: 0xffaa00, spots: 0x222222 }, // Gold with black
83 { body: 0xff3333, spots: 0xffffff }, // Red with white
84 { body: 0xffd700, spots: 0xff6600 }, // Golden
85 ]
86
87 for (let i = 0; i < koiCount; i++) {
88 const koi = createKoi(gradientMap, koiColors[i % koiColors.length])
89
90 // Random starting position and direction
91 const angle = Math.random() * Math.PI * 2
92 const dist = Math.random() * pondRadius * 0.6 + pondRadius * 0.1
93
94 koi.group.position.set(
95 Math.cos(angle) * dist,
96 -0.08,
97 Math.sin(angle) * dist
98 )
99
100 // Face a random direction to start
101 koi.group.rotation.y = Math.random() * Math.PI * 2
102
103 koi.state = {
104 speed: 0.3 + Math.random() * 0.3,
105 turnRate: 0,
106 targetTurnRate: 0,
107 turnTimer: Math.random() * 3,
108 flickerPhase: Math.random() * Math.PI * 2,
109 // Attraction state
110 attractedTo: null, // { x, z } of bread attracting this koi
111 // Capture state
112 captured: false,
113 beingCaptured: false,
114 captureProgress: 0,
115 respawnTimer: 0,
116 originalScale: 0.9
117 }
118
119 group.add(koi.group)
120 kois.push(koi)
121 }
122
123 scene.add(group)
124
125 function createKoi(gradientMap, colors) {
126 const koiGroup = new THREE.Group()
127
128 const bodyMaterial = new THREE.MeshToonMaterial({
129 color: colors.body,
130 gradientMap: gradientMap
131 })
132
133 const spotMaterial = new THREE.MeshToonMaterial({
134 color: colors.spots,
135 gradientMap: gradientMap
136 })
137
138 // Body - elongated oval, facing +Z
139 const bodyGeom = new THREE.SphereGeometry(0.1, 6, 4)
140 bodyGeom.scale(0.7, 0.5, 1.8) // Long along Z
141 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
142 koiGroup.add(body)
143
144 // Head - at +Z end
145 const headGeom = new THREE.SphereGeometry(0.07, 5, 4)
146 headGeom.scale(0.9, 0.7, 1)
147 const head = new THREE.Mesh(headGeom, bodyMaterial)
148 head.position.set(0, 0.01, 0.18)
149 koiGroup.add(head)
150
151 // Tail fin - at -Z end
152 const tailGeom = new THREE.ConeGeometry(0.06, 0.15, 4)
153 tailGeom.rotateX(Math.PI / 2) // Point along Z
154 const tail = new THREE.Mesh(tailGeom, bodyMaterial)
155 tail.position.set(0, 0, -0.22)
156 koiGroup.add(tail)
157
158 // Spots on top
159 const spotCount = 2 + Math.floor(Math.random() * 2)
160 for (let i = 0; i < spotCount; i++) {
161 const spotGeom = new THREE.SphereGeometry(0.03 + Math.random() * 0.02, 4, 3)
162 const spot = new THREE.Mesh(spotGeom, spotMaterial)
163 spot.position.set(
164 (Math.random() - 0.5) * 0.06,
165 0.04,
166 (Math.random() - 0.5) * 0.12
167 )
168 spot.scale.y = 0.4
169 koiGroup.add(spot)
170 }
171
172 // Dorsal fin
173 const dorsalGeom = new THREE.ConeGeometry(0.02, 0.06, 3)
174 const dorsal = new THREE.Mesh(dorsalGeom, bodyMaterial)
175 dorsal.position.set(0, 0.055, -0.03)
176 koiGroup.add(dorsal)
177
178 // Scale the whole koi
179 koiGroup.scale.setScalar(0.9)
180
181 return { group: koiGroup, tail }
182 }
183
184 // Attract koi to nearby bread - replaces panic behavior
185 function attractToBread(breadBits) {
186 for (const koi of kois) {
187 if (koi.state.captured || koi.state.beingCaptured) continue
188
189 // Find nearest bread
190 let nearestBread = null
191 let nearestDist = 2.5 // Attraction radius
192
193 for (const bread of breadBits) {
194 const dist = Math.hypot(
195 koi.group.position.x - bread.x,
196 koi.group.position.z - bread.z
197 )
198 if (dist < nearestDist) {
199 nearestDist = dist
200 nearestBread = bread
201 }
202 }
203
204 koi.state.attractedTo = nearestBread
205 }
206 }
207
208 // Legacy panic trigger - keep for ripple effects but make koi scatter briefly
209 function triggerPanic(x, z) {
210 // Now just a brief scatter, not sustained panic
211 for (const koi of kois) {
212 if (koi.state.captured || koi.state.beingCaptured) continue
213
214 const dist = Math.hypot(
215 koi.group.position.x - x,
216 koi.group.position.z - z
217 )
218
219 if (dist < 0.8) {
220 // Brief scatter only when bread lands very close
221 koi.state.attractedTo = null
222 // Turn slightly away then resume
223 const awayAngle = Math.atan2(
224 koi.group.position.x - x,
225 koi.group.position.z - z
226 )
227 let turnNeeded = awayAngle - koi.group.rotation.y
228 while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
229 while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
230 koi.state.targetTurnRate = Math.sign(turnNeeded) * 2
231 koi.state.turnTimer = 0.3 // Brief scatter
232 }
233 }
234 }
235
236 // Get koi under a given position (for capture detection)
237 function getKoiUnderPosition(x, z, radius = 0.3) {
238 for (const koi of kois) {
239 if (koi.state.captured || koi.state.beingCaptured) continue
240
241 const dist = Math.hypot(
242 koi.group.position.x - x,
243 koi.group.position.z - z
244 )
245
246 if (dist < radius) {
247 return koi
248 }
249 }
250 return null
251 }
252
253 // Start capturing a koi
254 function startCapture(koi) {
255 if (!koi || koi.state.captured || koi.state.beingCaptured) return false
256 koi.state.beingCaptured = true
257 koi.state.captureProgress = 0
258 koi.state.attractedTo = null
259 return true
260 }
261
262 // Update capture progress - returns true if capture completes
263 function updateCapture(koi, delta) {
264 if (!koi || !koi.state.beingCaptured) return false
265
266 koi.state.captureProgress += delta / 0.8 // 0.8 seconds to capture
267
268 // Scale down and wiggle during capture
269 const scale = koi.state.originalScale * (1 - koi.state.captureProgress * 0.3)
270 koi.group.scale.setScalar(Math.max(scale, 0.4))
271
272 // Wiggle/struggle effect
273 koi.group.rotation.z = Math.sin(koi.state.captureProgress * 20) * 0.3
274
275 if (koi.state.captureProgress >= 1) {
276 completeCapture(koi)
277 return true
278 }
279 return false
280 }
281
282 // Cancel capture in progress
283 function cancelCapture(koi) {
284 if (!koi || !koi.state.beingCaptured) return
285 koi.state.beingCaptured = false
286 koi.state.captureProgress = 0
287 koi.group.scale.setScalar(koi.state.originalScale)
288 koi.group.rotation.z = 0
289 }
290
291 // Complete capture - hide koi and schedule respawn
292 function completeCapture(koi) {
293 // Spawn sparkles at koi position before hiding
294 const pos = koi.group.position
295 createCaptureSparkles(pos.x, pos.y, pos.z)
296
297 koi.state.captured = true
298 koi.state.beingCaptured = false
299 koi.state.captureProgress = 0
300 koi.group.visible = false
301
302 // Schedule respawn
303 koi.state.respawnTimer = 8 + Math.random() * 12 // 8-20 seconds
304
305 // Add to game state and play sound
306 gameState.addKoi(1)
307 playCapture()
308 }
309
310 // Respawn a captured koi
311 function respawnKoi(koi) {
312 koi.state.captured = false
313 koi.group.visible = true
314 koi.group.scale.setScalar(koi.state.originalScale)
315 koi.group.rotation.z = 0
316
317 // Random new position
318 const angle = Math.random() * Math.PI * 2
319 const dist = Math.random() * pondRadius * 0.6 + pondRadius * 0.1
320 koi.group.position.set(
321 Math.cos(angle) * dist,
322 -0.08,
323 Math.sin(angle) * dist
324 )
325 koi.group.rotation.y = Math.random() * Math.PI * 2
326 }
327
328 function update(delta, elapsed) {
329 for (const koi of kois) {
330 const s = koi.state
331 const pos = koi.group.position
332
333 // Handle respawn timer
334 if (s.captured) {
335 s.respawnTimer -= delta
336 if (s.respawnTimer <= 0) {
337 respawnKoi(koi)
338 }
339 continue
340 }
341
342 // Skip movement if being captured
343 if (s.beingCaptured) {
344 continue
345 }
346
347 // Decide turning behavior
348 s.turnTimer -= delta
349
350 // If attracted to bread, swim toward it
351 if (s.attractedTo) {
352 const toBreakX = s.attractedTo.x - pos.x
353 const toBreadZ = s.attractedTo.z - pos.z
354 const distToBread = Math.hypot(toBreakX, toBreadZ)
355
356 if (distToBread < 0.15) {
357 // Very close to bread - slow down and circle
358 s.targetTurnRate = 0.3
359 s.speed = 0.1
360 } else {
361 // Swim toward bread
362 const toBreadAngle = Math.atan2(toBreakX, toBreadZ)
363 let turnNeeded = toBreadAngle - koi.group.rotation.y
364 while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
365 while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
366
367 s.targetTurnRate = Math.sign(turnNeeded) * Math.min(Math.abs(turnNeeded) * 2, 2)
368 s.speed = 0.4 + Math.min(distToBread * 0.2, 0.3) // Faster when far
369 }
370 } else {
371 // Natural wandering behavior
372 if (s.turnTimer <= 0) {
373 s.targetTurnRate = (Math.random() - 0.5) * 1.5
374 s.turnTimer = 1 + Math.random() * 3
375 s.speed = 0.3 + Math.random() * 0.3
376 }
377 }
378
379 // Check if heading toward pond edge
380 const distFromCenter = Math.hypot(pos.x, pos.z)
381 if (distFromCenter > pondRadius * 0.75) {
382 const toCenter = Math.atan2(-pos.x, -pos.z)
383 let turnNeeded = toCenter - koi.group.rotation.y
384 while (turnNeeded > Math.PI) turnNeeded -= Math.PI * 2
385 while (turnNeeded < -Math.PI) turnNeeded += Math.PI * 2
386 s.targetTurnRate = Math.sign(turnNeeded) * 1.5
387 }
388
389 // Smooth turn rate changes
390 s.turnRate += (s.targetTurnRate - s.turnRate) * delta * 2
391
392 // Apply rotation
393 koi.group.rotation.y += s.turnRate * delta
394
395 // Move forward
396 const forwardX = Math.sin(koi.group.rotation.y)
397 const forwardZ = Math.cos(koi.group.rotation.y)
398 pos.x += forwardX * s.speed * delta
399 pos.z += forwardZ * s.speed * delta
400
401 // Tail wiggle
402 const wiggleSpeed = s.attractedTo ? 14 : 10
403 const wiggleAmount = s.attractedTo ? 0.35 : 0.25
404 koi.tail.rotation.y = Math.sin(elapsed * wiggleSpeed + s.flickerPhase) * wiggleAmount
405
406 // Gentle vertical bob
407 pos.y = -0.08 + Math.sin(elapsed * 2.5 + s.flickerPhase) * 0.008
408
409 // Hard clamp to pond bounds
410 const currentDist = Math.hypot(pos.x, pos.z)
411 if (currentDist > pondRadius * 0.88) {
412 const scale = (pondRadius * 0.85) / currentDist
413 pos.x *= scale
414 pos.z *= scale
415 }
416 }
417
418 // Update sparkle particles
419 updateSparkles(delta)
420 }
421
422 return {
423 group,
424 update,
425 triggerPanic,
426 attractToBread,
427 getKoiUnderPosition,
428 startCapture,
429 updateCapture,
430 cancelCapture,
431 getKois: () => kois.filter(k => !k.state.captured).map(k => k.group)
432 }
433 }