JavaScript · 18251 bytes Raw Blame History
1 // Donny the Narwhal - distinguished gentleman of the deep
2 import * as THREE from 'three'
3
4 export function createDonny(scene, gradientMap) {
5 const group = new THREE.Group()
6
7 // Store gradientMap for accessory creation
8 const storedGradientMap = gradientMap
9
10 // Default colors
11 const defaultColors = {
12 body: 0x7a9eb8,
13 belly: 0xc8d8e4,
14 monocleRim: 0xd4af37,
15 monocleGlass: 0x88ccff
16 }
17
18 // Materials (stored for outfit swapping)
19 const bodyMaterial = new THREE.MeshToonMaterial({
20 color: defaultColors.body,
21 gradientMap: gradientMap
22 })
23
24 const bellyMaterial = new THREE.MeshToonMaterial({
25 color: defaultColors.belly,
26 gradientMap: gradientMap
27 })
28
29 const tuskMaterial = new THREE.MeshToonMaterial({
30 color: 0xf5f0e6,
31 gradientMap: gradientMap
32 })
33
34 const monocleMaterial = new THREE.MeshToonMaterial({
35 color: defaultColors.monocleRim,
36 gradientMap: gradientMap
37 })
38
39 const glassMaterial = new THREE.MeshBasicMaterial({
40 color: defaultColors.monocleGlass,
41 transparent: true,
42 opacity: 0.3
43 })
44
45 // Accessory tracking
46 const accessories = {
47 head: null
48 }
49
50 // Mount points
51 const mountPoints = {
52 head: new THREE.Vector3(1.0, 0.55, 0)
53 }
54
55 // Main body - elongated oval
56 const bodyGeom = new THREE.SphereGeometry(0.5, 8, 6)
57 bodyGeom.scale(2.2, 0.7, 0.8)
58 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
59 body.position.y = 0.1
60 group.add(body)
61
62 // Belly
63 const bellyGeom = new THREE.SphereGeometry(0.4, 8, 6)
64 bellyGeom.scale(1.8, 0.5, 0.7)
65 const belly = new THREE.Mesh(bellyGeom, bellyMaterial)
66 belly.position.set(0, -0.05, 0)
67 group.add(belly)
68
69 // Head bump
70 const headGeom = new THREE.SphereGeometry(0.35, 8, 6)
71 headGeom.scale(1.2, 1, 1)
72 const head = new THREE.Mesh(headGeom, bodyMaterial)
73 head.position.set(1.0, 0.25, 0)
74 group.add(head)
75
76 // The magnificent tusk! - positioned so base touches front of head
77 const tuskGeom = new THREE.ConeGeometry(0.05, 1.4, 6)
78 // Shift geometry so base is at origin, tip extends in +Y
79 tuskGeom.translate(0, 0.7, 0)
80 const tusk = new THREE.Mesh(tuskGeom, tuskMaterial)
81 // Position at front of head
82 tusk.position.set(1.35, 0.4, 0)
83 // Rotate to point forward and slightly up
84 tusk.rotation.z = -Math.PI / 2 + 0.2
85 group.add(tusk)
86
87 // Eyes
88 const eyeGeom = new THREE.SphereGeometry(0.08, 8, 6)
89 const eyeMaterial = new THREE.MeshBasicMaterial({ color: 0x1a1a1a })
90
91 const leftEye = new THREE.Mesh(eyeGeom, eyeMaterial)
92 leftEye.position.set(1.15, 0.38, 0.28)
93 group.add(leftEye)
94
95 const rightEye = new THREE.Mesh(eyeGeom, eyeMaterial)
96 rightEye.position.set(1.15, 0.38, -0.28)
97 group.add(rightEye)
98
99 // Eye shines
100 const shineGeom = new THREE.SphereGeometry(0.025, 6, 4)
101 const shineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
102
103 const leftShine = new THREE.Mesh(shineGeom, shineMaterial)
104 leftShine.position.set(1.18, 0.42, 0.3)
105 group.add(leftShine)
106
107 const rightShine = new THREE.Mesh(shineGeom, shineMaterial)
108 rightShine.position.set(1.18, 0.42, -0.26)
109 group.add(rightShine)
110
111 // THE MONOCLE - on right eye (our left when facing)
112 const monocleGroup = new THREE.Group()
113
114 // Monocle rim
115 const rimGeom = new THREE.TorusGeometry(0.12, 0.015, 8, 16)
116 const rim = new THREE.Mesh(rimGeom, monocleMaterial)
117 monocleGroup.add(rim)
118
119 // Monocle glass
120 const glassGeom = new THREE.CircleGeometry(0.11, 16)
121 const glass = new THREE.Mesh(glassGeom, glassMaterial)
122 glass.position.z = 0.01
123 monocleGroup.add(glass)
124
125 // Monocle chain attachment
126 const chainStartGeom = new THREE.SphereGeometry(0.02, 6, 4)
127 const chainStart = new THREE.Mesh(chainStartGeom, monocleMaterial)
128 chainStart.position.set(0, -0.12, 0)
129 monocleGroup.add(chainStart)
130
131 // Chain (simple dangling segments)
132 const chainMaterial = new THREE.MeshToonMaterial({
133 color: defaultColors.monocleRim,
134 gradientMap: gradientMap
135 })
136 for (let i = 0; i < 4; i++) {
137 const linkGeom = new THREE.TorusGeometry(0.018, 0.005, 4, 8)
138 const link = new THREE.Mesh(linkGeom, chainMaterial)
139 link.position.set(0, -0.16 - i * 0.05, 0)
140 link.rotation.x = i % 2 === 0 ? 0 : Math.PI / 2
141 monocleGroup.add(link)
142 }
143
144 monocleGroup.position.set(1.22, 0.38, -0.32)
145 monocleGroup.rotation.y = -0.3
146 group.add(monocleGroup)
147
148 // Flippers
149 const flipperGeom = new THREE.ConeGeometry(0.15, 0.5, 4)
150
151 const leftFlipper = new THREE.Mesh(flipperGeom, bodyMaterial)
152 leftFlipper.position.set(0.3, -0.1, 0.45)
153 leftFlipper.rotation.x = 0.5
154 leftFlipper.rotation.z = 2.2
155 group.add(leftFlipper)
156
157 const rightFlipper = new THREE.Mesh(flipperGeom, bodyMaterial)
158 rightFlipper.position.set(0.3, -0.1, -0.45)
159 rightFlipper.rotation.x = -0.5
160 rightFlipper.rotation.z = 2.2
161 group.add(rightFlipper)
162
163 // Tail flukes
164 const flukeGeom = new THREE.ConeGeometry(0.2, 0.4, 4)
165
166 const leftFluke = new THREE.Mesh(flukeGeom, bodyMaterial)
167 leftFluke.position.set(-1.2, 0.15, 0.15)
168 leftFluke.rotation.z = 1.8
169 leftFluke.rotation.y = 0.3
170 group.add(leftFluke)
171
172 const rightFluke = new THREE.Mesh(flukeGeom, bodyMaterial)
173 rightFluke.position.set(-1.2, 0.15, -0.15)
174 rightFluke.rotation.z = 1.8
175 rightFluke.rotation.y = -0.3
176 group.add(rightFluke)
177
178 // Donny starts hidden below the water
179 group.position.y = -3
180 group.visible = false
181
182 scene.add(group)
183
184 // State
185 const state = {
186 mode: 'waiting', // 'waiting', 'rumbling', 'emerging', 'surfaced', 'submerging', 'shop_approaching', 'shop_ready', 'shop_departing'
187 timer: 30 + Math.random() * 30, // First appearance in 30-60 seconds
188 emergeX: 0,
189 emergeZ: 0,
190 surfaceTime: 0,
191 // Shop state
192 shopMode: false,
193 shopCooldown: 0,
194 onShopReady: null
195 }
196
197 // Shop trigger thresholds
198 const SHOP_KOI_THRESHOLD = 5 // Lower threshold for first shop experience
199 const SHOP_COOLDOWN = 45 // seconds between shop approaches
200
201 function startRumble(pond) {
202 state.mode = 'rumbling'
203 state.timer = 0
204
205 // Pick random spot in the pond, avoiding forbidden zones (dock, boat)
206 let attempts = 0
207 let angle, dist
208 do {
209 angle = Math.random() * Math.PI * 2
210 dist = Math.random() * pond.radius * 0.5 + pond.radius * 0.2
211 state.emergeX = Math.cos(angle) * dist
212 state.emergeZ = Math.sin(angle) * dist
213 attempts++
214 } while (!pond.isValidEmergenceSpot(state.emergeX, state.emergeZ) && attempts < 20)
215
216 group.position.x = state.emergeX
217 group.position.z = state.emergeZ
218 group.rotation.y = angle + Math.PI / 2 // Face outward-ish
219 }
220
221 function startShopApproach(pond, doug) {
222 state.mode = 'shop_approaching'
223 state.shopMode = true
224 state.timer = 0
225
226 // Emerge near Doug
227 const dougPos = doug.getPosition()
228 const approachAngle = Math.random() * Math.PI * 2
229 const approachDist = 1.5 // Close to Doug
230
231 state.emergeX = dougPos.x + Math.cos(approachAngle) * approachDist
232 state.emergeZ = dougPos.z + Math.sin(approachAngle) * approachDist
233
234 // Clamp to pond bounds
235 const distFromCenter = Math.hypot(state.emergeX, state.emergeZ)
236 if (distFromCenter > pond.radius * 0.85) {
237 const scale = (pond.radius * 0.85) / distFromCenter
238 state.emergeX *= scale
239 state.emergeZ *= scale
240 }
241
242 group.position.x = state.emergeX
243 group.position.z = state.emergeZ
244 group.visible = true
245 group.position.y = -2
246 }
247
248 // Helper to smoothly interpolate angles
249 function lerpAngle(from, to, t) {
250 let diff = to - from
251 while (diff > Math.PI) diff -= Math.PI * 2
252 while (diff < -Math.PI) diff += Math.PI * 2
253 return from + diff * t
254 }
255
256 function update(delta, elapsed, pond, doug) {
257 state.timer += delta
258
259 // Calculate angle to face Doug
260 // Model faces +X, so we offset by -PI/2
261 let angleToDoug = 0
262 if (doug) {
263 const dougPos = doug.getPosition()
264 const dx = dougPos.x - group.position.x
265 const dz = dougPos.z - group.position.z
266 angleToDoug = Math.atan2(dx, dz) - Math.PI / 2
267 }
268
269 switch (state.mode) {
270 case 'waiting':
271 if (state.timer >= 60) {
272 startRumble(pond)
273 }
274 break
275
276 case 'rumbling':
277 // Create rumble ripples
278 if (state.timer < 2) {
279 if (Math.random() < delta * 8) {
280 const rx = state.emergeX + (Math.random() - 0.5) * 0.8
281 const rz = state.emergeZ + (Math.random() - 0.5) * 0.8
282 pond.addRipple(rx, rz)
283 }
284 } else {
285 state.mode = 'emerging'
286 state.timer = 0
287 group.visible = true
288 group.position.y = -2
289 // Start facing Doug
290 group.rotation.y = angleToDoug
291 }
292 break
293
294 case 'emerging':
295 // Rise from the water
296 const emergeProgress = Math.min(state.timer / 1.5, 1)
297 const easeOut = 1 - Math.pow(1 - emergeProgress, 3)
298 group.position.y = -2 + easeOut * 2.25 // Rise higher out of water (10% less)
299
300 // Slowly turn toward Doug - lugubrious, not laser tracking
301 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.5)
302
303 // Tilt nose UP ~50 degrees - rotate around Z since model faces +X (8% less)
304 group.rotation.z = 0.87 * easeOut
305
306 // Gentle side-to-side rocking
307 group.rotation.x = Math.sin(state.timer * 4) * 0.06
308
309 if (emergeProgress >= 1) {
310 state.mode = 'surfaced'
311 state.timer = 0
312 state.surfaceTime = 4 + Math.random() * 3 // Stay 4-7 seconds
313 }
314 break
315
316 case 'surfaced':
317 // Bob gently, positioned at adjusted height
318 group.position.y = 0.25 + Math.sin(elapsed * 2) * 0.06
319
320 // Slowly turn toward Doug
321 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.3)
322
323 // Keep tilt ~50 degrees - nose up, tail in water
324 group.rotation.z = 0.87 + Math.sin(elapsed * 1.5) * 0.04
325
326 // Gentle side-to-side rocking
327 group.rotation.x = Math.sin(elapsed * 1.5) * 0.03
328
329 // Gentle flipper animation
330 leftFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3) * 0.15
331 rightFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3 + 0.5) * 0.15
332
333 // Occasional ripples
334 if (Math.random() < delta * 0.5) {
335 pond.addRipple(
336 group.position.x + (Math.random() - 0.5) * 0.5,
337 group.position.z + (Math.random() - 0.5) * 0.5
338 )
339 }
340
341 if (state.timer >= state.surfaceTime) {
342 state.mode = 'submerging'
343 state.timer = 0
344 }
345 break
346
347 case 'submerging':
348 // Sink back down
349 const submergeProgress = Math.min(state.timer / 1.2, 1)
350 const easeIn = Math.pow(submergeProgress, 2)
351 group.position.y = 0.25 - easeIn * 2.5
352
353 // Tilt nose down as diving back under
354 group.rotation.z = 0.87 - easeIn * 1.1
355
356 // Add bubbles/ripples as submerging
357 if (Math.random() < delta * 4) {
358 pond.addRipple(
359 group.position.x + (Math.random() - 0.5) * 0.6,
360 group.position.z + (Math.random() - 0.5) * 0.6
361 )
362 }
363
364 if (submergeProgress >= 1) {
365 state.mode = 'waiting'
366 state.timer = 0
367 group.visible = false
368 group.position.y = -3
369 group.rotation.x = 0
370 group.rotation.z = 0
371 }
372 break
373
374 case 'shop_approaching':
375 // Rise from water for shop
376 const shopEmergeProgress = Math.min(state.timer / 1.2, 1)
377 const shopEaseOut = 1 - Math.pow(1 - shopEmergeProgress, 3)
378 group.position.y = -2 + shopEaseOut * 2.25
379
380 // Face Doug
381 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.8)
382 group.rotation.z = 0.87 * shopEaseOut
383
384 if (shopEmergeProgress >= 1) {
385 state.mode = 'shop_ready'
386 state.timer = 0
387 // Trigger shop UI callback
388 if (state.onShopReady) {
389 state.onShopReady('donny')
390 }
391 }
392 break
393
394 case 'shop_ready':
395 // Bob gently while shop is open
396 group.position.y = 0.25 + Math.sin(elapsed * 2) * 0.06
397 group.rotation.y = lerpAngle(group.rotation.y, angleToDoug, delta * 0.3)
398 group.rotation.z = 0.87 + Math.sin(elapsed * 1.5) * 0.04
399
400 // Follow Doug while talking - swim toward him
401 if (doug) {
402 const dougPos = doug.getPosition()
403 const dx = dougPos.x - group.position.x
404 const dz = dougPos.z - group.position.z
405 const distToDoug = Math.hypot(dx, dz)
406 const minDist = 1.0 // Keep some distance from Doug
407
408 if (distToDoug > minDist) {
409 // Swim toward Doug - faster when farther away
410 const approachSpeed = Math.min(distToDoug * 0.5, 1.5) * delta
411 group.position.x += (dx / distToDoug) * approachSpeed
412 group.position.z += (dz / distToDoug) * approachSpeed
413
414 // Clamp to pond bounds
415 const distFromCenter = Math.hypot(group.position.x, group.position.z)
416 if (distFromCenter > pond.radius * 0.85) {
417 const scale = (pond.radius * 0.85) / distFromCenter
418 group.position.x *= scale
419 group.position.z *= scale
420 }
421 }
422 }
423
424 // Flipper animation
425 leftFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3) * 0.15
426 rightFlipper.rotation.z = 2.2 + Math.sin(elapsed * 3 + 0.5) * 0.15
427
428 // Occasional ripples
429 if (Math.random() < delta * 0.5) {
430 pond.addRipple(
431 group.position.x + (Math.random() - 0.5) * 0.5,
432 group.position.z + (Math.random() - 0.5) * 0.5
433 )
434 }
435 // Stay in this state until dismissShop() is called
436 break
437
438 case 'shop_departing':
439 // Sink back down after shop closes
440 const shopSubmergeProgress = Math.min(state.timer / 1.2, 1)
441 const shopEaseIn = Math.pow(shopSubmergeProgress, 2)
442 group.position.y = 0.25 - shopEaseIn * 2.5
443 group.rotation.z = 0.87 - shopEaseIn * 1.1
444
445 if (Math.random() < delta * 4) {
446 pond.addRipple(
447 group.position.x + (Math.random() - 0.5) * 0.6,
448 group.position.z + (Math.random() - 0.5) * 0.6
449 )
450 }
451
452 if (shopSubmergeProgress >= 1) {
453 state.mode = 'waiting'
454 state.timer = 0
455 state.shopMode = false
456 state.shopCooldown = SHOP_COOLDOWN
457 group.visible = false
458 group.position.y = -3
459 group.rotation.x = 0
460 group.rotation.z = 0
461 }
462 break
463 }
464
465 // Decrement shop cooldown
466 if (state.shopCooldown > 0) {
467 state.shopCooldown -= delta
468 }
469 }
470
471 // Try to trigger shop approach (called from main loop)
472 function tryTriggerShop(koiCount, pond, doug) {
473 if (state.shopCooldown > 0) return false
474 if (koiCount < SHOP_KOI_THRESHOLD) return false
475
476 // If waiting, do full approach sequence
477 if (state.mode === 'waiting') {
478 startShopApproach(pond, doug)
479 return true
480 }
481
482 // If already surfaced, transition directly to shop mode
483 if (state.mode === 'surfaced') {
484 state.mode = 'shop_ready'
485 state.shopMode = true
486 state.timer = 0
487 if (state.onShopReady) {
488 state.onShopReady('donny')
489 }
490 return true
491 }
492
493 return false
494 }
495
496 // Dismiss shop and start departing
497 function dismissShop() {
498 if (state.mode === 'shop_ready') {
499 state.mode = 'shop_departing'
500 state.timer = 0
501 }
502 }
503
504 // Set callback for when shop is ready
505 function setShopReadyCallback(callback) {
506 state.onShopReady = callback
507 }
508
509 // Check if in shop mode
510 function isInShopMode() {
511 return state.shopMode
512 }
513
514 // Apply an outfit to Donny
515 function applyOutfit(outfit) {
516 if (!outfit) return
517
518 switch (outfit.type) {
519 case 'color_body':
520 if (outfit.colors) {
521 if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
522 if (outfit.colors.belly) bellyMaterial.color.setHex(outfit.colors.belly)
523 }
524 break
525
526 case 'accessory_face':
527 // Monocle color swap
528 if (outfit.colors) {
529 if (outfit.colors.rim) monocleMaterial.color.setHex(outfit.colors.rim)
530 if (outfit.colors.glass) glassMaterial.color.setHex(outfit.colors.glass)
531 }
532 break
533
534 case 'accessory_head':
535 // Remove existing head accessory
536 if (accessories.head) {
537 group.remove(accessories.head)
538 accessories.head = null
539 }
540 // Add new accessory
541 if (outfit.meshFactory) {
542 accessories.head = outfit.meshFactory(storedGradientMap)
543 accessories.head.position.copy(mountPoints.head)
544 group.add(accessories.head)
545 }
546 break
547 }
548 }
549
550 // Remove an outfit from Donny
551 function removeOutfit(outfit) {
552 if (!outfit) return
553
554 switch (outfit.type) {
555 case 'color_body':
556 bodyMaterial.color.setHex(defaultColors.body)
557 bellyMaterial.color.setHex(defaultColors.belly)
558 break
559
560 case 'accessory_face':
561 monocleMaterial.color.setHex(defaultColors.monocleRim)
562 glassMaterial.color.setHex(defaultColors.monocleGlass)
563 break
564
565 case 'accessory_head':
566 if (accessories.head) {
567 group.remove(accessories.head)
568 accessories.head = null
569 }
570 break
571 }
572 }
573
574 // Check if Donny can be tapped to open shop
575 function isTappable() {
576 // Tappable when visible and surfaced (not emerging/submerging)
577 return group.visible && (state.mode === 'surfaced' || state.mode === 'shop_ready')
578 }
579
580 // Manually trigger shop (for tap-to-shop feature)
581 function triggerShopFromTap(pond, doug) {
582 if (state.mode === 'surfaced') {
583 // Already surfaced - transition to shop mode
584 state.mode = 'shop_ready'
585 state.shopMode = true
586 state.timer = 0
587 if (state.onShopReady) {
588 state.onShopReady('donny')
589 }
590 return true
591 }
592 return false
593 }
594
595 return {
596 group,
597 update,
598 tryTriggerShop,
599 dismissShop,
600 setShopReadyCallback,
601 isInShopMode,
602 isTappable,
603 triggerShopFromTap,
604 applyOutfit,
605 removeOutfit
606 }
607 }