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