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