JavaScript · 17402 bytes Raw Blame History
1 // Doug the Duck - 3D low-poly model with Wind Waker cel-shading
2 import * as THREE from 'three'
3 import { playMonch } from './sounds.js'
4
5 export function createDoug(scene, gradientMap) {
6 const group = new THREE.Group()
7
8 // Store gradientMap for accessory creation
9 const storedGradientMap = gradientMap
10
11 // Default colors - vibrant Wind Waker yellows
12 const defaultColors = {
13 body: 0xffdc50,
14 highlight: 0xfff0a0,
15 beak: 0xff9020
16 }
17
18 // Toon materials (stored for outfit swapping)
19 const bodyMaterial = new THREE.MeshToonMaterial({
20 color: defaultColors.body,
21 gradientMap: gradientMap
22 })
23
24 const highlightMaterial = new THREE.MeshToonMaterial({
25 color: defaultColors.highlight,
26 gradientMap: gradientMap
27 })
28
29 const beakMaterial = new THREE.MeshToonMaterial({
30 color: defaultColors.beak,
31 gradientMap: gradientMap
32 })
33
34 // Accessory tracking
35 const accessories = {
36 head: null,
37 face: null,
38 clothing: null
39 }
40
41 // Mount points for accessories
42 const mountPoints = {
43 head: new THREE.Vector3(0.38, 0.98, 0),
44 face: new THREE.Vector3(0.72, 0.78, 0)
45 }
46
47 const eyeWhite = 0xffffff
48 const eyePupil = 0x191410
49
50 const eyeWhiteMaterial = new THREE.MeshToonMaterial({
51 color: eyeWhite,
52 gradientMap: gradientMap
53 })
54
55 const eyePupilMaterial = new THREE.MeshBasicMaterial({
56 color: eyePupil
57 })
58
59 // Body - elongated pear shape using multiple parts
60 // Main body (more oval, less ball)
61 const bodyGeom = new THREE.SphereGeometry(0.42, 8, 6) // Lower poly for angular look
62 bodyGeom.scale(1.4, 0.75, 0.9)
63 const body = new THREE.Mesh(bodyGeom, bodyMaterial)
64 body.position.y = 0.28
65 body.rotation.z = 0.1 // Slight tilt forward
66 group.add(body)
67
68 // Rear bump (makes it pear-shaped)
69 const rearGeom = new THREE.SphereGeometry(0.28, 6, 5)
70 rearGeom.scale(1, 0.8, 0.9)
71 const rear = new THREE.Mesh(rearGeom, bodyMaterial)
72 rear.position.set(-0.35, 0.22, 0)
73 group.add(rear)
74
75 // Chest puff (front bump)
76 const chestGeom = new THREE.SphereGeometry(0.25, 6, 5)
77 chestGeom.scale(0.8, 0.9, 0.85)
78 const chest = new THREE.Mesh(chestGeom, highlightMaterial)
79 chest.position.set(0.25, 0.32, 0)
80 group.add(chest)
81
82 // Subtle feather tufts on body (toned down)
83 const tuftMaterial = bodyMaterial
84 const featherPositions = [
85 { x: -0.38, y: 0.44, z: 0.1, rx: 0.2, rz: 0.3 },
86 { x: -0.38, y: 0.44, z: -0.1, rx: -0.2, rz: 0.3 },
87 ]
88
89 for (const f of featherPositions) {
90 const featherGeom = new THREE.ConeGeometry(0.04, 0.12, 4)
91 const feather = new THREE.Mesh(featherGeom, tuftMaterial)
92 feather.position.set(f.x, f.y, f.z)
93 feather.rotation.x = f.rx
94 feather.rotation.z = f.rz
95 group.add(feather)
96 }
97
98 // Tail feathers - subtle curl up
99 const tailGroup = new THREE.Group()
100 const tailFeathers = [
101 { x: -0.55, y: 0.36, z: 0, rx: 1.6, rz: 0, scale: 1.0 },
102 { x: -0.52, y: 0.40, z: 0.08, rx: 1.4, rz: 0.2, scale: 0.7 },
103 { x: -0.52, y: 0.40, z: -0.08, rx: 1.4, rz: -0.2, scale: 0.7 },
104 ]
105
106 for (const t of tailFeathers) {
107 const tailGeom = new THREE.ConeGeometry(0.04, 0.18, 4)
108 const tail = new THREE.Mesh(tailGeom, bodyMaterial)
109 tail.position.set(t.x, t.y, t.z)
110 tail.rotation.x = t.rx
111 tail.rotation.z = t.rz
112 tail.scale.setScalar(t.scale)
113 tailGroup.add(tail)
114 }
115 group.add(tailGroup)
116
117 // Wings - more angular, feather-like
118 const wingGeom = new THREE.ConeGeometry(0.18, 0.4, 4)
119
120 const leftWing = new THREE.Mesh(wingGeom, bodyMaterial)
121 leftWing.position.set(-0.1, 0.32, 0.38)
122 leftWing.rotation.x = 1.2
123 leftWing.rotation.z = 0.4
124 group.add(leftWing)
125
126 const rightWing = new THREE.Mesh(wingGeom, bodyMaterial)
127 rightWing.position.set(-0.1, 0.32, -0.38)
128 rightWing.rotation.x = -1.2
129 rightWing.rotation.z = 0.4
130 group.add(rightWing)
131
132 // Wing feather details
133 const wingFeatherGeom = new THREE.ConeGeometry(0.08, 0.22, 3)
134
135 const leftWingFeather = new THREE.Mesh(wingFeatherGeom, bodyMaterial)
136 leftWingFeather.position.set(-0.2, 0.28, 0.42)
137 leftWingFeather.rotation.x = 1.4
138 leftWingFeather.rotation.z = 0.6
139 group.add(leftWingFeather)
140
141 const rightWingFeather = new THREE.Mesh(wingFeatherGeom, bodyMaterial)
142 rightWingFeather.position.set(-0.2, 0.28, -0.42)
143 rightWingFeather.rotation.x = -1.4
144 rightWingFeather.rotation.z = 0.6
145 group.add(rightWingFeather)
146
147 // Head - slightly egg-shaped, not perfectly round
148 const headGeom = new THREE.SphereGeometry(0.28, 7, 6)
149 headGeom.scale(1.1, 1.0, 0.95)
150 const head = new THREE.Mesh(headGeom, bodyMaterial)
151 head.position.set(0.42, 0.68, 0)
152 group.add(head)
153
154 // Cheek puffs
155 const cheekGeom = new THREE.SphereGeometry(0.1, 5, 4)
156 const leftCheek = new THREE.Mesh(cheekGeom, highlightMaterial)
157 leftCheek.position.set(0.48, 0.62, 0.18)
158 leftCheek.scale.set(0.8, 0.7, 0.6)
159 group.add(leftCheek)
160
161 const rightCheek = new THREE.Mesh(cheekGeom, highlightMaterial)
162 rightCheek.position.set(0.48, 0.62, -0.18)
163 rightCheek.scale.set(0.8, 0.7, 0.6)
164 group.add(rightCheek)
165
166 // Head tuft - simple pair of feathers
167 const tuftGeom = new THREE.ConeGeometry(0.035, 0.12, 4)
168 const tuft1 = new THREE.Mesh(tuftGeom, bodyMaterial)
169 tuft1.position.set(0.34, 0.93, 0.03)
170 tuft1.rotation.z = -0.3
171 tuft1.rotation.x = 0.2
172 group.add(tuft1)
173
174 const tuft2 = new THREE.Mesh(tuftGeom, bodyMaterial)
175 tuft2.position.set(0.34, 0.93, -0.03)
176 tuft2.rotation.z = -0.3
177 tuft2.rotation.x = -0.2
178 group.add(tuft2)
179
180 // Beak - flatter, more duck-like
181 const beakGeom = new THREE.ConeGeometry(0.09, 0.32, 6)
182 beakGeom.scale(1, 1, 0.6) // Flatten it
183 const beak = new THREE.Mesh(beakGeom, beakMaterial)
184 beak.rotation.z = -Math.PI / 2
185 beak.position.set(0.75, 0.62, 0)
186 group.add(beak)
187
188 // Eyes - big and expressive Wind Waker style
189 const eyeGeom = new THREE.SphereGeometry(0.1, 12, 8)
190
191 const leftEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
192 leftEye.position.set(0.65, 0.78, 0.15)
193 leftEye.scale.set(0.8, 1, 0.6)
194 group.add(leftEye)
195
196 const rightEye = new THREE.Mesh(eyeGeom, eyeWhiteMaterial)
197 rightEye.position.set(0.65, 0.78, -0.15)
198 rightEye.scale.set(0.8, 1, 0.6)
199 group.add(rightEye)
200
201 // Pupils
202 const pupilGeom = new THREE.SphereGeometry(0.05, 8, 6)
203
204 const leftPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
205 leftPupil.position.set(0.72, 0.78, 0.15)
206 group.add(leftPupil)
207
208 const rightPupil = new THREE.Mesh(pupilGeom, eyePupilMaterial)
209 rightPupil.position.set(0.72, 0.78, -0.15)
210 group.add(rightPupil)
211
212 // Eye shine
213 const shineGeom = new THREE.SphereGeometry(0.025, 6, 4)
214 const shineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
215
216 const leftShine = new THREE.Mesh(shineGeom, shineMaterial)
217 leftShine.position.set(0.73, 0.82, 0.13)
218 group.add(leftShine)
219
220 const rightShine = new THREE.Mesh(shineGeom, shineMaterial)
221 rightShine.position.set(0.73, 0.82, -0.17)
222 group.add(rightShine)
223
224 // Add to scene
225 scene.add(group)
226
227 // Duck state
228 const state = {
229 position: new THREE.Vector3(0, 0, 0),
230 targetPosition: new THREE.Vector3(0, 0, 0),
231 rotation: 0,
232 targetRotation: 0,
233 mode: 'idle', // 'idle', 'waiting', 'swimming'
234 waitTimer: 0,
235 waitDuration: 0,
236 idleTimer: 0,
237 nextIdleMove: 3 + Math.random() * 4,
238 wobble: 0,
239 headBob: 0,
240 rippleTimer: 0,
241 isMoving: false
242 }
243
244 // Movement speeds
245 const idleSpeed = 0.5
246 const swimSpeed = 1.2
247 const turnSpeed = 4 // radians per second
248
249 // Helper to lerp angles properly (handles wraparound)
250 function lerpAngle(from, to, t) {
251 let diff = to - from
252 // Normalize to -PI to PI
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 pickIdleTarget(pond) {
259 const angle = Math.random() * Math.PI * 2
260 const radius = Math.random() * (pond.radius * 0.7) + pond.radius * 0.1
261 state.targetPosition.set(
262 Math.cos(angle) * radius,
263 0,
264 Math.sin(angle) * radius
265 )
266 }
267
268 function update(delta, elapsed, breadBits, pond, options = {}) {
269 const { paused = false, focusTarget = null } = options
270
271 // When paused (during dialog), stop movement but keep animations
272 if (paused) {
273 state.isMoving = false
274
275 // If there's a focus target, slowly turn to face it
276 if (focusTarget) {
277 const dx = focusTarget.x - state.position.x
278 const dz = focusTarget.z - state.position.z
279 const targetAngle = Math.atan2(dx, dz)
280 state.rotation = lerpAngle(state.rotation, targetAngle, delta * 2)
281 }
282
283 // Apply position (no movement, just stay in place)
284 group.position.x = state.position.x
285 group.position.z = state.position.z
286
287 // Gentle bobbing
288 const bobAmount = Math.sin(elapsed * 2)
289 group.position.y = bobAmount * 0.03
290
291 // Rotation
292 group.rotation.y = state.rotation - Math.PI / 2
293
294 // Subtle idle animations
295 leftWing.rotation.z = Math.sin(elapsed * 2) * 0.05
296 rightWing.rotation.z = -Math.sin(elapsed * 2) * 0.05
297 head.position.y = 0.7 + Math.sin(elapsed * 1.5) * 0.02
298
299 // Occasional idle ripple
300 state.rippleTimer += delta
301 if (state.rippleTimer > 2 && bobAmount < -0.9) {
302 state.rippleTimer = 0
303 pond.addRipple(state.position.x, state.position.z)
304 }
305
306 return
307 }
308
309 // Find closest bread
310 let closestBread = null
311 let closestDist = Infinity
312
313 for (const bread of breadBits) {
314 if (!bread.eaten) {
315 const dx = bread.position.x - state.position.x
316 const dz = bread.position.z - state.position.z
317 const dist = Math.sqrt(dx * dx + dz * dz)
318 if (dist < closestDist) {
319 closestDist = dist
320 closestBread = bread
321 }
322 }
323 }
324
325 // React to bread
326 if (closestBread && state.mode === 'idle') {
327 state.mode = 'waiting'
328 state.waitTimer = 0
329 state.waitDuration = 0.5 + Math.random() * 0.8 // Quirky delay
330 state.pendingTarget = closestBread.position.clone()
331 }
332
333 // Handle waiting (the quirky pause)
334 if (state.mode === 'waiting') {
335 state.waitTimer += delta
336 state.headBob = Math.sin(state.waitTimer * 8) * 0.1
337 head.position.y = 0.7 + state.headBob
338
339 if (state.waitTimer >= state.waitDuration) {
340 state.mode = 'swimming'
341 state.targetPosition.copy(state.pendingTarget)
342 }
343 }
344
345 // Movement
346 const dx = state.targetPosition.x - state.position.x
347 const dz = state.targetPosition.z - state.position.z
348 const dist = Math.sqrt(dx * dx + dz * dz)
349
350 if (dist > 0.1) {
351 // Calculate desired rotation to face target
352 state.targetRotation = Math.atan2(dx, dz)
353
354 // Smoothly turn toward target direction
355 state.rotation = lerpAngle(state.rotation, state.targetRotation, turnSpeed * delta)
356
357 // Check if we're facing roughly the right direction (within ~30 degrees)
358 let angleDiff = Math.abs(state.targetRotation - state.rotation)
359 while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
360 angleDiff = Math.abs(angleDiff)
361
362 // Only move forward if facing the right way
363 if (angleDiff < 0.5) {
364 const speed = state.mode === 'swimming' ? swimSpeed : idleSpeed
365 const moveAmount = Math.min(speed * delta, dist)
366
367 // Move in the direction Doug is FACING (not toward target directly)
368 const moveX = Math.sin(state.rotation) * moveAmount
369 const moveZ = Math.cos(state.rotation) * moveAmount
370
371 state.position.x += moveX
372 state.position.z += moveZ
373
374 // Wobble animation while moving
375 state.wobble += delta * 8
376 state.isMoving = true
377
378 // Create swimming ripples
379 state.rippleTimer += delta
380 const rippleInterval = state.mode === 'swimming' ? 0.25 : 0.5
381 if (state.rippleTimer >= rippleInterval) {
382 state.rippleTimer = 0
383 // Ripple slightly behind the duck
384 const rippleX = state.position.x - Math.sin(state.rotation) * 0.3
385 const rippleZ = state.position.z - Math.cos(state.rotation) * 0.3
386 pond.addRipple(rippleX, rippleZ)
387 }
388 } else {
389 state.isMoving = false
390 }
391 } else {
392 state.isMoving = false
393 // Arrived
394 if (state.mode === 'swimming') {
395 state.mode = 'idle'
396 // Eat nearby bread - create splash ripple!
397 for (const bread of breadBits) {
398 if (!bread.eaten) {
399 const bx = bread.position.x - state.position.x
400 const bz = bread.position.z - state.position.z
401 if (Math.sqrt(bx * bx + bz * bz) < 0.4) {
402 bread.eaten = true
403 // Eating splash ripple
404 pond.addRipple(state.position.x, state.position.z)
405 // Damp crunch sound
406 playMonch()
407 }
408 }
409 }
410 }
411 }
412
413 // Idle wandering
414 if (state.mode === 'idle' && !closestBread) {
415 state.idleTimer += delta
416 if (state.idleTimer >= state.nextIdleMove) {
417 pickIdleTarget(pond)
418 state.idleTimer = 0
419 state.nextIdleMove = 3 + Math.random() * 4
420 }
421 }
422
423 // Apply transforms
424 group.position.x = state.position.x
425 group.position.z = state.position.z
426
427 // Bobbing on water
428 const bobAmount = Math.sin(elapsed * 2)
429 group.position.y = bobAmount * 0.03
430
431 // Occasional idle bob ripples (when bobbing down)
432 if (!state.isMoving && bobAmount < -0.9 && state.rippleTimer > 1.5) {
433 state.rippleTimer = 0
434 pond.addRipple(state.position.x, state.position.z)
435 }
436 if (!state.isMoving) {
437 state.rippleTimer += delta
438 }
439
440 // Rotation (face direction of movement)
441 // Duck model faces +X locally, so offset by -PI/2 to align with movement
442 group.rotation.y = state.rotation - Math.PI / 2
443
444 // Body wobble while swimming
445 const wobbleAmount = Math.sin(state.wobble) * 0.08
446 body.rotation.z = wobbleAmount
447 head.position.x = 0.45 + wobbleAmount * 0.2
448
449 // Wing flap animation
450 leftWing.rotation.z = Math.sin(elapsed * 3) * 0.1
451 rightWing.rotation.z = -Math.sin(elapsed * 3) * 0.1
452
453 // Gentle head bob
454 if (state.mode !== 'waiting') {
455 head.position.y = 0.7 + Math.sin(elapsed * 2) * 0.02
456 }
457
458 // Animate head accessories (propeller beanie, etc.)
459 if (accessories.head) {
460 accessories.head.traverse((child) => {
461 if (child.userData.isPropeller) {
462 child.rotation.y = elapsed * 8 // Fast spin!
463 }
464 })
465 }
466 }
467
468 // Apply an outfit to Doug
469 function applyOutfit(outfit) {
470 if (!outfit) return
471
472 switch (outfit.type) {
473 case 'color_body':
474 if (outfit.colors) {
475 if (outfit.colors.body) bodyMaterial.color.setHex(outfit.colors.body)
476 if (outfit.colors.highlight) highlightMaterial.color.setHex(outfit.colors.highlight)
477 }
478 break
479
480 case 'color_accent':
481 if (outfit.colors && outfit.colors.beak) {
482 beakMaterial.color.setHex(outfit.colors.beak)
483 }
484 break
485
486 case 'accessory_head':
487 // Remove existing head accessory
488 if (accessories.head) {
489 group.remove(accessories.head)
490 accessories.head = null
491 }
492 // Add new accessory
493 if (outfit.meshFactory) {
494 accessories.head = outfit.meshFactory(storedGradientMap)
495 accessories.head.position.copy(mountPoints.head)
496 group.add(accessories.head)
497 }
498 break
499
500 case 'accessory_face':
501 // Remove existing face accessory
502 if (accessories.face) {
503 group.remove(accessories.face)
504 accessories.face = null
505 }
506 // Add new accessory
507 if (outfit.meshFactory) {
508 accessories.face = outfit.meshFactory(storedGradientMap)
509 accessories.face.position.copy(mountPoints.face)
510 accessories.face.rotation.y = -Math.PI / 2 // Face forward
511 group.add(accessories.face)
512 }
513 break
514
515 case 'clothing_body':
516 // Remove existing clothing
517 if (accessories.clothing) {
518 group.remove(accessories.clothing)
519 accessories.clothing = null
520 }
521 // Add new clothing
522 if (outfit.meshFactory) {
523 accessories.clothing = outfit.meshFactory(storedGradientMap)
524 // Clothing is positioned relative to body origin
525 group.add(accessories.clothing)
526 }
527 break
528 }
529 }
530
531 // Remove an outfit from Doug
532 function removeOutfit(outfit) {
533 if (!outfit) return
534
535 switch (outfit.type) {
536 case 'color_body':
537 bodyMaterial.color.setHex(defaultColors.body)
538 highlightMaterial.color.setHex(defaultColors.highlight)
539 break
540
541 case 'color_accent':
542 beakMaterial.color.setHex(defaultColors.beak)
543 break
544
545 case 'accessory_head':
546 if (accessories.head) {
547 group.remove(accessories.head)
548 accessories.head = null
549 }
550 break
551
552 case 'accessory_face':
553 if (accessories.face) {
554 group.remove(accessories.face)
555 accessories.face = null
556 }
557 break
558
559 case 'clothing_body':
560 if (accessories.clothing) {
561 group.remove(accessories.clothing)
562 accessories.clothing = null
563 }
564 break
565 }
566 }
567
568 return {
569 group,
570 update,
571 getPosition: () => state.position.clone(),
572 applyOutfit,
573 removeOutfit
574 }
575 }