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