JavaScript · 6166 bytes Raw Blame History
1 // Koi fish - natural 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: 0x222222 }, // 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 and direction
22 const angle = Math.random() * Math.PI * 2
23 const dist = Math.random() * pondRadius * 0.6 + pondRadius * 0.1
24
25 koi.group.position.set(
26 Math.cos(angle) * dist,
27 -0.08,
28 Math.sin(angle) * dist
29 )
30
31 // Face a random direction to start
32 koi.group.rotation.y = Math.random() * Math.PI * 2
33
34 koi.state = {
35 speed: 0.3 + Math.random() * 0.3,
36 turnRate: 0, // Current turning rate
37 targetTurnRate: 0,
38 turnTimer: Math.random() * 3,
39 panicTimer: 0,
40 flickerPhase: Math.random() * Math.PI * 2
41 }
42
43 group.add(koi.group)
44 kois.push(koi)
45 }
46
47 scene.add(group)
48
49 function createKoi(gradientMap, colors) {
50 const koiGroup = new THREE.Group()
51
52 const bodyMaterial = new THREE.MeshToonMaterial({
53 color: colors.body,
54 gradientMap: gradientMap
55 })
56
57 const spotMaterial = new THREE.MeshToonMaterial({
58 color: colors.spots,
59 gradientMap: gradientMap
60 })
61
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
65 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
66 koiGroup.add(body)
67
68 // Head - at +Z end
69 const headGeom = new THREE.SphereGeometry(0.07, 5, 4)
70 headGeom.scale(0.9, 0.7, 1)
71 const head = new THREE.Mesh(headGeom, bodyMaterial)
72 head.position.set(0, 0.01, 0.18)
73 koiGroup.add(head)
74
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
78 const tail = new THREE.Mesh(tailGeom, bodyMaterial)
79 tail.position.set(0, 0, -0.22)
80 koiGroup.add(tail)
81
82 // Spots on top
83 const spotCount = 2 + Math.floor(Math.random() * 2)
84 for (let i = 0; i < spotCount; i++) {
85 const spotGeom = new THREE.SphereGeometry(0.03 + Math.random() * 0.02, 4, 3)
86 const spot = new THREE.Mesh(spotGeom, spotMaterial)
87 spot.position.set(
88 (Math.random() - 0.5) * 0.06,
89 0.04,
90 (Math.random() - 0.5) * 0.12
91 )
92 spot.scale.y = 0.4
93 koiGroup.add(spot)
94 }
95
96 // Dorsal fin
97 const dorsalGeom = new THREE.ConeGeometry(0.02, 0.06, 3)
98 const dorsal = new THREE.Mesh(dorsalGeom, bodyMaterial)
99 dorsal.position.set(0, 0.055, -0.03)
100 koiGroup.add(dorsal)
101
102 // Scale the whole koi
103 koiGroup.scale.setScalar(0.9)
104
105 return { group: koiGroup, tail }
106 }
107
108 function triggerPanic(x, z) {
109 for (const koi of kois) {
110 const dist = Math.hypot(
111 koi.group.position.x - x,
112 koi.group.position.z - z
113 )
114
115 if (dist < 1.5) {
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
122 )
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
128 }
129 }
130 }
131
132 function update(delta, elapsed) {
133 for (const koi of kois) {
134 const s = koi.state
135 const pos = koi.group.position
136
137 // Update panic
138 const isPanicked = s.panicTimer > 0
139 if (isPanicked) {
140 s.panicTimer -= delta
141 }
142
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
149 }
150
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
162 }
163
164 // Smooth turn rate changes
165 s.turnRate += (s.targetTurnRate - s.turnRate) * delta * 2
166
167 // Apply rotation
168 koi.group.rotation.y += s.turnRate * delta
169
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
172
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
194 }
195 }
196 }
197
198 return {
199 group,
200 update,
201 triggerPanic,
202 getKois: () => kois.map(k => k.group)
203 }
204 }